fix(update-center): align modal actions and tests

This commit is contained in:
2026-04-15 22:21:35 +08:00
parent 1410a80df5
commit f9aa31d257
6 changed files with 128 additions and 204 deletions
@@ -242,6 +242,15 @@ describe("update-center load items", () => {
}; };
} }
if (key === APTSS_NOTES_PRINT_URIS_KEY) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") { if (key === "apm list --upgradable" || key === "apm list --installed") {
return { return {
code: 127, code: 127,
@@ -299,6 +308,15 @@ describe("update-center load items", () => {
}; };
} }
if (key === APTSS_NOTES_PRINT_URIS_KEY) {
return {
code: 0,
stdout:
"'https://example.invalid/spark-notes_2.0.0_amd64.deb' spark-notes_2.0.0_amd64.deb 654321 SHA512:beadfeed",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") { if (key === "apm list --upgradable" || key === "apm list --installed") {
return { return {
code: 127, code: 127,
@@ -319,7 +337,10 @@ describe("update-center load items", () => {
currentVersion: "1.0.0", currentVersion: "1.0.0",
nextVersion: "2.0.0", nextVersion: "2.0.0",
arch: "amd64", arch: "amd64",
name: "Spark Notes", downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
}, },
]); ]);
@@ -344,6 +365,10 @@ describe("update-center load items", () => {
name: "Spark Notes", name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
}, },
]); ]);
}); });
@@ -409,6 +434,10 @@ describe("update-center load items", () => {
name: "Spark Notes", name: "Spark Notes",
remoteIcon: remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png", "https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
downloadUrl: "https://example.invalid/spark-notes_2.0.0_amd64.deb",
fileName: "spark-notes_2.0.0_amd64.deb",
size: 654321,
sha512: "beadfeed",
}, },
]); ]);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual([
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types"; import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import type { UpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue"; import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { import {
createUpdateCenterService, createUpdateCenterService,
@@ -31,6 +30,11 @@ const createItem = (): UpdateCenterItem => ({
nextVersion: "2.0.0", nextVersion: "2.0.0",
}); });
const createStartTask = (taskKey: string, id: number) => ({
taskKey,
id,
});
describe("update-center/ipc", () => { describe("update-center/ipc", () => {
beforeEach(() => { beforeEach(() => {
electronMock.getAllWindows.mockReset(); electronMock.getAllWindows.mockReset();
@@ -127,7 +131,10 @@ describe("update-center/ipc", () => {
const startHandler = handle.mock.calls.find( const startHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-start", ([channel]: [string]) => channel === "update-center-start",
)?.[1] as )?.[1] as
| ((event: unknown, taskKeys: string[]) => Promise<void>) | ((
event: unknown,
tasks: Array<{ taskKey: string; id: number }>,
) => Promise<void>)
| undefined; | undefined;
const cancelHandler = handle.mock.calls.find( const cancelHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-cancel", ([channel]: [string]) => channel === "update-center-cancel",
@@ -146,7 +153,7 @@ describe("update-center/ipc", () => {
{}, {},
{ packageName: "spark-weather", newVersion: "2.0.0" }, { packageName: "spark-weather", newVersion: "2.0.0" },
); );
await startHandler?.({}, ["aptss:spark-weather"]); await startHandler?.({}, [{ taskKey: "aptss:spark-weather", id: 1 }]);
await cancelHandler?.({}, "aptss:spark-weather"); await cancelHandler?.({}, "aptss:spark-weather");
expect(getStateHandler?.()).toEqual(snapshot); expect(getStateHandler?.()).toEqual(snapshot);
@@ -160,7 +167,9 @@ describe("update-center/ipc", () => {
packageName: "spark-weather", packageName: "spark-weather",
newVersion: "2.0.0", newVersion: "2.0.0",
}); });
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]); expect(service.start).toHaveBeenCalledWith([
{ taskKey: "aptss:spark-weather", id: 1 },
]);
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather"); expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
listener?.(snapshot); listener?.(snapshot);
@@ -169,43 +178,35 @@ describe("update-center/ipc", () => {
it("service subscribers receive state updates after refresh start and ignore", async () => { it("service subscribers receive state updates after refresh start and ignore", async () => {
let ignoredEntries = new Set<string>(); let ignoredEntries = new Set<string>();
const send = vi.fn();
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], loadItems: async () => [createItem()],
loadIgnoredEntries: async () => new Set(ignoredEntries), loadIgnoredEntries: async () => new Set(ignoredEntries),
saveIgnoredEntries: async (entries: ReadonlySet<string>) => { saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
ignoredEntries = new Set(entries); ignoredEntries = new Set(entries);
}, },
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
}); });
const snapshots: UpdateCenterServiceState[] = []; const snapshots: UpdateCenterServiceState[] = [];
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
service.subscribe((snapshot: UpdateCenterServiceState) => { service.subscribe((snapshot: UpdateCenterServiceState) => {
snapshots.push(snapshot); snapshots.push(snapshot);
}); });
await service.refresh(); await service.refresh();
await service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" }); await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true); expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"spark-weather"'),
);
expect( expect(
snapshots.some((snapshot) => snapshots.some((snapshot) =>
snapshot.tasks.some( snapshot.items.every(
(task: UpdateCenterServiceState["tasks"][number]) => (item: UpdateCenterServiceState["items"][number]) =>
task.taskKey === "aptss:spark-weather" && item.taskKey !== "aptss:spark-weather",
task.status === "completed",
), ),
), ),
).toBe(true); ).toBe(true);
@@ -215,10 +216,15 @@ describe("update-center/ipc", () => {
newVersion: "2.0.0", newVersion: "2.0.0",
}); });
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion"); expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
expect(snapshots.every((snapshot) => snapshot.tasks.length === 0)).toBe(
true,
);
expect(
snapshots.every((snapshot) => snapshot.hasRunningTasks === false),
).toBe(true);
}); });
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => { it("service item snapshots keep localIcon and remoteIcon after refresh", async () => {
let releaseTask: (() => void) | undefined;
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
{ {
@@ -227,39 +233,17 @@ describe("update-center/ipc", () => {
remoteIcon: "https://example.com/weather.png", remoteIcon: "https://example.com/weather.png",
}, },
], ],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
}); });
await service.refresh(); await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
expect(service.getState().tasks).toMatchObject([ expect(service.getState().items).toMatchObject([
{ {
taskKey: "aptss:spark-weather", taskKey: "aptss:spark-weather",
localIcon: "/icons/weather.png", localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png", remoteIcon: "https://example.com/weather.png",
status: "queued",
}, },
]); ]);
releaseTask?.();
await startPromise;
}); });
it("service item snapshots prefer resolved app names over package names", async () => { it("service item snapshots prefer resolved app names over package names", async () => {
@@ -283,178 +267,85 @@ describe("update-center/ipc", () => {
]); ]);
}); });
it("concurrent start calls still serialize through one processing pipeline", async () => { it("start forwards selected updates to the main download queue", async () => {
const startedTaskIds: number[] = []; const send = vi.fn();
const releases: Array<() => void> = [];
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
createItem(), createItem(),
{ ...createItem(), pkgname: "spark-clock" }, { ...createItem(), pkgname: "spark-clock" },
], ],
createTaskRunner: (queue: UpdateCenterQueue) => ({ });
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
startedTaskIds.push(task.id); electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releases.push(resolve);
});
queue.finishTask(task.id, "completed");
return task;
},
}),
});
await service.refresh(); await service.refresh();
await service.start([
const firstStart = service.start(["aptss:spark-weather"]); createStartTask("aptss:spark-weather", 1),
const secondStart = service.start(["aptss:spark-clock"]); createStartTask("aptss:spark-clock", 2),
await flushPromises();
expect(startedTaskIds).toEqual([1]);
releases.shift()?.();
await flushPromises();
expect(startedTaskIds).toEqual([1, 2]);
releases.shift()?.();
await Promise.all([firstStart, secondStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "completed" },
]); ]);
expect(send).toHaveBeenCalledTimes(2);
expect(service.getState().items).toEqual([]);
}); });
it("cancelling an active task stops it and leaves it cancelled", async () => { it("cancel is a no-op for update-center tasks", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], loadItems: async () => [createItem()],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
return (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id) ??
null
);
},
}),
}); });
await service.refresh(); await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
await service.cancel("aptss:spark-weather"); await service.cancel("aptss:spark-weather");
await startPromise;
expect(cancelActiveTask).toHaveBeenCalledTimes(1); expect(service.getState()).toMatchObject({
expect(service.getState().tasks).toMatchObject([ items: [{ taskKey: "aptss:spark-weather" }],
{ taskKey: "aptss:spark-weather", status: "cancelled" }, tasks: [],
]); hasRunningTasks: false,
});
}); });
it("cancelling a queued task does not abort the currently active task", async () => { it("start without a main window leaves updates actionable", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [ loadItems: async () => [
createItem(), createItem(),
{ ...createItem(), pkgname: "spark-clock" }, { ...createItem(), pkgname: "spark-clock" },
], ],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
}); });
if ( electronMock.getAllWindows.mockReturnValue([]);
queue.getSnapshot().tasks.find((entry) => entry.id === task.id)
?.status !== "cancelled"
) {
queue.finishTask(task.id, "completed");
}
return task;
},
}),
});
await service.refresh(); await service.refresh();
const activeStart = service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
await flushPromises();
const queuedStart = service.start(["aptss:spark-clock"]);
await flushPromises();
await service.cancel("aptss:spark-clock"); expect(service.getState().items).toMatchObject([
expect(cancelActiveTask).not.toHaveBeenCalled(); { taskKey: "aptss:spark-weather" },
{ taskKey: "aptss:spark-clock" },
releaseTask?.();
await Promise.all([activeStart, queuedStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "cancelled" },
]); ]);
}); });
it("superUserCmdProvider failure does not leave a task stuck in queued state", async () => { it("ignored items are not forwarded to the main download queue", async () => {
const send = vi.fn();
const service = createUpdateCenterService({ const service = createUpdateCenterService({
loadItems: async () => [createItem()], loadItems: async () => [createItem()],
superUserCmdProvider: async () => { loadIgnoredEntries: async () => new Set(["spark-weather|2.0.0"]),
throw new Error("pkexec unavailable");
},
createTaskRunner: () => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
throw new Error(
"runner should not start when privilege lookup fails",
);
},
}),
}); });
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
await service.refresh(); await service.refresh();
await service.start(["aptss:spark-weather"]); await service.start([createStartTask("aptss:spark-weather", 1)]);
expect(service.getState()).toMatchObject({ expect(service.getState()).toMatchObject({
hasRunningTasks: false, hasRunningTasks: false,
tasks: [ items: [
{ {
taskKey: "aptss:spark-weather", taskKey: "aptss:spark-weather",
status: "failed", ignored: true,
errorMessage: "pkexec unavailable",
}, },
], ],
tasks: [],
}); });
expect(send).not.toHaveBeenCalled();
}); });
it("refresh exposes load-item failures as warnings", async () => { it("refresh exposes load-item failures as warnings", async () => {
+2 -2
View File
@@ -10,7 +10,7 @@
<div <div
v-if="show" v-if="show"
v-bind="attrs" v-bind="attrs"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-slate-900/70 p-4" class="fixed inset-0 z-[70] flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal" @click.self="closeModal"
@wheel="onOverlayWheel" @wheel="onOverlayWheel"
> >
@@ -332,7 +332,7 @@
> >
<div <div
v-if="showMetaModal" v-if="showMetaModal"
class="fixed inset-0 z-[60] flex items-center justify-center overflow-hidden bg-slate-900/60 p-4" class="fixed inset-0 z-[80] flex items-center justify-center overflow-hidden bg-slate-900/60 p-4"
@click.self="closeMetaModal" @click.self="closeMetaModal"
> >
<div <div
+4
View File
@@ -146,6 +146,9 @@
</div> </div>
</div> </div>
</div> </div>
<div
class="flex flex-wrap items-center justify-end gap-2 sm:min-w-[22rem]"
>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@@ -177,6 +180,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</Transition> </Transition>
</template> </template>
+1 -1
View File
@@ -9,7 +9,7 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/80 px-4 py-10" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/80 px-4 py-10"
@click.self="closePreview" @click.self="closePreview"
> >
<div class="relative w-full max-w-5xl"> <div class="relative w-full max-w-5xl">
+1 -1
View File
@@ -9,7 +9,7 @@
> >
<div <div
v-if="show" v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 p-4" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="handleClose" @click.self="handleClose"
> >
<div <div