fix(install): 修复 APM 未安装时点安装静默无反应,并清理同类接线断裂

主问题:showApmInstallDialog 在 useAppState.ts 被重复声明为独立 ref,
而 processInstall.ts 置位的是 storeConfig.ts 中的另一份实例,导致 APM
未安装时点「安装」既不弹引导弹窗也不创建下载任务(界面毫无反应)。

修复:
- useAppState 改为从 storeConfig 再导出同一实例(消除双实例)
- processInstall 在 install-complete 兜底识别「未安装 APM」失败原因
- 新增回归测试 ApmInstallDialog.test.ts(回退修复后 3/3 失败)

同类「接线断裂」问题全量排查修复(源自 d7bdcf36 拆分 composables):
- 应用下载排行永远为空:loadRanking 在 apps 未填充时空跑且不重试
- 详情页卸载后按钮状态不刷新(丢失的 checkAppInstalled 分支)
- 重试已取消任务复用 id,被旧任务 finally 删除而永久卡在下载中
- remove-installed 的 spawn 缺 error 监听(可致主进程崩溃)且不消费 stderr
- 云端应用列表在来源查询失败时静默丢数据(已安装列表同源问题)
- 忽略更新写入失败无反馈;下载统计上报缺 catch
- isInstalledAppInfo 运行时校验被削弱

清理重构遗留孤儿代码:
- 删除无引用的 AppDetailPage/AccountQuickMenu/TopActions/UpdateCenterCloseConfirm
- 恢复 handleRemoveComplete 语义与 onUnmounted 的 abort/unbind
- 删除孤儿 IPC:list-upgradable/show-apm-install-dialog/uninstall-installed
- 删除孤儿渲染端代码:handleUpgrade/handleRemove/useDownloadItemStatus
  及已无生产者的 queue-install 回灌监听器

验证:vue-tsc 0 error、vite build 通过、单元测试无新增失败
(基线 40 失败 → 35 失败)。

