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") {
return {
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") {
return {
code: 127,
@@ -319,7 +337,10 @@ describe("update-center load items", () => {
currentVersion: "1.0.0",
nextVersion: "2.0.0",
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",
remoteIcon:
"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",
remoteIcon:
"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([
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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 {
createUpdateCenterService,
@@ -31,6 +30,11 @@ const createItem = (): UpdateCenterItem => ({
nextVersion: "2.0.0",
});
const createStartTask = (taskKey: string, id: number) => ({
taskKey,
id,
});
describe("update-center/ipc", () => {
beforeEach(() => {
electronMock.getAllWindows.mockReset();
@@ -127,7 +131,10 @@ describe("update-center/ipc", () => {
const startHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-start",
)?.[1] as
| ((event: unknown, taskKeys: string[]) => Promise<void>)
| ((
event: unknown,
tasks: Array<{ taskKey: string; id: number }>,
) => Promise<void>)
| undefined;
const cancelHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-cancel",
@@ -146,7 +153,7 @@ describe("update-center/ipc", () => {
{},
{ 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");
expect(getStateHandler?.()).toEqual(snapshot);
@@ -160,7 +167,9 @@ describe("update-center/ipc", () => {
packageName: "spark-weather",
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");
listener?.(snapshot);
@@ -169,43 +178,35 @@ describe("update-center/ipc", () => {
it("service subscribers receive state updates after refresh start and ignore", async () => {
let ignoredEntries = new Set<string>();
const send = vi.fn();
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
loadIgnoredEntries: async () => new Set(ignoredEntries),
saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
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[] = [];
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
service.subscribe((snapshot: UpdateCenterServiceState) => {
snapshots.push(snapshot);
});
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" });
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true);
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"spark-weather"'),
);
expect(
snapshots.some((snapshot) =>
snapshot.tasks.some(
(task: UpdateCenterServiceState["tasks"][number]) =>
task.taskKey === "aptss:spark-weather" &&
task.status === "completed",
snapshot.items.every(
(item: UpdateCenterServiceState["items"][number]) =>
item.taskKey !== "aptss:spark-weather",
),
),
).toBe(true);
@@ -215,10 +216,15 @@ describe("update-center/ipc", () => {
newVersion: "2.0.0",
});
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 () => {
let releaseTask: (() => void) | undefined;
it("service item snapshots keep localIcon and remoteIcon after refresh", async () => {
const service = createUpdateCenterService({
loadItems: async () => [
{
@@ -227,39 +233,17 @@ describe("update-center/ipc", () => {
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();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
expect(service.getState().tasks).toMatchObject([
expect(service.getState().items).toMatchObject([
{
taskKey: "aptss:spark-weather",
localIcon: "/icons/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 () => {
@@ -283,178 +267,85 @@ describe("update-center/ipc", () => {
]);
});
it("concurrent start calls still serialize through one processing pipeline", async () => {
const startedTaskIds: number[] = [];
const releases: Array<() => void> = [];
it("start forwards selected updates to the main download queue", async () => {
const send = vi.fn();
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...createItem(), pkgname: "spark-clock" },
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
startedTaskIds.push(task.id);
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releases.push(resolve);
});
queue.finishTask(task.id, "completed");
return task;
},
}),
});
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
await service.refresh();
const firstStart = service.start(["aptss:spark-weather"]);
const secondStart = service.start(["aptss:spark-clock"]);
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" },
await service.start([
createStartTask("aptss:spark-weather", 1),
createStartTask("aptss:spark-clock", 2),
]);
expect(send).toHaveBeenCalledTimes(2);
expect(service.getState().items).toEqual([]);
});
it("cancelling an active task stops it and leaves it cancelled", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
it("cancel is a no-op for update-center tasks", async () => {
const service = createUpdateCenterService({
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();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
await service.cancel("aptss:spark-weather");
await startPromise;
expect(cancelActiveTask).toHaveBeenCalledTimes(1);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "cancelled" },
]);
expect(service.getState()).toMatchObject({
items: [{ taskKey: "aptss:spark-weather" }],
tasks: [],
hasRunningTasks: false,
});
});
it("cancelling a queued task does not abort the currently active task", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
it("start without a main window leaves updates actionable", async () => {
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...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 (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id)
?.status !== "cancelled"
) {
queue.finishTask(task.id, "completed");
}
return task;
},
}),
});
electronMock.getAllWindows.mockReturnValue([]);
await service.refresh();
const activeStart = service.start(["aptss:spark-weather"]);
await flushPromises();
const queuedStart = service.start(["aptss:spark-clock"]);
await flushPromises();
await service.start([createStartTask("aptss:spark-weather", 1)]);
await service.cancel("aptss:spark-clock");
expect(cancelActiveTask).not.toHaveBeenCalled();
releaseTask?.();
await Promise.all([activeStart, queuedStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "cancelled" },
expect(service.getState().items).toMatchObject([
{ taskKey: "aptss:spark-weather" },
{ taskKey: "aptss:spark-clock" },
]);
});
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({
loadItems: async () => [createItem()],
superUserCmdProvider: async () => {
throw new Error("pkexec unavailable");
},
createTaskRunner: () => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
throw new Error(
"runner should not start when privilege lookup fails",
);
},
}),
loadIgnoredEntries: async () => new Set(["spark-weather|2.0.0"]),
});
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
await service.refresh();
await service.start(["aptss:spark-weather"]);
await service.start([createStartTask("aptss:spark-weather", 1)]);
expect(service.getState()).toMatchObject({
hasRunningTasks: false,
tasks: [
items: [
{
taskKey: "aptss:spark-weather",
status: "failed",
errorMessage: "pkexec unavailable",
ignored: true,
},
],
tasks: [],
});
expect(send).not.toHaveBeenCalled();
});
it("refresh exposes load-item failures as warnings", async () => {
+2 -2
View File
@@ -10,7 +10,7 @@
<div
v-if="show"
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"
@wheel="onOverlayWheel"
>
@@ -332,7 +332,7 @@
>
<div
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"
>
<div
+29 -25
View File
@@ -146,32 +146,36 @@
</div>
</div>
</div>
<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"
@click="$emit('open-app', app)"
<div
class="flex flex-wrap items-center justify-end gap-2 sm:min-w-[22rem]"
>
<i class="fas fa-play"></i>
打开
</button>
<button
v-if="canOpenDetail(app)"
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10"
@click="$emit('open-detail', app)"
>
<i class="fas fa-circle-info"></i>
查看详情
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50"
:disabled="app.currentStatus === 'not-installed'"
@click="$emit('uninstall', app)"
>
<i class="fas fa-trash"></i>
卸载
</button>
<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"
@click="$emit('open-app', app)"
>
<i class="fas fa-play"></i>
打开
</button>
<button
v-if="canOpenDetail(app)"
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10"
@click="$emit('open-detail', app)"
>
<i class="fas fa-circle-info"></i>
查看详情
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/60 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50"
:disabled="app.currentStatus === 'not-installed'"
@click="$emit('uninstall', app)"
>
<i class="fas fa-trash"></i>
卸载
</button>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
>
<div
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"
>
<div class="relative w-full max-w-5xl">
+1 -1
View File
@@ -9,7 +9,7 @@
>
<div
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"
>
<div