diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts index de04ecc9..c0eb19c9 100644 --- a/electron/main/backend/update-center/index.ts +++ b/electron/main/backend/update-center/index.ts @@ -52,10 +52,7 @@ const categoryCache = new Map>(); const APTSS_LIST_UPGRADABLE_COMMAND = { command: "bash", - args: [ - "-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", - ], + args: ["-lc", "env LANGUAGE=en_US aptss list --upgradable"], }; const DPKG_QUERY_INSTALLED_COMMAND = { diff --git a/electron/main/backend/update-center/query.ts b/electron/main/backend/update-center/query.ts index 1cb49422..def20b47 100644 --- a/electron/main/backend/update-center/query.ts +++ b/electron/main/backend/update-center/query.ts @@ -6,10 +6,9 @@ import type { UpdateSource, } from "./types"; -const UPGRADABLE_PATTERN = - /^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i; const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/; const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/; +const CURRENT_VERSION_PATTERN = /\[(?:upgradable from|from):\s*([^\]\s]+)\]/i; const splitVersion = (version: string) => { const epochMatch = version.match(/^(\d+):(.*)$/); @@ -190,18 +189,27 @@ const parseUpgradableOutput = ( const items: UpdateCenterItem[] = []; for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("Listing")) { + const trimmed = line + .replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;]*m/g, + "", + ) + .trim(); + if (!trimmed || trimmed.startsWith("Listing") || !trimmed.includes("/")) { continue; } - const match = trimmed.match(UPGRADABLE_PATTERN); - if (!match) { + const tokens = trimmed.split(/\s+/); + if (tokens.length < 3) { continue; } - const [, pkgname, nextVersion, currentVersion] = match; - const arch = trimmed.split(/\s+/)[2]; + const pkgname = tokens[0]?.split("/")[0] ?? ""; + const nextVersion = tokens[1] ?? ""; + const arch = tokens[2] ?? ""; + const currentVersion = + trimmed.match(CURRENT_VERSION_PATTERN)?.[1] ?? tokens[5] ?? ""; if (!pkgname || nextVersion === currentVersion) { continue; } diff --git a/src/__tests__/unit/App.update-center-runtime.test.ts b/src/__tests__/unit/App.update-center-runtime.test.ts new file mode 100644 index 00000000..b3fee6b0 --- /dev/null +++ b/src/__tests__/unit/App.update-center-runtime.test.ts @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import App from "@/App.vue"; + +const invoke = vi.fn(); +const updateCenterOpen = vi.fn(); + +vi.mock("axios", () => { + const get = vi.fn(async () => ({ data: [] })); + + return { + default: { + create: () => ({ get }), + }, + }; +}); + +describe("App update center runtime", () => { + beforeEach(() => { + invoke.mockReset(); + updateCenterOpen.mockReset(); + + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "both"; + if (channel === "check-spark-available") return true; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + return []; + }); + + Object.assign(window.ipcRenderer, { + invoke, + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }); + + Object.assign(window, { + updateCenter: { + open: updateCenterOpen.mockResolvedValue({ + items: [], + tasks: [], + warnings: [], + hasRunningTasks: false, + }), + refresh: vi.fn(), + ignore: vi.fn(), + unignore: vi.fn(), + start: vi.fn(), + cancel: vi.fn(), + getState: vi.fn(), + onState: vi.fn(), + offState: vi.fn(), + }, + }); + + window.apm_store.arch = "amd64"; + + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + }); + + it("opens update center with an empty snapshot without throwing", async () => { + render(App); + + await fireEvent.click(await screen.findByText("软件更新")); + + expect(updateCenterOpen).toHaveBeenCalledWith("both"); + expect(await screen.findByText("暂无可展示的更新任务")).toBeTruthy(); + }); +}); diff --git a/src/__tests__/unit/App.update-center.test.ts b/src/__tests__/unit/App.update-center.test.ts new file mode 100644 index 00000000..872a1ee2 --- /dev/null +++ b/src/__tests__/unit/App.update-center.test.ts @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import App from "@/App.vue"; + +const invoke = vi.fn(); +const open = vi.fn(); + +vi.mock("axios", () => { + const get = vi.fn(async (url: string) => { + if (url.includes("categories.json")) { + return { data: {} }; + } + + if (url.includes("homelinks.json") || url.includes("homelist.json")) { + return { data: [] }; + } + + if (url.includes("applist.json")) { + return { data: [] }; + } + + return { data: [] }; + }); + + return { + default: { + create: () => ({ get }), + }, + }; +}); + +vi.mock("@/modules/updateCenter", () => ({ + createUpdateCenterStore: () => ({ + isOpen: { value: false }, + showCloseConfirm: { value: false }, + showMigrationConfirm: { value: false }, + searchQuery: { value: "" }, + selectedTaskKeys: { value: new Set() }, + snapshot: { + value: { items: [], tasks: [], warnings: [], hasRunningTasks: false }, + }, + filteredItems: { value: [] }, + allSelected: { value: false }, + someSelected: { value: false }, + bind: vi.fn(), + unbind: vi.fn(), + open, + refresh: vi.fn(), + ignoreItem: vi.fn(), + unignoreItem: vi.fn(), + toggleSelection: vi.fn(), + toggleSelectAll: vi.fn(), + getSelectedItems: vi.fn(() => []), + closeNow: vi.fn(), + startSelected: vi.fn(), + requestClose: vi.fn(), + }), +})); + +describe("App update center entry", () => { + beforeEach(() => { + invoke.mockReset(); + open.mockReset(); + + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "both"; + if (channel === "check-spark-available") return true; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + return []; + }); + + Object.assign(window.ipcRenderer, { + invoke, + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + }); + + window.apm_store.arch = "amd64"; + + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + }); + + it("opens update center when clicking the sidebar action", async () => { + render(App); + + await fireEvent.click(await screen.findByText("软件更新")); + + expect(open).toHaveBeenCalledWith("both"); + }); +}); diff --git a/src/__tests__/unit/update-center/load-items.test.ts b/src/__tests__/unit/update-center/load-items.test.ts index 32e26adf..b231bc2a 100644 --- a/src/__tests__/unit/update-center/load-items.test.ts +++ b/src/__tests__/unit/update-center/load-items.test.ts @@ -11,7 +11,7 @@ type RemoteStoreResponse = | Array>; 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"; + "bash -lc env LANGUAGE=en_US aptss list --upgradable"; const DPKG_QUERY_INSTALLED_KEY = "dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n"; diff --git a/src/__tests__/unit/update-center/query.test.ts b/src/__tests__/unit/update-center/query.test.ts index 027c3d96..b9c7916b 100644 --- a/src/__tests__/unit/update-center/query.test.ts +++ b/src/__tests__/unit/update-center/query.test.ts @@ -28,6 +28,25 @@ describe("update-center query", () => { ]); }); + it("parses aptss wrapper output with ansi noise before package lines", () => { + const output = [ + "\u001b[1;32m信息:正在使用非 Root 权限模式启动!若出现问题,请尝试使用 Root 权限执行命令。\u001b[0m", + "正在列表...", + "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]", + "", + ].join("\n"); + + expect(parseAptssUpgradableOutput(output)).toEqual([ + { + pkgname: "spark-weather", + source: "aptss", + currentVersion: "1.9.0", + nextVersion: "2.0.0", + arch: "amd64", + }, + ]); + }); + 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]"; diff --git a/src/__tests__/unit/update-center/store.test.ts b/src/__tests__/unit/update-center/store.test.ts index 2f0da84a..8481ac22 100644 --- a/src/__tests__/unit/update-center/store.test.ts +++ b/src/__tests__/unit/update-center/store.test.ts @@ -69,6 +69,18 @@ describe("updateCenter store", () => { expect(store.filteredItems.value).toEqual(snapshot.items); }); + it("reuses the last store filter when refreshing without an explicit filter", async () => { + const snapshot = createSnapshot(); + open.mockResolvedValue(snapshot); + refresh.mockResolvedValue(snapshot); + const store = createUpdateCenterStore(); + + await store.open("apm"); + await store.refresh(); + + expect(refresh).toHaveBeenCalledWith("apm"); + }); + it("starts only the selected non-ignored items", async () => { const snapshot = createSnapshot({ items: [ diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts index f99c3b5e..acb36b82 100644 --- a/src/modules/updateCenter.ts +++ b/src/modules/updateCenter.ts @@ -59,6 +59,7 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { const searchQuery = ref(""); const selectedTaskKeys = ref(new Set()); const snapshot = ref(EMPTY_SNAPSHOT); + let lastStoreFilter: StoreFilter = "both"; const resetSessionState = (): void => { showCloseConfirm.value = false; @@ -131,13 +132,17 @@ export const createUpdateCenterStore = (): UpdateCenterStore => { }; const open = async (storeFilter: StoreFilter = "both"): Promise => { + lastStoreFilter = storeFilter; resetSessionState(); const nextSnapshot = await window.updateCenter.open(storeFilter); applySnapshot(nextSnapshot); isOpen.value = true; }; - const refresh = async (storeFilter: StoreFilter = "both"): Promise => { + const refresh = async ( + storeFilter: StoreFilter = lastStoreFilter, + ): Promise => { + lastStoreFilter = storeFilter; const nextSnapshot = await window.updateCenter.refresh(storeFilter); applySnapshot(nextSnapshot); };