Signed-off-by: shenmo <jifengshenmo@outlook.com>
This commit is contained in:
2026-10-11 12:34:10 +08:00
parent 48a6e10263
commit 56a7e11622
16 changed files with 415 additions and 1056 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* APM 安装引导弹窗回归测试。
*
* 背景(历史 Bug):APM 未安装时点击安装 APM 来源的应用,商店「静默什么都不发生」。
* 根因是 showApmInstallDialog 存在两份 ref 实例:
* - src/global/storeConfig.ts (modules/processInstall 置位)
* - src/composables/useAppState.ts (App.vue 读取,历史上重复 ref(false) 声明)
* 置位写的是 A,弹窗读的是 B,因此弹窗永不显示、下载任务也不会创建。
*
* 本测试锁死「同一实例」这一不变量,并端到端验证:APM 未安装时点「安装」
* 必定弹出 APM 引导弹窗(而不是静默失败)。
*/
import { cleanup, fireEvent, render, screen } from "@testing-library/vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// 注意:App / processInstall / 共享状态一律在用例内动态 import。
// 原因:processInstall 在模块初始化时就用 window.ipcRenderer.on 注册监听,
// 静态 import 会在测试文件的 beforeEach 之前执行(此时收集用的 on mock 还没装上),
// 处理器就收集不到了。
const importApp = () => import("@/App.vue");
const importState = () => import("@/composables/useAppState");
const importDownloads = () => import("@/global/downloadStatus");
const invoke = vi.fn();
const send = vi.fn();
const ipcHandlers = new Map<string, Array<(...args: unknown[]) => void>>();
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
return { data: { office: { zh: "办公" } } };
}
if (url.includes("/office/applist.json")) {
return {
data: [
{
Name: "WPS",
Pkgname: "wps",
Version: "1.0.0",
Filename: "wps_1.0.0_amd64.deb",
Torrent_address: "",
Author: "",
Contributor: "",
Website: "",
Update: "",
Size: "",
More: "Office suite",
Tags: "",
img_urls: "[]",
icons: "",
},
],
};
}
return { data: [] };
});
const post = vi.fn(async () => ({ data: { ok: true } }));
return {
default: {
create: () => ({
interceptors: {
request: { use: () => undefined },
response: { use: () => undefined },
},
get,
post,
}),
},
};
});
vi.mock("@/modules/updateCenter", () => ({
createUpdateCenterStore: () => ({
isOpen: { value: false },
showCloseConfirm: { value: false },
showMigrationConfirm: { value: false },
searchQuery: { value: "" },
selectedTaskKeys: { value: new Set<string>() },
snapshot: {
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
},
filteredItems: { value: [] },
allSelected: { value: false },
someSelected: { value: false },
bind: vi.fn(),
unbind: vi.fn(),
open: vi.fn(),
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("APM 安装引导弹窗", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "apm";
if (channel === "check-spark-available") return false;
// 本机未安装 APM —— 这正是弹窗需要出现的场景
if (channel === "check-apm-available") return false;
if (channel === "get-app-version") return "5.0.0";
if (channel === "get-system-info") return { distro: "deepin 25" };
if (channel === "list-installed") return { success: true, apps: [] };
if (channel === "check-installed") return false;
return [];
});
Object.assign(window.ipcRenderer, {
invoke,
send,
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
// 同一 channel 可能有多个监听者(App.vue 与 processInstall 各注册一份),
// 全部收集,避免只拿到最后一个 handler。
const list = ipcHandlers.get(channel) ?? [];
list.push(handler);
ipcHandlers.set(channel, list);
}),
off: vi.fn(),
});
window.apm_store.arch = "amd64";
localStorage.clear();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
vi.stubGlobal("scrollTo", vi.fn());
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
it("storeConfig 与 useAppState 导出的弹窗开关必须是同一个 ref 实例", async () => {
const config = await import("@/global/storeConfig");
const state = await importState();
expect(state.showApmInstallDialog).toBe(config.showApmInstallDialog);
// 写在任一侧,另一侧必须立刻可见(防止再次分裂成两份 ref)
config.showApmInstallDialog.value = false;
state.showApmInstallDialog.value = true;
expect(config.showApmInstallDialog.value).toBe(true);
});
it(
"APM 未安装时点击「安装」会弹出 APM 引导弹窗,且不创建下载任务",
{ timeout: 30000 },
async () => {
render((await importApp()).default);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(
await screen.findByRole("button", { name: "安装" }),
);
// 弹窗必须出现(修复前:因为读的是另一个 ref,这里永远找不到,界面「什么都不发生」)
expect(await screen.findByText("需要安装 APM")).toBeTruthy();
// 未安装 APM 时不应把任务塞进下载队列
expect(send).not.toHaveBeenCalledWith("queue-install", expect.anything());
},
);
it("主进程回传「未安装 APM」失败时会置位弹窗开关(兜底不再静默)", async () => {
// 清模块缓存后重新加载:测试文件自身对 processInstall 的依赖会在收集 mock 之前
// 就把它初始化掉,这里强制重跑一遍模块顶层,才能让本次用例收集到它注册的处理器。
vi.resetModules();
await import("@/modules/processInstall");
const { downloads } = await importDownloads();
// 先挂载 App,让 useDownloads 注册 downloadStatus 监听(它会改写 downloads.value),
// 之后再写入本次用例的下载项,避免被清空。
render((await importApp()).default);
await screen.findByRole("button", { name: "全部应用 1" });
downloads.value = [
{
id: 42,
name: "WPS",
pkgname: "wps",
version: "1.0.0",
icon: "",
origin: "apm",
status: "queued",
progress: 0,
downloadedSize: 0,
totalSize: 0,
speed: 0,
timeRemaining: 0,
startTime: Date.now(),
logs: [],
source: "APM Store",
retry: false,
},
];
const { showApmInstallDialog } = await importState();
showApmInstallDialog.value = false;
const completion = {
id: 42,
time: Date.now(),
success: false,
exitCode: -1,
message: JSON.stringify({
message: "未安装 APM,无法继续安装此应用",
stdout: "",
stderr: "",
}),
};
for (const handler of ipcHandlers.get("install-complete") ?? []) {
handler({}, completion);
}
expect(showApmInstallDialog.value).toBe(true);
expect(downloads.value[0].status).toBe("failed");
});
});
-193
View File
@@ -1,193 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it, vi } from "vitest";
import AppDetailPage from "@/components/AppDetailPage.vue";
import type { App, ReviewTags } from "@/global/typedefinition";
vi.mock("@/components/ReviewsPanel.vue", () => ({
default: {
name: "ReviewsPanel",
props: ["appKey", "tags", "loggedIn", "canSubmit"],
emits: ["request-login", "show-user"],
template:
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
},
}));
const app: App = {
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "110M",
more: "Office suite",
tags: "office",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
};
const sparkApp: App = {
...app,
name: "WPS Spark",
version: "2.0.0",
filename: "wps_2.0.0_amd64.deb",
origin: "spark",
};
const apmApp: App = {
...app,
name: "WPS APM",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
origin: "apm",
};
const mergedApp: App = {
...sparkApp,
isMerged: true,
sparkApp,
apmApp,
viewingOrigin: "spark",
};
const sparkTags: ReviewTags = {
origin: "spark",
category: "office",
pkgname: "wps",
version: "2.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
describe("AppDetailPage", () => {
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: false,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: null,
},
});
expect(screen.getByText("Office suite")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "返回" }));
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
expect(rendered.emitted("back")).toHaveLength(1);
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
"收藏应用需要登录星火账号。",
);
});
it("gates reviews for anonymous users", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: false,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.queryByTestId("reviews-panel")).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "登录后查看评价" }),
);
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
"登录后查看和发表评论。",
);
});
it("updates review identity when switching a merged app origin", async () => {
render(AppDetailPage, {
props: {
app: mergedApp,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "spark:amd64-store:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"spark:amd64-store:office:wps",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-origin",
"spark",
);
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"apm:amd64-apm:office:wps",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-origin",
"apm",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-version",
"1.0.0",
);
});
it("marks reviews read-only when the selected origin is not installed", () => {
render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-can-submit",
"false",
);
});
it("forwards review user profile events", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
await fireEvent.click(screen.getByTestId("reviews-panel"));
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
expect.objectContaining({ userDisplayName: "Detail User" }),
);
});
});
+5 -30
View File
@@ -1,39 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("processInstall queue forwarding", () => {
describe("processInstall", () => {
// 注:原先此处的「queue-install 事件回灌」用例已删除——
// 该渲染端监听器对应的主进程 webContents.send("queue-install") 生产者在
// 更新中心改为直接调用 addInstallTask 后即被移除(b5c5ab04),
// 监听器成为孤儿代码,已一并清理。
beforeEach(() => {
vi.resetModules();
});
it("forwards update-center queue-install events back to the main install queue", async () => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const send = vi.fn();
const on = vi.fn(
(channel: string, handler: (...args: unknown[]) => void) => {
handlers.set(channel, handler);
},
);
Object.assign(window.ipcRenderer, {
on,
send,
invoke: vi.fn(),
});
await import("@/modules/processInstall");
const payload = JSON.stringify({
id: 7,
pkgname: "spark-weather",
origin: "spark",
upgradeOnly: true,
});
handlers.get("queue-install")?.({}, payload);
expect(send).toHaveBeenCalledWith("queue-install", payload);
});
it("allocates install ids after existing update tasks", async () => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const send = vi.fn();