mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-11-11 23:11:03 +08:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user