414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
/**
|
||
* 单元测试: autoUpdater - 安装类型检测
|
||
*
|
||
* 测试 Windows 安装类型检测逻辑(NSIS vs MSI)
|
||
* 通过 mock electron 和 fs 模块验证 getInstallerType() / canAutoUpdate() 的行为
|
||
*
|
||
* 注意: detectInstallerType 是私有函数,但 getInstallerType / canAutoUpdate 已导出
|
||
* 每个测试重置模块缓存以清除 cachedInstallerType
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
|
||
// ── Mocks ──
|
||
|
||
const mockExistsSync = vi.fn((...args: unknown[]) => false);
|
||
const mockReaddirSync = vi.fn((...args: unknown[]) => [] as string[]);
|
||
|
||
vi.mock("fs", () => ({
|
||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||
}));
|
||
|
||
vi.mock("electron-log", () => ({
|
||
default: {
|
||
info: vi.fn(),
|
||
warn: vi.fn(),
|
||
error: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock("electron", () => ({
|
||
app: {
|
||
isPackaged: true,
|
||
getName: () => "QimingClaw",
|
||
getPath: (name: string) => {
|
||
if (name === "exe")
|
||
return "C:\\Users\\user\\AppData\\Local\\Programs\\QimingClaw\\QimingClaw.exe";
|
||
return "";
|
||
},
|
||
getAppPath: () => "/app",
|
||
getVersion: () => "0.9.4",
|
||
},
|
||
BrowserWindow: class {},
|
||
shell: {},
|
||
dialog: {},
|
||
net: {},
|
||
}));
|
||
|
||
vi.mock("electron-updater", () => ({
|
||
autoUpdater: {
|
||
on: vi.fn(),
|
||
setFeedURL: vi.fn(),
|
||
checkForUpdates: vi.fn(),
|
||
downloadUpdate: vi.fn(),
|
||
quitAndInstall: vi.fn(),
|
||
autoDownload: false,
|
||
autoInstallOnAppQuit: true,
|
||
},
|
||
}));
|
||
|
||
vi.mock("@shared/constants", () => ({
|
||
APP_DATA_DIR_NAME: ".qimingclaw",
|
||
}));
|
||
|
||
vi.mock("../db", () => ({
|
||
readSetting: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("./i18n", () => ({
|
||
t: (key: string) => key,
|
||
}));
|
||
|
||
vi.mock("./updatePlatformUtils", () => ({
|
||
getWindowsDownloadUrl: vi.fn(),
|
||
getMacosDownloadUrl: vi.fn(),
|
||
getLinuxDownloadUrl: vi.fn(),
|
||
}));
|
||
|
||
// ── Helper ──
|
||
|
||
/** 重置模块缓存并重新导入,确保 cachedInstallerType 被清除 */
|
||
async function importFresh() {
|
||
vi.resetModules();
|
||
return import("./autoUpdater");
|
||
}
|
||
|
||
/** 让 process.platform 模拟为 win32 */
|
||
function mockWin32() {
|
||
Object.defineProperty(process, "platform", {
|
||
value: "win32",
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
/** 让 process.platform 模拟为 darwin */
|
||
function mockDarwin() {
|
||
Object.defineProperty(process, "platform", {
|
||
value: "darwin",
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
/** 让 process.platform 模拟为 linux */
|
||
function mockLinux() {
|
||
Object.defineProperty(process, "platform", {
|
||
value: "linux",
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
/** 恢复 platform */
|
||
function restorePlatform() {
|
||
// vitest 在 Node 中 process.platform 原值已丢失,记录初始值
|
||
// 这里不做恢复,每个测试自己设 platform
|
||
}
|
||
|
||
// ── Tests ──
|
||
|
||
describe("autoUpdater - getInstallerType & canAutoUpdate", () => {
|
||
beforeEach(() => {
|
||
mockExistsSync.mockReset();
|
||
mockReaddirSync.mockReset();
|
||
mockExistsSync.mockReturnValue(false);
|
||
mockReaddirSync.mockReturnValue([]);
|
||
});
|
||
|
||
describe("macOS / Linux 平台", () => {
|
||
it("macOS 应返回 'mac'", async () => {
|
||
mockDarwin();
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("mac");
|
||
});
|
||
|
||
it("Linux 应返回 'linux'", async () => {
|
||
mockLinux();
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("linux");
|
||
});
|
||
});
|
||
|
||
describe("Windows NSIS 检测", () => {
|
||
beforeEach(() => {
|
||
mockWin32();
|
||
});
|
||
|
||
it("标准 NSIS: 存在 'Uninstall QimingClaw.exe' 应返回 'nsis'", async () => {
|
||
mockExistsSync.mockImplementation((...args: unknown[]) =>
|
||
String(args[0]).includes("Uninstall QimingClaw.exe"),
|
||
);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
});
|
||
|
||
it("通用 NSIS: 存在 unins000.exe 应返回 'nsis'", async () => {
|
||
mockReaddirSync.mockReturnValue(["app.exe", "resources", "unins000.exe"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
});
|
||
|
||
it("通用 NSIS: unins001.exe 也应返回 'nsis'", async () => {
|
||
mockReaddirSync.mockReturnValue(["app.exe", "unins001.exe"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
});
|
||
|
||
it("方式3: Uninstall.exe (无产品名) 应返回 'nsis'", async () => {
|
||
mockReaddirSync.mockReturnValue(["app.exe", "Uninstall.exe"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
});
|
||
|
||
it("方式3: unins.exe 应返回 'nsis'", async () => {
|
||
mockReaddirSync.mockReturnValue(["app.exe", "unins.exe"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
});
|
||
|
||
it("优先级: 标准 NSIS 优先于通用 NSIS", async () => {
|
||
mockExistsSync.mockImplementation((...args: unknown[]) =>
|
||
String(args[0]).includes("Uninstall QimingClaw.exe"),
|
||
);
|
||
// 即使目录中也有 unins000.exe,应该先走 existsSync 的标准检测
|
||
mockReaddirSync.mockReturnValue(["app.exe", "unins000.exe"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("nsis");
|
||
// readdirSync 不应被调用(标准检测先命中)
|
||
expect(mockReaddirSync).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("Windows MSI 检测 (fallback)", () => {
|
||
beforeEach(() => {
|
||
mockWin32();
|
||
});
|
||
|
||
it("目录中无卸载程序文件应返回 'msi'", async () => {
|
||
mockReaddirSync.mockReturnValue(["app.exe", "resources", "locales"]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("msi");
|
||
});
|
||
|
||
it("readdirSync 抛出异常应 fallback 为 'msi'", async () => {
|
||
mockReaddirSync.mockImplementation(() => {
|
||
throw new Error("ENOENT");
|
||
});
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("msi");
|
||
});
|
||
|
||
it("目录为空应返回 'msi'", async () => {
|
||
mockReaddirSync.mockReturnValue([]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("msi");
|
||
});
|
||
|
||
it("不应将无关文件误判为 NSIS", async () => {
|
||
mockReaddirSync.mockReturnValue([
|
||
"app.exe",
|
||
"qimingclaw.exe",
|
||
"setup.exe",
|
||
"uninstall.dat",
|
||
"uninstall", // 无扩展名
|
||
"helper.dll",
|
||
]);
|
||
|
||
const { getInstallerType } = await importFresh();
|
||
expect(getInstallerType()).toBe("msi");
|
||
});
|
||
});
|
||
|
||
describe("canAutoUpdate", () => {
|
||
it("NSIS 应支持自动更新", async () => {
|
||
mockWin32();
|
||
mockExistsSync.mockImplementation((...args: unknown[]) =>
|
||
String(args[0]).includes("Uninstall QimingClaw.exe"),
|
||
);
|
||
|
||
const { canAutoUpdate } = await importFresh();
|
||
expect(canAutoUpdate()).toBe(true);
|
||
});
|
||
|
||
it("MSI 不支持自动更新", async () => {
|
||
mockWin32();
|
||
mockReaddirSync.mockReturnValue(["app.exe"]);
|
||
|
||
const { canAutoUpdate } = await importFresh();
|
||
expect(canAutoUpdate()).toBe(false);
|
||
});
|
||
|
||
it("macOS 应支持自动更新", async () => {
|
||
mockDarwin();
|
||
const { canAutoUpdate } = await importFresh();
|
||
expect(canAutoUpdate()).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── yml URL 处理逻辑测试 ──
|
||
// 测试 electron-updater generic provider 的 URL 构造行为:
|
||
// setFeedURL({ provider: "generic", url: "https://.../dir/" }) 会自动拼接 {channel}.yml
|
||
// 因此传入的 URL 必须是目录路径(以 / 结尾),而不是文件 URL
|
||
|
||
describe("autoUpdater - yml URL 处理", () => {
|
||
// 纯函数测试:模拟 doCheckViaLatestJson 中的 URL 推导逻辑
|
||
function deriveFeedUrl(params: {
|
||
ymlUrl: string | null;
|
||
updateChannel: "stable" | "beta";
|
||
version: string;
|
||
}): string {
|
||
const OSS_BASE =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron";
|
||
const { ymlUrl, updateChannel, version } = params;
|
||
// 从 yml 文件 URL 提取目录路径(electron-updater generic provider 期望目录 URL)
|
||
const ymlDir = ymlUrl ? ymlUrl.replace(/\/[^/]+\.yml$/, "/") : null;
|
||
return ymlDir
|
||
? ymlDir
|
||
: updateChannel === "beta"
|
||
? `${OSS_BASE}/beta-build/prerelease-v${version}`
|
||
: `${OSS_BASE}/electron-v${version}`;
|
||
}
|
||
|
||
describe("yml 文件 URL → 目录 URL 转换(供 electron-updater generic provider 使用)", () => {
|
||
it("Windows: yml 文件 URL 应提取为目录 URL", () => {
|
||
const ymlUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest.yml";
|
||
const result = deriveFeedUrl({
|
||
ymlUrl,
|
||
updateChannel: "beta",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/",
|
||
);
|
||
});
|
||
|
||
it("macOS: yml 文件 URL 应提取为目录 URL", () => {
|
||
const ymlUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest-mac.yml";
|
||
const result = deriveFeedUrl({
|
||
ymlUrl,
|
||
updateChannel: "beta",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/",
|
||
);
|
||
});
|
||
|
||
it("Linux: yml 文件 URL 应提取为目录 URL", () => {
|
||
const ymlUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest-linux.yml";
|
||
const result = deriveFeedUrl({
|
||
ymlUrl,
|
||
updateChannel: "beta",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/",
|
||
);
|
||
});
|
||
|
||
it("stable 通道: yml 文件 URL 应提取为目录 URL", () => {
|
||
const ymlUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/electron-v0.10.7/latest.yml";
|
||
const result = deriveFeedUrl({
|
||
ymlUrl,
|
||
updateChannel: "stable",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/electron-v0.10.7/",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("降级逻辑(yml 字段缺失时的 fallback)", () => {
|
||
it("yml 字段缺失时,beta 通道应使用 beta-build/prerelease-v{version} 路径", () => {
|
||
const result = deriveFeedUrl({
|
||
ymlUrl: null,
|
||
updateChannel: "beta",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7",
|
||
);
|
||
});
|
||
|
||
it("yml 字段缺失时,stable 通道应使用 electron-v{version} 路径", () => {
|
||
const result = deriveFeedUrl({
|
||
ymlUrl: null,
|
||
updateChannel: "stable",
|
||
version: "0.10.7",
|
||
});
|
||
expect(result).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/electron-v0.10.7",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("electron-updater generic provider URL 拼接验证", () => {
|
||
// 模拟 GenericProvider 的 URL 构造行为:
|
||
// new URL(channelFile, newBaseUrl(directoryUrl))
|
||
// newBaseUrl 会确保目录 URL 以 / 结尾
|
||
function simulateGenericProvider(
|
||
channelFile: string,
|
||
feedUrl: string,
|
||
): string {
|
||
const url = new URL(feedUrl);
|
||
if (!url.pathname.endsWith("/")) {
|
||
url.pathname += "/";
|
||
}
|
||
return new URL(channelFile, url).toString();
|
||
}
|
||
|
||
it("目录 URL 传给 electron-updater 应拼接出正确的 yml 文件路径", () => {
|
||
const feedUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/";
|
||
const windowsResult = simulateGenericProvider("latest.yml", feedUrl);
|
||
const macResult = simulateGenericProvider("latest-mac.yml", feedUrl);
|
||
const linuxResult = simulateGenericProvider("latest-linux.yml", feedUrl);
|
||
|
||
expect(windowsResult).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest.yml",
|
||
);
|
||
expect(macResult).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest-mac.yml",
|
||
);
|
||
expect(linuxResult).toBe(
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest-linux.yml",
|
||
);
|
||
});
|
||
|
||
it("文件 URL 传给 electron-updater 会导致路径重复(Bug 演示)", () => {
|
||
// 这是之前错误的做法:直接传文件 URL
|
||
const fileUrl =
|
||
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron/beta-build/prerelease-v0.10.7/latest.yml";
|
||
const result = simulateGenericProvider("latest.yml", fileUrl);
|
||
|
||
// URL 构造器会将 /latest.yml 当作文件名,拼接后变成 /beta-build/prerelease-v0.10.7//latest.yml/latest.yml
|
||
// 最终标准化为 .../beta-build/prerelease-v0.10.7/latest.yml/latest.yml(路径重复)
|
||
expect(result).toContain("latest.yml/latest.yml");
|
||
});
|
||
});
|
||
});
|