mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 09:20:18 +08:00
feat(update-center): 实现集中式软件更新中心功能
新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务 - 添加更新任务队列管理、状态跟踪和日志记录功能 - 实现更新项忽略配置持久化存储 - 新增更新确认对话框和迁移提示 - 优化主窗口关闭时的任务保护机制 - 添加单元测试覆盖核心逻辑
This commit is contained in:
182
src/__tests__/unit/update-center/UpdateCenterModal.test.ts
Normal file
182
src/__tests__/unit/update-center/UpdateCenterModal.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import UpdateCenterModal from "@/components/UpdateCenterModal.vue";
|
||||
import type {
|
||||
UpdateCenterItem,
|
||||
UpdateCenterSnapshot,
|
||||
UpdateCenterTaskState,
|
||||
} from "@/global/typedefinition";
|
||||
import type { UpdateCenterStore } from "@/modules/updateCenter";
|
||||
|
||||
const createItem = (
|
||||
overrides: Partial<UpdateCenterItem> = {},
|
||||
): UpdateCenterItem => ({
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTask = (
|
||||
overrides: Partial<UpdateCenterTaskState> = {},
|
||||
): UpdateCenterTaskState => ({
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
source: "aptss",
|
||||
status: "downloading",
|
||||
progress: 42,
|
||||
logs: [],
|
||||
errorMessage: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createStore = (
|
||||
overrides: Partial<UpdateCenterSnapshot> = {},
|
||||
): UpdateCenterStore => {
|
||||
const snapshot = ref<UpdateCenterSnapshot>({
|
||||
items: [
|
||||
createItem({
|
||||
taskKey: "aptss:spark-weather",
|
||||
source: "aptss",
|
||||
}),
|
||||
createItem({
|
||||
taskKey: "apm:spark-clock",
|
||||
packageName: "spark-clock",
|
||||
displayName: "Spark Clock",
|
||||
source: "apm",
|
||||
isMigration: true,
|
||||
migrationTarget: "apm",
|
||||
}),
|
||||
],
|
||||
tasks: [createTask()],
|
||||
warnings: ["更新过程中请勿关闭商店"],
|
||||
hasRunningTasks: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const selectedTaskKeys = ref(new Set<string>(["aptss:spark-weather"]));
|
||||
|
||||
return {
|
||||
isOpen: ref(true),
|
||||
showCloseConfirm: ref(true),
|
||||
showMigrationConfirm: ref(false),
|
||||
searchQuery: ref(""),
|
||||
selectedTaskKeys,
|
||||
snapshot,
|
||||
filteredItems: computed(() => snapshot.value.items),
|
||||
bind: vi.fn(),
|
||||
unbind: vi.fn(),
|
||||
open: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
toggleSelection: vi.fn(),
|
||||
getSelectedItems: vi.fn(() =>
|
||||
snapshot.value.items.filter(
|
||||
(item) =>
|
||||
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
|
||||
),
|
||||
),
|
||||
closeNow: vi.fn(),
|
||||
startSelected: vi.fn(),
|
||||
requestClose: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
describe("UpdateCenterModal", () => {
|
||||
it("renders source tags, running state, warnings, migration marker, and close confirmation", () => {
|
||||
const store = createStore();
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("软件更新")).toBeTruthy();
|
||||
expect(screen.getByText("传统deb")).toBeTruthy();
|
||||
expect(screen.getByText("APM")).toBeTruthy();
|
||||
expect(screen.getByText("将迁移到 APM")).toBeTruthy();
|
||||
expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy();
|
||||
expect(screen.getByText("下载中")).toBeTruthy();
|
||||
expect(screen.getByText("42%")).toBeTruthy();
|
||||
expect(screen.getByText(/确定关闭/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("close confirmation exposes a confirm-close path", async () => {
|
||||
const onConfirmClose = vi.fn();
|
||||
const store = createStore();
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
onConfirmClose,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "确认关闭" }));
|
||||
|
||||
expect(onConfirmClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders ignored items as disabled instead of normal selectable actions", () => {
|
||||
const store = createStore({
|
||||
items: [
|
||||
createItem({
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
source: "aptss",
|
||||
ignored: true,
|
||||
}),
|
||||
],
|
||||
tasks: [],
|
||||
warnings: [],
|
||||
hasRunningTasks: false,
|
||||
});
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("已忽略")).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders migration confirmation when requested", () => {
|
||||
const store = createStore({ hasRunningTasks: false });
|
||||
store.showMigrationConfirm.value = true;
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("迁移确认")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("close button triggers request-close flow", async () => {
|
||||
const store = createStore({ hasRunningTasks: false });
|
||||
|
||||
render(UpdateCenterModal, {
|
||||
props: {
|
||||
show: true,
|
||||
store,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
|
||||
|
||||
expect(store.requestClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
124
src/__tests__/unit/update-center/ignore-config.test.ts
Normal file
124
src/__tests__/unit/update-center/ignore-config.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
|
||||
import {
|
||||
LEGACY_IGNORE_CONFIG_PATH,
|
||||
applyIgnoredEntries,
|
||||
createIgnoreKey,
|
||||
loadIgnoredEntries,
|
||||
parseIgnoredEntries,
|
||||
saveIgnoredEntries,
|
||||
} from "../../../../electron/main/backend/update-center/ignore-config";
|
||||
|
||||
describe("update-center ignore config", () => {
|
||||
it("round-trips the legacy package|version format", async () => {
|
||||
expect(LEGACY_IGNORE_CONFIG_PATH).toBe(
|
||||
"/etc/spark-store/ignored_apps.conf",
|
||||
);
|
||||
|
||||
const entries = new Set([
|
||||
createIgnoreKey("spark-clock", "2.0.0"),
|
||||
createIgnoreKey("spark-browser", "1.5.0"),
|
||||
]);
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "spark-ignore-config-"));
|
||||
const filePath = join(tempDir, "ignored_apps.conf");
|
||||
|
||||
try {
|
||||
await saveIgnoredEntries(filePath, entries);
|
||||
|
||||
expect(await readFile(filePath, "utf8")).toBe(
|
||||
"spark-browser|1.5.0\nspark-clock|2.0.0\n",
|
||||
);
|
||||
expect(
|
||||
parseIgnoredEntries("spark-browser|1.5.0\nspark-clock|2.0.0\n"),
|
||||
).toEqual(new Set(["spark-browser|1.5.0", "spark-clock|2.0.0"]));
|
||||
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(entries);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores malformed lines and accepts CRLF legacy entries", () => {
|
||||
expect(
|
||||
parseIgnoredEntries(
|
||||
[
|
||||
"spark-browser|1.5.0\r",
|
||||
"spark-clock|2.0.0|extra",
|
||||
"missing-version|",
|
||||
"|missing-package",
|
||||
"spark-player|3.0.0",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual(new Set(["spark-browser|1.5.0", "spark-player|3.0.0"]));
|
||||
});
|
||||
|
||||
it("marks only exact package/version matches as ignored", () => {
|
||||
const items: UpdateCenterItem[] = [
|
||||
{
|
||||
pkgname: "spark-clock",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
{
|
||||
pkgname: "spark-clock",
|
||||
source: "apm",
|
||||
currentVersion: "1.1.0",
|
||||
nextVersion: "2.1.0",
|
||||
},
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
currentVersion: "4.0.0",
|
||||
nextVersion: "5.0.0",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
applyIgnoredEntries(
|
||||
items,
|
||||
new Set([createIgnoreKey("spark-clock", "2.0.0")]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
pkgname: "spark-clock",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
ignored: true,
|
||||
},
|
||||
{
|
||||
pkgname: "spark-clock",
|
||||
source: "apm",
|
||||
currentVersion: "1.1.0",
|
||||
nextVersion: "2.1.0",
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
currentVersion: "4.0.0",
|
||||
nextVersion: "5.0.0",
|
||||
ignored: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing file returns an empty set", async () => {
|
||||
const tempDir = await mkdtemp(
|
||||
join(tmpdir(), "spark-ignore-config-missing-"),
|
||||
);
|
||||
const filePath = join(tempDir, "missing.conf");
|
||||
|
||||
try {
|
||||
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(new Set());
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
134
src/__tests__/unit/update-center/load-items.test.ts
Normal file
134
src/__tests__/unit/update-center/load-items.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
|
||||
|
||||
interface CommandResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
const APTSS_LIST_UPGRADABLE_KEY =
|
||||
"bash -lc env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0";
|
||||
|
||||
const DPKG_QUERY_INSTALLED_KEY =
|
||||
"dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n";
|
||||
|
||||
describe("update-center load items", () => {
|
||||
it("enriches apm and migration items with download metadata needed by the runner", async () => {
|
||||
const commandResults = new Map<string, CommandResult>([
|
||||
[
|
||||
APTSS_LIST_UPGRADABLE_KEY,
|
||||
{
|
||||
code: 0,
|
||||
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||
stderr: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
"apm list --upgradable",
|
||||
{
|
||||
code: 0,
|
||||
stdout: "spark-weather/main 3.0.0 amd64 [upgradable from: 1.5.0]",
|
||||
stderr: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
DPKG_QUERY_INSTALLED_KEY,
|
||||
{
|
||||
code: 0,
|
||||
stdout: "spark-weather\tinstall ok installed\n",
|
||||
stderr: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
"apm list --installed",
|
||||
{
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
"apm info spark-weather --print-uris",
|
||||
{
|
||||
code: 0,
|
||||
stdout:
|
||||
"'https://example.invalid/spark-weather_3.0.0_amd64.deb' spark-weather_3.0.0_amd64.deb 123456 SHA512:deadbeef",
|
||||
stderr: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||
const key = `${command} ${args.join(" ")}`;
|
||||
const match = commandResults.get(key);
|
||||
if (!match) {
|
||||
throw new Error(`Missing mock for ${key}`);
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.items).toContainEqual({
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.5.0",
|
||||
nextVersion: "3.0.0",
|
||||
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
|
||||
fileName: "spark-weather_3.0.0_amd64.deb",
|
||||
size: 123456,
|
||||
sha512: "deadbeef",
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades to aptss-only results when apm commands fail", async () => {
|
||||
const result = await loadUpdateCenterItems(async (command, args) => {
|
||||
const key = `${command} ${args.join(" ")}`;
|
||||
|
||||
if (key === APTSS_LIST_UPGRADABLE_KEY) {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === DPKG_QUERY_INSTALLED_KEY) {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "spark-notes\tinstall ok installed\n",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "apm list --upgradable" || key === "apm list --installed") {
|
||||
return {
|
||||
code: 127,
|
||||
stdout: "",
|
||||
stderr: "apm: command not found",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command ${key}`);
|
||||
});
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
pkgname: "spark-notes",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
"apm upgradable query failed: apm: command not found",
|
||||
"apm installed query failed: apm: command not found",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getMainWindowCloseAction,
|
||||
shouldPreventMainWindowClose,
|
||||
} from "../../../../electron/main/window-close-guard";
|
||||
|
||||
describe("main window close guard", () => {
|
||||
it("keeps the app alive while update-center work is running", () => {
|
||||
expect(
|
||||
shouldPreventMainWindowClose({
|
||||
installTaskCount: 0,
|
||||
hasRunningUpdateCenterTasks: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows close only when both install and update-center work are idle", () => {
|
||||
expect(
|
||||
shouldPreventMainWindowClose({
|
||||
installTaskCount: 0,
|
||||
hasRunningUpdateCenterTasks: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a hide action while any guarded work is active", () => {
|
||||
expect(
|
||||
getMainWindowCloseAction({
|
||||
installTaskCount: 1,
|
||||
hasRunningUpdateCenterTasks: false,
|
||||
}),
|
||||
).toBe("hide");
|
||||
});
|
||||
|
||||
it("returns a destroy action only when all guarded work is idle", () => {
|
||||
expect(
|
||||
getMainWindowCloseAction({
|
||||
installTaskCount: 0,
|
||||
hasRunningUpdateCenterTasks: false,
|
||||
}),
|
||||
).toBe("destroy");
|
||||
});
|
||||
});
|
||||
294
src/__tests__/unit/update-center/query.test.ts
Normal file
294
src/__tests__/unit/update-center/query.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildInstalledSourceMap,
|
||||
mergeUpdateSources,
|
||||
parseApmUpgradableOutput,
|
||||
parseAptssUpgradableOutput,
|
||||
parsePrintUrisOutput,
|
||||
} from "../../../../electron/main/backend/update-center/query";
|
||||
|
||||
describe("update-center query", () => {
|
||||
it("parses aptss upgradable output into normalized aptss items", () => {
|
||||
const output = [
|
||||
"Listing...",
|
||||
"spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]",
|
||||
"spark-same/stable 1.0.0 amd64 [upgradable from: 1.0.0]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
expect(parseAptssUpgradableOutput(output)).toEqual([
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.9.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses the legacy from variant in upgradable output", () => {
|
||||
const aptssOutput = "spark-clock/stable 1.2.0 amd64 [from: 1.1.0]";
|
||||
const apmOutput = "spark-player/main 2.0.0 amd64 [from: 1.5.0]";
|
||||
|
||||
expect(parseAptssUpgradableOutput(aptssOutput)).toEqual([
|
||||
{
|
||||
pkgname: "spark-clock",
|
||||
source: "aptss",
|
||||
currentVersion: "1.1.0",
|
||||
nextVersion: "1.2.0",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(parseApmUpgradableOutput(apmOutput)).toEqual([
|
||||
{
|
||||
pkgname: "spark-player",
|
||||
source: "apm",
|
||||
currentVersion: "1.5.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses apt print-uris output into download metadata", () => {
|
||||
const output =
|
||||
"'https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb' spark-weather_2.0.0_amd64.deb 123456 SHA512:deadbeef";
|
||||
|
||||
expect(parsePrintUrisOutput(output)).toEqual({
|
||||
downloadUrl:
|
||||
"https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb",
|
||||
fileName: "spark-weather_2.0.0_amd64.deb",
|
||||
size: 123456,
|
||||
sha512: "deadbeef",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an apm item as migration when the same package is only installed in aptss", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.9.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.8.0",
|
||||
nextVersion: "3.0.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-weather", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "apm",
|
||||
currentVersion: "1.8.0",
|
||||
nextVersion: "3.0.0",
|
||||
isMigration: true,
|
||||
migrationSource: "aptss",
|
||||
migrationTarget: "apm",
|
||||
aptssVersion: "2.0.0",
|
||||
},
|
||||
{
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.9.0",
|
||||
nextVersion: "2.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses Debian-style version ordering for migration decisions", () => {
|
||||
const merged = mergeUpdateSources(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "aptss",
|
||||
currentVersion: "9.0",
|
||||
nextVersion: "10.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
currentVersion: "1:0.9",
|
||||
nextVersion: "2:1.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-browser", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged[0]).toMatchObject({
|
||||
pkgname: "spark-browser",
|
||||
source: "apm",
|
||||
isMigration: true,
|
||||
aptssVersion: "10.0",
|
||||
});
|
||||
expect(merged[1]).toMatchObject({
|
||||
pkgname: "spark-browser",
|
||||
source: "aptss",
|
||||
nextVersion: "10.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Debian epoch ordering in the fallback when dpkg is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("node:child_process")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
spawnSync: vi.fn(() => ({
|
||||
status: null,
|
||||
error: new Error("dpkg unavailable"),
|
||||
output: null,
|
||||
pid: 0,
|
||||
signal: null,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const { mergeUpdateSources: mergeWithFallback } =
|
||||
await import("../../../../electron/main/backend/update-center/query");
|
||||
|
||||
const merged = mergeWithFallback(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-reader",
|
||||
source: "aptss",
|
||||
currentVersion: "2.5.0",
|
||||
nextVersion: "2.9.0",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-reader",
|
||||
source: "apm",
|
||||
currentVersion: "2.0.0",
|
||||
nextVersion: "3.0.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-reader", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged[0]).toMatchObject({
|
||||
pkgname: "spark-reader",
|
||||
source: "apm",
|
||||
isMigration: true,
|
||||
aptssVersion: "2.9.0",
|
||||
});
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("uses Debian tilde ordering in the fallback when dpkg is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("node:child_process")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
spawnSync: vi.fn(() => ({
|
||||
status: null,
|
||||
error: new Error("dpkg unavailable"),
|
||||
output: null,
|
||||
pid: 0,
|
||||
signal: null,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const { mergeUpdateSources: mergeWithFallback } =
|
||||
await import("../../../../electron/main/backend/update-center/query");
|
||||
|
||||
const merged = mergeWithFallback(
|
||||
[
|
||||
{
|
||||
pkgname: "spark-tilde",
|
||||
source: "aptss",
|
||||
currentVersion: "0.9",
|
||||
nextVersion: "1.0~rc1",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
pkgname: "spark-tilde",
|
||||
source: "apm",
|
||||
currentVersion: "0.9",
|
||||
nextVersion: "1.0",
|
||||
},
|
||||
],
|
||||
new Map([["spark-tilde", { aptss: true, apm: false }]]),
|
||||
);
|
||||
|
||||
expect(merged[0]).toMatchObject({
|
||||
pkgname: "spark-tilde",
|
||||
source: "apm",
|
||||
isMigration: true,
|
||||
aptssVersion: "1.0~rc1",
|
||||
});
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("parses apm list output into normalized apm items", () => {
|
||||
const output = [
|
||||
"Listing...",
|
||||
"spark-music/main 5.0.0 arm64 [upgradable from: 4.5.0]",
|
||||
"spark-same/main 1.0.0 arm64 [upgradable from: 1.0.0]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
expect(parseApmUpgradableOutput(output)).toEqual([
|
||||
{
|
||||
pkgname: "spark-music",
|
||||
source: "apm",
|
||||
currentVersion: "4.5.0",
|
||||
nextVersion: "5.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds installed-source map from dpkg-query and apm list output", () => {
|
||||
const dpkgOutput = [
|
||||
"spark-weather\tinstall ok installed",
|
||||
"spark-weather-data\tdeinstall ok config-files",
|
||||
"spark-notes\tinstall ok installed",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const apmInstalledOutput = [
|
||||
"Listing...",
|
||||
"spark-weather/main,stable 3.0.0 amd64 [installed]",
|
||||
"spark-player/main 1.0.0 amd64 [installed,automatic]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
buildInstalledSourceMap(dpkgOutput, apmInstalledOutput).entries(),
|
||||
),
|
||||
).toEqual([
|
||||
["spark-weather", { aptss: true, apm: true }],
|
||||
["spark-notes", { aptss: true, apm: false }],
|
||||
["spark-player", { aptss: false, apm: true }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
555
src/__tests__/unit/update-center/registerUpdateCenter.test.ts
Normal file
555
src/__tests__/unit/update-center/registerUpdateCenter.test.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
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,
|
||||
type UpdateCenterServiceState,
|
||||
} from "../../../../electron/main/backend/update-center/service";
|
||||
import { registerUpdateCenterIpc } from "../../../../electron/main/backend/update-center";
|
||||
|
||||
const flushPromises = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const electronMock = vi.hoisted(() => ({
|
||||
getAllWindows: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: electronMock.getAllWindows,
|
||||
},
|
||||
}));
|
||||
|
||||
const createItem = (): UpdateCenterItem => ({
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
});
|
||||
|
||||
describe("update-center/ipc", () => {
|
||||
beforeEach(() => {
|
||||
electronMock.getAllWindows.mockReset();
|
||||
});
|
||||
|
||||
it("registers every update-center handler and forwards service calls", async () => {
|
||||
const handle = vi.fn();
|
||||
const send = vi.fn();
|
||||
const snapshot: UpdateCenterServiceState = {
|
||||
items: [],
|
||||
tasks: [],
|
||||
warnings: [],
|
||||
hasRunningTasks: false,
|
||||
};
|
||||
let listener:
|
||||
| ((nextSnapshot: UpdateCenterServiceState) => void)
|
||||
| undefined;
|
||||
const service = {
|
||||
open: vi.fn().mockResolvedValue(snapshot),
|
||||
refresh: vi.fn().mockResolvedValue(snapshot),
|
||||
ignore: vi.fn().mockResolvedValue(undefined),
|
||||
unignore: vi.fn().mockResolvedValue(undefined),
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
getState: vi.fn().mockReturnValue(snapshot),
|
||||
subscribe: vi.fn(
|
||||
(nextListener: (nextSnapshot: UpdateCenterServiceState) => void) => {
|
||||
listener = nextListener;
|
||||
return () => undefined;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
|
||||
|
||||
registerUpdateCenterIpc({ handle }, service);
|
||||
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-open",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-refresh",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-ignore",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-unignore",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-start",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-cancel",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(handle).toHaveBeenCalledWith(
|
||||
"update-center-get-state",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(service.subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
const openHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-open",
|
||||
)?.[1] as
|
||||
| ((event: unknown) => Promise<UpdateCenterServiceState>)
|
||||
| undefined;
|
||||
const refreshHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-refresh",
|
||||
)?.[1] as
|
||||
| ((event: unknown) => Promise<UpdateCenterServiceState>)
|
||||
| undefined;
|
||||
const ignoreHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-ignore",
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
payload: { packageName: string; newVersion: string },
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
const unignoreHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-unignore",
|
||||
)?.[1] as
|
||||
| ((
|
||||
event: unknown,
|
||||
payload: { packageName: string; newVersion: string },
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
const startHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-start",
|
||||
)?.[1] as
|
||||
| ((event: unknown, taskKeys: string[]) => Promise<void>)
|
||||
| undefined;
|
||||
const cancelHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-cancel",
|
||||
)?.[1] as ((event: unknown, taskKey: string) => Promise<void>) | undefined;
|
||||
const getStateHandler = handle.mock.calls.find(
|
||||
([channel]: [string]) => channel === "update-center-get-state",
|
||||
)?.[1] as (() => UpdateCenterServiceState) | undefined;
|
||||
|
||||
await openHandler?.({});
|
||||
await refreshHandler?.({});
|
||||
await ignoreHandler?.(
|
||||
{},
|
||||
{ packageName: "spark-weather", newVersion: "2.0.0" },
|
||||
);
|
||||
await unignoreHandler?.(
|
||||
{},
|
||||
{ packageName: "spark-weather", newVersion: "2.0.0" },
|
||||
);
|
||||
await startHandler?.({}, ["aptss:spark-weather"]);
|
||||
await cancelHandler?.({}, "aptss:spark-weather");
|
||||
|
||||
expect(getStateHandler?.()).toEqual(snapshot);
|
||||
expect(service.open).toHaveBeenCalledTimes(1);
|
||||
expect(service.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(service.ignore).toHaveBeenCalledWith({
|
||||
packageName: "spark-weather",
|
||||
newVersion: "2.0.0",
|
||||
});
|
||||
expect(service.unignore).toHaveBeenCalledWith({
|
||||
packageName: "spark-weather",
|
||||
newVersion: "2.0.0",
|
||||
});
|
||||
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]);
|
||||
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
|
||||
|
||||
listener?.(snapshot);
|
||||
expect(send).toHaveBeenCalledWith("update-center-state", snapshot);
|
||||
});
|
||||
|
||||
it("service subscribers receive state updates after refresh start and ignore", async () => {
|
||||
let ignoredEntries = new Set<string>();
|
||||
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[] = [];
|
||||
|
||||
service.subscribe((snapshot: UpdateCenterServiceState) => {
|
||||
snapshots.push(snapshot);
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
await service.start(["aptss:spark-weather"]);
|
||||
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
|
||||
|
||||
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true);
|
||||
expect(
|
||||
snapshots.some((snapshot) =>
|
||||
snapshot.tasks.some(
|
||||
(task: UpdateCenterServiceState["tasks"][number]) =>
|
||||
task.taskKey === "aptss:spark-weather" &&
|
||||
task.status === "completed",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(snapshots.at(-1)?.items[0]).toMatchObject({
|
||||
taskKey: "aptss:spark-weather",
|
||||
ignored: true,
|
||||
newVersion: "2.0.0",
|
||||
});
|
||||
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
|
||||
});
|
||||
|
||||
it("concurrent start calls still serialize through one processing pipeline", async () => {
|
||||
const startedTaskIds: number[] = [];
|
||||
const releases: Array<() => void> = [];
|
||||
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;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancelling an active task stops it and leaves it cancelled", async () => {
|
||||
let releaseTask: (() => void) | undefined;
|
||||
const cancelActiveTask = vi.fn(() => {
|
||||
releaseTask?.();
|
||||
});
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancelling a queued task does not abort the currently active task", async () => {
|
||||
let releaseTask: (() => void) | undefined;
|
||||
const cancelActiveTask = vi.fn(() => {
|
||||
releaseTask?.();
|
||||
});
|
||||
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;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
|
||||
const activeStart = service.start(["aptss:spark-weather"]);
|
||||
await flushPromises();
|
||||
const queuedStart = service.start(["aptss:spark-clock"]);
|
||||
await flushPromises();
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("superUserCmdProvider failure does not leave a task stuck in queued state", async () => {
|
||||
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",
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await service.refresh();
|
||||
await service.start(["aptss:spark-weather"]);
|
||||
|
||||
expect(service.getState()).toMatchObject({
|
||||
hasRunningTasks: false,
|
||||
tasks: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
status: "failed",
|
||||
errorMessage: "pkexec unavailable",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh exposes load-item failures as warnings", async () => {
|
||||
const service = createUpdateCenterService({
|
||||
loadItems: async () => {
|
||||
throw new Error("apt list failed");
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await service.refresh();
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
items: [],
|
||||
warnings: ["apt list failed"],
|
||||
hasRunningTasks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh failure clears previously loaded items so stale updates are not actionable", async () => {
|
||||
let shouldFailRefresh = false;
|
||||
const service = createUpdateCenterService({
|
||||
loadItems: async () => {
|
||||
if (shouldFailRefresh) {
|
||||
throw new Error("apt list failed");
|
||||
}
|
||||
|
||||
return [createItem()];
|
||||
},
|
||||
});
|
||||
|
||||
expect(await service.refresh()).toMatchObject({
|
||||
items: [{ taskKey: "aptss:spark-weather" }],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
shouldFailRefresh = true;
|
||||
|
||||
expect(await service.refresh()).toMatchObject({
|
||||
items: [],
|
||||
warnings: ["apt list failed"],
|
||||
hasRunningTasks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh preserves warnings returned alongside successful items", async () => {
|
||||
const service = createUpdateCenterService({
|
||||
loadItems: async () => ({
|
||||
items: [createItem()],
|
||||
warnings: ["apm unavailable, showing aptss updates only"],
|
||||
}),
|
||||
});
|
||||
|
||||
const snapshot = await service.refresh();
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
},
|
||||
],
|
||||
warnings: ["apm unavailable, showing aptss updates only"],
|
||||
hasRunningTasks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("window ipcRenderer typing matches the preload facade only", async () => {
|
||||
type IpcFacade = Window["ipcRenderer"];
|
||||
type HasOn = IpcFacade extends { on: (...args: never[]) => unknown }
|
||||
? true
|
||||
: false;
|
||||
type HasInvoke = IpcFacade extends { invoke: (...args: never[]) => unknown }
|
||||
? true
|
||||
: false;
|
||||
type HasPostMessage = IpcFacade extends {
|
||||
postMessage: (...args: never[]) => unknown;
|
||||
}
|
||||
? true
|
||||
: false;
|
||||
|
||||
const typeShape: [HasOn, HasInvoke, HasPostMessage] = [true, true, false];
|
||||
|
||||
expect(typeShape).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("default task runner forwards abort signals into download and install helpers", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
let downloadSignal: AbortSignal | undefined;
|
||||
let installAborted = false;
|
||||
let closeHandler: ((code: number | null) => void) | undefined;
|
||||
|
||||
vi.doMock(
|
||||
"../../../../electron/main/backend/update-center/download",
|
||||
() => ({
|
||||
runAria2Download: vi.fn(async (context: { signal?: AbortSignal }) => {
|
||||
downloadSignal = context.signal;
|
||||
return { filePath: "/tmp/spark-weather.deb" };
|
||||
}),
|
||||
}),
|
||||
);
|
||||
vi.doMock("node:child_process", () => {
|
||||
const spawn = vi.fn(() => {
|
||||
const child = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
kill: vi.fn(() => {
|
||||
installAborted = true;
|
||||
closeHandler?.(1);
|
||||
}),
|
||||
on: vi.fn(
|
||||
(event: string, callback: (code: number | null) => void) => {
|
||||
if (event === "close") {
|
||||
closeHandler = callback;
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return child;
|
||||
});
|
||||
|
||||
return {
|
||||
default: { spawn },
|
||||
spawn,
|
||||
};
|
||||
});
|
||||
|
||||
const { createTaskRunner } =
|
||||
await import("../../../../electron/main/backend/update-center/install");
|
||||
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = {
|
||||
...createItem(),
|
||||
downloadUrl: "https://example.invalid/spark-weather.deb",
|
||||
fileName: "spark-weather.deb",
|
||||
};
|
||||
|
||||
queue.setItems([item]);
|
||||
queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue);
|
||||
const runPromise = runner.runNextTask();
|
||||
|
||||
await flushPromises();
|
||||
expect(downloadSignal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
runner.cancelActiveTask();
|
||||
|
||||
const settled = await Promise.race([
|
||||
runPromise.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), 50);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(installAborted).toBe(true);
|
||||
expect(settled).toBe(true);
|
||||
|
||||
vi.doUnmock("../../../../electron/main/backend/update-center/download");
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
232
src/__tests__/unit/update-center/store.test.ts
Normal file
232
src/__tests__/unit/update-center/store.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createUpdateCenterStore } from "@/modules/updateCenter";
|
||||
|
||||
const createSnapshot = (overrides = {}) => ({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
warnings: [],
|
||||
hasRunningTasks: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("updateCenter store", () => {
|
||||
const open = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
const start = vi.fn();
|
||||
const onState = vi.fn();
|
||||
const offState = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
open.mockReset();
|
||||
refresh.mockReset();
|
||||
start.mockReset();
|
||||
onState.mockReset();
|
||||
offState.mockReset();
|
||||
|
||||
Object.defineProperty(window, "updateCenter", {
|
||||
configurable: true,
|
||||
value: {
|
||||
open,
|
||||
refresh,
|
||||
ignore: vi.fn(),
|
||||
unignore: vi.fn(),
|
||||
start,
|
||||
cancel: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
onState,
|
||||
offState,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the modal with the initial snapshot", async () => {
|
||||
const snapshot = createSnapshot();
|
||||
open.mockResolvedValue(snapshot);
|
||||
const store = createUpdateCenterStore();
|
||||
|
||||
await store.open();
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(store.isOpen.value).toBe(true);
|
||||
expect(store.snapshot.value).toEqual(snapshot);
|
||||
expect(store.filteredItems.value).toEqual(snapshot.items);
|
||||
});
|
||||
|
||||
it("starts only the selected non-ignored items", async () => {
|
||||
const snapshot = createSnapshot({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
taskKey: "apm:spark-clock",
|
||||
packageName: "spark-clock",
|
||||
displayName: "Spark Clock",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "apm" as const,
|
||||
ignored: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
open.mockResolvedValue(snapshot);
|
||||
const store = createUpdateCenterStore();
|
||||
|
||||
await store.open();
|
||||
store.toggleSelection("aptss:spark-weather");
|
||||
store.toggleSelection("apm:spark-clock");
|
||||
await store.startSelected();
|
||||
|
||||
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]);
|
||||
});
|
||||
|
||||
it("blocks close requests while the snapshot reports running tasks", () => {
|
||||
const store = createUpdateCenterStore();
|
||||
store.isOpen.value = true;
|
||||
store.snapshot.value = createSnapshot({ hasRunningTasks: true });
|
||||
|
||||
store.requestClose();
|
||||
|
||||
expect(store.isOpen.value).toBe(true);
|
||||
expect(store.showCloseConfirm.value).toBe(true);
|
||||
});
|
||||
|
||||
it("applies pushed snapshots from the main process", () => {
|
||||
let listener:
|
||||
| ((snapshot: ReturnType<typeof createSnapshot>) => void)
|
||||
| null = null;
|
||||
onState.mockImplementation((nextListener) => {
|
||||
listener = nextListener;
|
||||
});
|
||||
const store = createUpdateCenterStore();
|
||||
|
||||
store.bind();
|
||||
|
||||
const pushedSnapshot = createSnapshot({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-music",
|
||||
packageName: "spark-music",
|
||||
displayName: "Spark Music",
|
||||
currentVersion: "3.0.0",
|
||||
newVersion: "3.1.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
listener?.(pushedSnapshot);
|
||||
|
||||
expect(onState).toHaveBeenCalledTimes(1);
|
||||
expect(store.snapshot.value).toEqual(pushedSnapshot);
|
||||
expect(store.filteredItems.value).toEqual(pushedSnapshot.items);
|
||||
|
||||
store.unbind();
|
||||
expect(offState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prunes stale selected task keys when snapshots change", async () => {
|
||||
open.mockResolvedValue(
|
||||
createSnapshot({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
taskKey: "apm:spark-clock",
|
||||
packageName: "spark-clock",
|
||||
displayName: "Spark Clock",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "apm" as const,
|
||||
ignored: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
refresh.mockResolvedValue(
|
||||
createSnapshot({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-music",
|
||||
packageName: "spark-music",
|
||||
displayName: "Spark Music",
|
||||
currentVersion: "3.0.0",
|
||||
newVersion: "3.1.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const store = createUpdateCenterStore();
|
||||
|
||||
await store.open();
|
||||
store.toggleSelection("aptss:spark-weather");
|
||||
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
|
||||
|
||||
await store.refresh();
|
||||
|
||||
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
|
||||
|
||||
await store.startSelected();
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears selection across a close and reopen cycle", async () => {
|
||||
const snapshot = createSnapshot({
|
||||
items: [
|
||||
{
|
||||
taskKey: "aptss:spark-weather",
|
||||
packageName: "spark-weather",
|
||||
displayName: "Spark Weather",
|
||||
currentVersion: "1.0.0",
|
||||
newVersion: "2.0.0",
|
||||
source: "aptss" as const,
|
||||
ignored: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
open.mockResolvedValue(snapshot);
|
||||
const store = createUpdateCenterStore();
|
||||
|
||||
await store.open();
|
||||
store.toggleSelection("aptss:spark-weather");
|
||||
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
|
||||
|
||||
store.requestClose();
|
||||
expect(store.isOpen.value).toBe(false);
|
||||
|
||||
await store.open();
|
||||
|
||||
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
|
||||
|
||||
await store.startSelected();
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
303
src/__tests__/unit/update-center/task-runner.test.ts
Normal file
303
src/__tests__/unit/update-center/task-runner.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
|
||||
import {
|
||||
createTaskRunner,
|
||||
buildLegacySparkUpgradeCommand,
|
||||
installUpdateItem,
|
||||
} from "../../../../electron/main/backend/update-center/install";
|
||||
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
|
||||
|
||||
const childProcessMock = vi.hoisted(() => ({
|
||||
spawnCalls: [] as Array<{ command: string; args: string[] }>,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
default: {
|
||||
spawn: vi.fn((command: string, args: string[] = []) => {
|
||||
childProcessMock.spawnCalls.push({ command, args });
|
||||
|
||||
return {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(
|
||||
(
|
||||
event: string,
|
||||
callback: ((code?: number) => void) | (() => void),
|
||||
) => {
|
||||
if (event === "close") {
|
||||
callback(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
spawn: vi.fn((command: string, args: string[] = []) => {
|
||||
childProcessMock.spawnCalls.push({ command, args });
|
||||
|
||||
return {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(
|
||||
(event: string, callback: ((code?: number) => void) | (() => void)) => {
|
||||
if (event === "close") {
|
||||
callback(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const createAptssItem = (): UpdateCenterItem => ({
|
||||
pkgname: "spark-weather",
|
||||
source: "aptss",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
downloadUrl: "https://example.invalid/spark-weather_2.0.0_amd64.deb",
|
||||
fileName: "spark-weather_2.0.0_amd64.deb",
|
||||
});
|
||||
|
||||
const createApmItem = (): UpdateCenterItem => ({
|
||||
pkgname: "spark-player",
|
||||
source: "apm",
|
||||
currentVersion: "1.0.0",
|
||||
nextVersion: "2.0.0",
|
||||
downloadUrl: "https://example.invalid/spark-player_2.0.0_amd64.deb",
|
||||
fileName: "spark-player_2.0.0_amd64.deb",
|
||||
});
|
||||
|
||||
describe("update-center task runner", () => {
|
||||
it("runs download then install and marks the task as completed", async () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = createAptssItem();
|
||||
const steps: string[] = [];
|
||||
|
||||
queue.setItems([item]);
|
||||
const task = queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue, {
|
||||
runDownload: async (context) => {
|
||||
steps.push(`download:${context.task.pkgname}`);
|
||||
context.onLog("download-started");
|
||||
context.onProgress(40);
|
||||
|
||||
return {
|
||||
filePath: `/tmp/${context.item.fileName}`,
|
||||
};
|
||||
},
|
||||
installItem: async (context) => {
|
||||
steps.push(`install:${context.task.pkgname}`);
|
||||
context.onLog("install-started");
|
||||
},
|
||||
});
|
||||
|
||||
await runner.runNextTask();
|
||||
|
||||
expect(steps).toEqual(["download:spark-weather", "install:spark-weather"]);
|
||||
expect(queue.getSnapshot()).toMatchObject({
|
||||
hasRunningTasks: false,
|
||||
warnings: [],
|
||||
tasks: [
|
||||
{
|
||||
id: task.id,
|
||||
pkgname: "spark-weather",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
logs: [
|
||||
expect.objectContaining({ message: "download-started" }),
|
||||
expect.objectContaining({ message: "install-started" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a direct aptss upgrade command instead of spark-update-tool", () => {
|
||||
expect(
|
||||
buildLegacySparkUpgradeCommand("spark-weather", "/usr/bin/pkexec"),
|
||||
).toEqual({
|
||||
execCommand: "/usr/bin/pkexec",
|
||||
execParams: [
|
||||
"/opt/spark-store/extras/shell-caller.sh",
|
||||
"aptss",
|
||||
"install",
|
||||
"-y",
|
||||
"spark-weather",
|
||||
"--only-upgrade",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks close while a refresh or task is still running", () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = createAptssItem();
|
||||
|
||||
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
|
||||
|
||||
queue.startRefresh();
|
||||
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
|
||||
|
||||
queue.finishRefresh(["metadata warning"]);
|
||||
expect(queue.getSnapshot()).toMatchObject({
|
||||
hasRunningTasks: false,
|
||||
warnings: ["metadata warning"],
|
||||
});
|
||||
|
||||
const task = queue.enqueueItem(item);
|
||||
queue.markActiveTask(task.id, "downloading");
|
||||
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
|
||||
|
||||
queue.finishTask(task.id, "cancelled");
|
||||
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates privilege escalation into the install path", async () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = createAptssItem();
|
||||
const installCalls: Array<{ superUserCmd?: string; filePath?: string }> =
|
||||
[];
|
||||
|
||||
queue.setItems([item]);
|
||||
queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue, {
|
||||
superUserCmd: "/usr/bin/pkexec",
|
||||
runDownload: async () => ({ filePath: "/tmp/spark-weather.deb" }),
|
||||
installItem: async (context) => {
|
||||
installCalls.push({
|
||||
superUserCmd: context.superUserCmd,
|
||||
filePath: context.filePath,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await runner.runNextTask();
|
||||
|
||||
expect(installCalls).toEqual([
|
||||
{
|
||||
superUserCmd: "/usr/bin/pkexec",
|
||||
filePath: "/tmp/spark-weather.deb",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails fast for apm items without a file path", async () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = {
|
||||
...createApmItem(),
|
||||
downloadUrl: undefined,
|
||||
fileName: undefined,
|
||||
};
|
||||
|
||||
queue.setItems([item]);
|
||||
const task = queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue, {
|
||||
installItem: async (context) => {
|
||||
throw new Error(`unexpected install for ${context.item.pkgname}`);
|
||||
},
|
||||
});
|
||||
|
||||
await runner.runNextTask();
|
||||
|
||||
expect(queue.getSnapshot()).toMatchObject({
|
||||
hasRunningTasks: false,
|
||||
tasks: [
|
||||
{
|
||||
id: task.id,
|
||||
status: "failed",
|
||||
error: "APM update task requires downloaded package metadata",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate work across concurrent runNextTask calls", async () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = createAptssItem();
|
||||
let releaseDownload: (() => void) | undefined;
|
||||
const downloadGate = new Promise<void>((resolve) => {
|
||||
releaseDownload = resolve;
|
||||
});
|
||||
const runDownload = vi.fn(async () => {
|
||||
await downloadGate;
|
||||
return { filePath: "/tmp/spark-weather.deb" };
|
||||
});
|
||||
const installItem = vi.fn(async () => {});
|
||||
|
||||
queue.setItems([item]);
|
||||
queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue, {
|
||||
runDownload,
|
||||
installItem,
|
||||
});
|
||||
|
||||
const firstRun = runner.runNextTask();
|
||||
const secondRun = runner.runNextTask();
|
||||
releaseDownload?.();
|
||||
|
||||
const results = await Promise.all([firstRun, secondRun]);
|
||||
|
||||
expect(runDownload).toHaveBeenCalledTimes(1);
|
||||
expect(installItem).toHaveBeenCalledTimes(1);
|
||||
expect(results[1]).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the task as failed when install fails", async () => {
|
||||
const queue = createUpdateCenterQueue();
|
||||
const item = createAptssItem();
|
||||
|
||||
queue.setItems([item]);
|
||||
const task = queue.enqueueItem(item);
|
||||
|
||||
const runner = createTaskRunner(queue, {
|
||||
runDownload: async (context) => {
|
||||
context.onProgress(30);
|
||||
return { filePath: "/tmp/spark-weather.deb" };
|
||||
},
|
||||
installItem: async () => {
|
||||
throw new Error("install exploded");
|
||||
},
|
||||
});
|
||||
|
||||
await runner.runNextTask();
|
||||
|
||||
expect(queue.getSnapshot()).toMatchObject({
|
||||
hasRunningTasks: false,
|
||||
tasks: [
|
||||
{
|
||||
id: task.id,
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
error: "install exploded",
|
||||
logs: [expect.objectContaining({ message: "install exploded" })],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall through to ssinstall for apm file installs", async () => {
|
||||
childProcessMock.spawnCalls.length = 0;
|
||||
|
||||
await installUpdateItem({
|
||||
item: createApmItem(),
|
||||
filePath: "/tmp/spark-player.deb",
|
||||
superUserCmd: "/usr/bin/pkexec",
|
||||
});
|
||||
|
||||
expect(childProcessMock.spawnCalls).toEqual([
|
||||
{
|
||||
command: "/usr/bin/pkexec",
|
||||
args: [
|
||||
"/opt/spark-store/extras/shell-caller.sh",
|
||||
"apm",
|
||||
"ssaudit",
|
||||
"/tmp/spark-player.deb",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user