chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* 单元测试: logConfig (日志按日分割 + 大小轮转)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. todayDateStr — 返回 YYYY-MM-DD 格式
|
||||
* 2. isArchiveLogName — 归档文件名判定(当日活跃、旧格式、轮转序号等)
|
||||
* 3. resolvePathFn — 按日路径 + 跨午夜切换
|
||||
* 4. archiveLogFn — 按序号轮转
|
||||
* 5. updateLatestLog / updateLatestLogWithRetry — 符号链接/硬链接
|
||||
* 6. migrateOldMainLog — 旧 main.log 一次性迁移
|
||||
* 7. cleanupOldLogs — TTL 清理(不删当日活跃日志)
|
||||
* 8. initLogging — 完整初始化流程
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as path from "path";
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/mock/home"),
|
||||
isPackaged: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockLog = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
create: vi.fn(() => ({
|
||||
transports: {
|
||||
file: {
|
||||
resolvePathFn: null as null | ((...args: unknown[]) => string),
|
||||
level: "info",
|
||||
maxSize: 0,
|
||||
},
|
||||
console: {
|
||||
level: "debug",
|
||||
},
|
||||
},
|
||||
})),
|
||||
transports: {
|
||||
file: {
|
||||
resolvePathFn: null as null | ((...args: unknown[]) => string),
|
||||
archiveLogFn: null as
|
||||
| null
|
||||
| ((oldLogFile: { path: string; crop?: (n: number) => void }) => void),
|
||||
level: "info",
|
||||
maxSize: 0,
|
||||
},
|
||||
console: {
|
||||
level: "debug",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("electron-log", () => ({ default: mockLog }));
|
||||
|
||||
const mockExistsSync = vi.fn(() => false);
|
||||
const mockMkdirSync = vi.fn();
|
||||
const mockRenameSync = vi.fn();
|
||||
const mockUnlinkSync = vi.fn();
|
||||
const mockSymlinkSync = vi.fn();
|
||||
const mockLinkSync = vi.fn();
|
||||
const mockStatSync = vi.fn();
|
||||
const mockLstatSync = vi.fn();
|
||||
const mockReaddirSync = vi.fn((..._args: unknown[]) => [] as unknown[]);
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...(args as [string])),
|
||||
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||
renameSync: (...args: unknown[]) => mockRenameSync(...args),
|
||||
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
|
||||
symlinkSync: (...args: unknown[]) => mockSymlinkSync(...args),
|
||||
linkSync: (...args: unknown[]) => mockLinkSync(...args),
|
||||
statSync: (...args: unknown[]) => mockStatSync(...args),
|
||||
lstatSync: (...args: unknown[]) => mockLstatSync(...args),
|
||||
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
|
||||
}));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
/** 冻结 Date.now 和 new Date() 到指定日期 */
|
||||
function freezeDate(dateStr: string) {
|
||||
const frozen = new Date(dateStr + "T12:00:00");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(frozen);
|
||||
}
|
||||
|
||||
const LOG_DIR = path.join("/mock/home", ".qimingclaw", "logs");
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("logConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
// Reset mock log transports
|
||||
mockLog.transports.file.resolvePathFn = null;
|
||||
mockLog.transports.file.archiveLogFn = null;
|
||||
mockLog.transports.file.level = "info";
|
||||
mockLog.transports.file.maxSize = 0;
|
||||
mockLog.transports.console.level = "debug";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── isArchiveLogName (通过 cleanupOldLogs 间接测试) ──
|
||||
|
||||
describe("isArchiveLogName (归档文件名判定)", () => {
|
||||
/**
|
||||
* isArchiveLogName 是模块内部函数,通过 initLogging → cleanupOldLogs 间接验证。
|
||||
* 构造一个包含各种文件名的目录,验证哪些被删除(归档)、哪些保留。
|
||||
*/
|
||||
|
||||
it("应将非当日的 main.YYYY-MM-DD.log 视为归档", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
const now = Date.now();
|
||||
const oldMtime = now - 40 * 24 * 60 * 60 * 1000; // 40 天前,超过 TTL
|
||||
|
||||
// logDir 存在
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
// 模拟目录内容
|
||||
mockReaddirSync.mockReturnValue([
|
||||
{ name: "main.2026-03-16.log", isFile: () => true }, // 当日活跃 → 不删
|
||||
{ name: "main.2026-03-10.log", isFile: () => true }, // 非当日 → 归档
|
||||
{ name: "main.2026-03-10.1.log", isFile: () => true }, // 轮转文件 → 归档
|
||||
{ name: "main.2026-03-10.2.log", isFile: () => true }, // 轮转文件 → 归档
|
||||
{ name: "main.2026-02-01-143022.log", isFile: () => true }, // 旧格式 → 归档
|
||||
{ name: "main.2026-02-01.legacy.log", isFile: () => true }, // 迁移文件 → 归档
|
||||
{ name: "main.log", isFile: () => true }, // 旧格式残留 → 归档
|
||||
{ name: "main.old.log", isFile: () => true }, // 旧 .old → 归档
|
||||
{ name: "renderer.log", isFile: () => true }, // 当前 renderer → 不删
|
||||
{ name: "latest.log", isFile: () => false }, // symlink → isFile=false → 跳过
|
||||
{ name: "mcp-proxy.log", isFile: () => true }, // 不以 main./renderer. 开头 → 不删
|
||||
]);
|
||||
|
||||
// statSync 返回超过 TTL 的 mtime
|
||||
mockStatSync.mockReturnValue({ mtimeMs: oldMtime });
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// 应删除的文件(归档 + 超 TTL)
|
||||
const deletedFiles = mockUnlinkSync.mock.calls.map((call) =>
|
||||
path.basename(call[0] as string),
|
||||
);
|
||||
|
||||
expect(deletedFiles).toContain("main.2026-03-10.log");
|
||||
expect(deletedFiles).toContain("main.2026-03-10.1.log");
|
||||
expect(deletedFiles).toContain("main.2026-03-10.2.log");
|
||||
expect(deletedFiles).toContain("main.2026-02-01-143022.log");
|
||||
expect(deletedFiles).toContain("main.2026-02-01.legacy.log");
|
||||
expect(deletedFiles).toContain("main.log");
|
||||
expect(deletedFiles).toContain("main.old.log");
|
||||
|
||||
// 不应删除的文件(排除 updateLatestLog 中对 latest.log 的 unlink)
|
||||
const cleanupDeleted = deletedFiles.filter((f) => f !== "latest.log");
|
||||
expect(cleanupDeleted).not.toContain("main.2026-03-16.log");
|
||||
expect(cleanupDeleted).not.toContain("renderer.log");
|
||||
expect(deletedFiles).not.toContain("mcp-proxy.log");
|
||||
});
|
||||
|
||||
it("不应删除未超过 TTL 的归档文件", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([
|
||||
{ name: "main.2026-03-15.log", isFile: () => true },
|
||||
]);
|
||||
|
||||
// mtime = 1 天前,未超过 30 天 TTL(dev 模式)
|
||||
const recentMtime = Date.now() - 1 * 24 * 60 * 60 * 1000;
|
||||
mockStatSync.mockReturnValue({ mtimeMs: recentMtime });
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// statSync 被调用了(说明识别为归档),但未删除(未超 TTL)
|
||||
const deletedFiles = mockUnlinkSync.mock.calls.map((call) =>
|
||||
path.basename(call[0] as string),
|
||||
);
|
||||
expect(deletedFiles).not.toContain("main.2026-03-15.log");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolvePathFn (按日路径) ──
|
||||
|
||||
describe("resolvePathFn (按日路径)", () => {
|
||||
it("应返回 main.YYYY-MM-DD.log 路径", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const resolvePathFn = mockLog.transports.file.resolvePathFn!;
|
||||
expect(resolvePathFn).toBeDefined();
|
||||
|
||||
const result = resolvePathFn();
|
||||
expect(result).toBe(path.join(LOG_DIR, "main.2026-03-16.log"));
|
||||
});
|
||||
|
||||
it("跨午夜应切换到新日期文件", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const resolvePathFn = mockLog.transports.file.resolvePathFn!;
|
||||
|
||||
// 第一次调用:2026-03-16
|
||||
const result1 = resolvePathFn();
|
||||
expect(result1).toBe(path.join(LOG_DIR, "main.2026-03-16.log"));
|
||||
|
||||
// 模拟跨午夜
|
||||
vi.setSystemTime(new Date("2026-03-17T00:00:01"));
|
||||
|
||||
const result2 = resolvePathFn();
|
||||
expect(result2).toBe(path.join(LOG_DIR, "main.2026-03-17.log"));
|
||||
});
|
||||
});
|
||||
|
||||
// ── archiveLogFn (按序号轮转) ──
|
||||
|
||||
describe("archiveLogFn (大小轮转)", () => {
|
||||
it("首次轮转应生成序号 1", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
// 目录中没有已有的轮转文件
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
|
||||
expect(archiveLogFn).toBeDefined();
|
||||
|
||||
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
|
||||
archiveLogFn({ path: oldPath });
|
||||
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
oldPath,
|
||||
path.join(LOG_DIR, "main.2026-03-16.1.log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("已有序号 1 和 2 时,应生成序号 3", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
const dirFiles = [
|
||||
"main.2026-03-16.1.log",
|
||||
"main.2026-03-16.2.log",
|
||||
"main.2026-03-16.log",
|
||||
];
|
||||
|
||||
mockReaddirSync.mockImplementation((...args: unknown[]) => {
|
||||
const opts = args[1];
|
||||
// cleanupOldLogs 调用 readdirSync(dir, { withFileTypes: true })
|
||||
if (
|
||||
opts &&
|
||||
typeof opts === "object" &&
|
||||
(opts as Record<string, unknown>).withFileTypes
|
||||
) {
|
||||
return dirFiles.map((name) => ({ name, isFile: () => true }));
|
||||
}
|
||||
// archiveLogFn 调用 readdirSync(dir)(返回字符串数组)
|
||||
return dirFiles;
|
||||
});
|
||||
mockStatSync.mockReturnValue({ mtimeMs: Date.now() });
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
|
||||
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
|
||||
archiveLogFn({ path: oldPath });
|
||||
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
oldPath,
|
||||
path.join(LOG_DIR, "main.2026-03-16.3.log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("不应混淆其他日期的序号", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
const dirFiles = [
|
||||
"main.2026-03-15.5.log", // 昨天的序号 5,不应影响今天
|
||||
"main.2026-03-16.log",
|
||||
];
|
||||
|
||||
mockReaddirSync.mockImplementation((...args: unknown[]) => {
|
||||
const opts = args[1];
|
||||
if (
|
||||
opts &&
|
||||
typeof opts === "object" &&
|
||||
(opts as Record<string, unknown>).withFileTypes
|
||||
) {
|
||||
return dirFiles.map((name) => ({ name, isFile: () => true }));
|
||||
}
|
||||
return dirFiles;
|
||||
});
|
||||
mockStatSync.mockReturnValue({ mtimeMs: Date.now() });
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
|
||||
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
|
||||
archiveLogFn({ path: oldPath });
|
||||
|
||||
// 今天从序号 1 开始,不受昨天的序号 5 影响
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
oldPath,
|
||||
path.join(LOG_DIR, "main.2026-03-16.1.log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rename 失败时应调用 crop 截断", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
mockRenameSync.mockImplementation(() => {
|
||||
throw new Error("EPERM");
|
||||
});
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// 清掉 initLogging 阶段的 renameSync 调用(migrateOldMainLog 等)
|
||||
mockRenameSync.mockClear();
|
||||
mockRenameSync.mockImplementation(() => {
|
||||
throw new Error("EPERM");
|
||||
});
|
||||
|
||||
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
|
||||
const cropFn = vi.fn();
|
||||
const oldPath = path.join(LOG_DIR, "main.2026-03-16.log");
|
||||
archiveLogFn({ path: oldPath, crop: cropFn });
|
||||
|
||||
expect(cropFn).toHaveBeenCalled();
|
||||
expect(mockLog.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Rotation failed"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("重入时应直接 crop 截断,不尝试 rename(防止死循环)", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
mockRenameSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// 清掉 initLogging 阶段的调用
|
||||
mockRenameSync.mockClear();
|
||||
mockRenameSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockLog.warn.mockClear();
|
||||
|
||||
const archiveLogFn = mockLog.transports.file.archiveLogFn!;
|
||||
const reentrantCropFn = vi.fn();
|
||||
|
||||
// 模拟真实场景:log.warn 触发新的日志写入 → 再次调用 archiveLogFn(重入)
|
||||
mockLog.warn.mockImplementation(() => {
|
||||
archiveLogFn({
|
||||
path: path.join(LOG_DIR, "main.2026-03-16.log"),
|
||||
crop: reentrantCropFn,
|
||||
});
|
||||
});
|
||||
|
||||
const cropFn = vi.fn();
|
||||
archiveLogFn({
|
||||
path: path.join(LOG_DIR, "main.2026-03-16.log"),
|
||||
crop: cropFn,
|
||||
});
|
||||
|
||||
// 首次调用:rename 失败 → 走 catch → log.warn → 重入
|
||||
expect(cropFn).toHaveBeenCalled();
|
||||
|
||||
// 重入调用:isArchiving=true → 直接 crop(256KB),不再尝试 rename
|
||||
expect(reentrantCropFn).toHaveBeenCalledWith(256 * 1024);
|
||||
|
||||
// renameSync 只被首次调用调用 1 次,重入时不应再调用
|
||||
expect(mockRenameSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateLatestLog (符号链接) ──
|
||||
|
||||
describe("updateLatestLog (latest.log 链接)", () => {
|
||||
it("macOS/Linux 应创建指向当日文件的符号链接", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
// 保存并模拟 platform
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
// main.2026-03-16.log 存在
|
||||
if (s.endsWith("main.2026-03-16.log")) return true;
|
||||
// latest.log 不存在
|
||||
if (s.endsWith("latest.log")) return false;
|
||||
// logDir 存在
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockSymlinkSync).toHaveBeenCalledWith(
|
||||
"main.2026-03-16.log",
|
||||
path.join(LOG_DIR, "latest.log"),
|
||||
"file",
|
||||
);
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("Windows 应创建硬链接", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s.endsWith("main.2026-03-16.log")) return true;
|
||||
if (s.endsWith("latest.log")) return false;
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockLinkSync).toHaveBeenCalledWith(
|
||||
path.join(LOG_DIR, "main.2026-03-16.log"),
|
||||
path.join(LOG_DIR, "latest.log"),
|
||||
);
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("应先删除已有的 latest.log 再创建", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s.endsWith("main.2026-03-16.log")) return true;
|
||||
if (s.endsWith("latest.log")) return true; // 已存在
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// 应先 unlink 旧的 latest.log
|
||||
const unlinkCalls = mockUnlinkSync.mock.calls.map((c) => c[0] as string);
|
||||
expect(unlinkCalls).toContain(path.join(LOG_DIR, "latest.log"));
|
||||
|
||||
// 再创建新的符号链接
|
||||
expect(mockSymlinkSync).toHaveBeenCalledWith(
|
||||
"main.2026-03-16.log",
|
||||
path.join(LOG_DIR, "latest.log"),
|
||||
"file",
|
||||
);
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("dangling symlink 应被正确替换", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// existsSync 对 dangling symlink 返回 false(目标不存在)
|
||||
// lstatSync 对 dangling symlink 成功返回(链接本身存在)
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s.endsWith("main.2026-03-16.log")) return true;
|
||||
if (s.endsWith("latest.log")) return false; // dangling: 目标不存在
|
||||
return true;
|
||||
});
|
||||
mockLstatSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s.endsWith("latest.log")) return {}; // lstat 成功 → 链接本身存在
|
||||
return {};
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// lstatSync 发现链接存在 → unlinkSync 删除 dangling symlink
|
||||
const unlinkCalls = mockUnlinkSync.mock.calls.map((c) => c[0] as string);
|
||||
expect(unlinkCalls).toContain(path.join(LOG_DIR, "latest.log"));
|
||||
|
||||
// 再创建新的符号链接
|
||||
expect(mockSymlinkSync).toHaveBeenCalledWith(
|
||||
"main.2026-03-16.log",
|
||||
path.join(LOG_DIR, "latest.log"),
|
||||
"file",
|
||||
);
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── migrateOldMainLog (旧 main.log 迁移) ──
|
||||
|
||||
describe("migrateOldMainLog (旧日志迁移)", () => {
|
||||
it("应将旧 main.log 按 mtime 重命名为 legacy 文件", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
// main.log 存在
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s === path.join(LOG_DIR, "main.log")) return true;
|
||||
return true;
|
||||
});
|
||||
// main.log 的 mtime 是 2026-03-10
|
||||
const migrateMtime = new Date("2026-03-10T15:30:00");
|
||||
mockStatSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s === path.join(LOG_DIR, "main.log")) {
|
||||
return { mtime: migrateMtime, mtimeMs: migrateMtime.getTime() };
|
||||
}
|
||||
return { mtimeMs: Date.now() };
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join(LOG_DIR, "main.log"),
|
||||
path.join(LOG_DIR, "main.2026-03-10.legacy.log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("旧 main.log 不存在时不应执行迁移", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s === path.join(LOG_DIR, "main.log")) return false;
|
||||
return true;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// renameSync 不应被调用来迁移 main.log
|
||||
const renameCalls = mockRenameSync.mock.calls.map((c) =>
|
||||
path.basename(c[0] as string),
|
||||
);
|
||||
expect(renameCalls).not.toContain("main.log");
|
||||
});
|
||||
|
||||
it("迁移应在 TTL 清理之前执行", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
const callOrder: string[] = [];
|
||||
|
||||
// main.log 存在
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
mockStatSync.mockReturnValue({
|
||||
mtime: new Date("2026-01-01T00:00:00"),
|
||||
mtimeMs: new Date("2026-01-01").getTime(),
|
||||
});
|
||||
|
||||
// 追踪 renameSync(迁移)和 readdirSync(清理)调用顺序
|
||||
mockRenameSync.mockImplementation((...args: unknown[]) => {
|
||||
const src = path.basename(args[0] as string);
|
||||
if (src === "main.log") {
|
||||
callOrder.push("migrate");
|
||||
}
|
||||
});
|
||||
|
||||
mockReaddirSync.mockImplementation((...args: unknown[]) => {
|
||||
const dirPath = args[0] as string;
|
||||
if (dirPath === LOG_DIR && args[1]) {
|
||||
// readdirSync with withFileTypes → cleanupOldLogs
|
||||
callOrder.push("cleanup");
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
const migrateIdx = callOrder.indexOf("migrate");
|
||||
const cleanupIdx = callOrder.indexOf("cleanup");
|
||||
expect(migrateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(cleanupIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(migrateIdx).toBeLessThan(cleanupIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ── initLogging (完整初始化) ──
|
||||
|
||||
describe("initLogging (完整初始化)", () => {
|
||||
it("应创建日志目录(如不存在)", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const s = p as string;
|
||||
if (s === LOG_DIR) return false; // logDir 不存在
|
||||
return false;
|
||||
});
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(LOG_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it("应设置正确的 maxSize", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockLog.transports.file.maxSize).toBe(100 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("应设置 file level 为 debug(开发模式)", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
// app.isPackaged = false → dev mode → debug
|
||||
expect(mockLog.transports.file.level).toBe("debug");
|
||||
});
|
||||
|
||||
it("应输出初始化日志", async () => {
|
||||
freezeDate("2026-03-16");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
|
||||
const { initLogging } = await import("./logConfig");
|
||||
initLogging();
|
||||
|
||||
expect(mockLog.info).toHaveBeenCalledWith(
|
||||
"[LogConfig] Logging initialized",
|
||||
"(development)",
|
||||
"fileLevel=",
|
||||
"debug",
|
||||
"maxSize=",
|
||||
"100MB",
|
||||
"ttlDays=",
|
||||
30,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 主进程日志配置:按日分割、大小轮转、TTL 清理,开发/正式环境区分
|
||||
*
|
||||
* - 按日分割:每日一个日志文件 main.YYYY-MM-DD.log
|
||||
* - 大小轮转:单日文件超 maxSize 时按序号轮转为 main.YYYY-MM-DD.N.log
|
||||
* - TTL:启动时删除 logs 目录下超过有效期的归档文件
|
||||
* - latest.log:符号链接(或 Windows 硬链接)指向当日活跃日志
|
||||
* - 开发:文件级别 debug、更长保留期;正式:info、更短保留期
|
||||
*/
|
||||
|
||||
import log from "electron-log";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
APP_DATA_DIR_NAME,
|
||||
LOGS_DIR_NAME,
|
||||
PERF_LOG_FILENAME_PREFIX,
|
||||
} from "../services/constants";
|
||||
|
||||
/** 开发环境:未打包或 NODE_ENV=development */
|
||||
function isDev(): boolean {
|
||||
return process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
}
|
||||
|
||||
/** 单文件最大字节数:默认 100MB */
|
||||
const MAX_SIZE_DEV = 100 * 1024 * 1024;
|
||||
const MAX_SIZE_PROD = 100 * 1024 * 1024;
|
||||
// 验证轮转时可将上面两行临时改为 50 * 1024(50KB),重启后打日志即可触发轮转,验证完改回
|
||||
|
||||
/** 归档日志保留时间(毫秒):开发 30 天,正式 7 天 */
|
||||
const TTL_MS_DEV = 30 * 24 * 60 * 60 * 1000;
|
||||
const TTL_MS_PROD = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 返回当天日期字符串 YYYY-MM-DD */
|
||||
function todayDateStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 转义正则特殊字符 */
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** 追踪当前日期,用于检测跨午夜切换 */
|
||||
let lastDateStr = "";
|
||||
|
||||
/**
|
||||
* 视为归档的日志文件名模式,用于 TTL 清理:
|
||||
* - main.YYYY-MM-DD.log(非当日)
|
||||
* - main.YYYY-MM-DD.N.log(大小轮转)
|
||||
* - main.YYYY-MM-DD-HHmmss.log(旧格式兼容)
|
||||
* - main.YYYY-MM-DD.legacy.log(旧 main.log 迁移)
|
||||
* - main.old.log / renderer.old.log
|
||||
* - main.log(旧格式,迁移后残留)
|
||||
*
|
||||
* 不视为归档:
|
||||
* - main.{todayDateStr()}.log(当日活跃日志)
|
||||
* - renderer.log
|
||||
* - latest.log
|
||||
* - mcp-proxy-*.log
|
||||
*/
|
||||
function isArchiveLogName(name: string): boolean {
|
||||
const n = name.toLowerCase();
|
||||
// 旧格式 .old.log
|
||||
if (n === "main.old.log" || n === "renderer.old.log") return true;
|
||||
if (!n.endsWith(".log")) return false;
|
||||
// 当前 renderer 不归档
|
||||
if (n === "renderer.log") return false;
|
||||
// 旧 main.log(迁移后残留)视为归档
|
||||
if (n === "main.log") return true;
|
||||
// 当日活跃日志不归档
|
||||
const today = todayDateStr();
|
||||
if (n === `main.${today}.log`) return false;
|
||||
if (n === `${PERF_LOG_FILENAME_PREFIX}.${today}.log`) return false;
|
||||
// main.* / perf.* 开头的其他日志都视为归档
|
||||
return (
|
||||
n.startsWith("main.") ||
|
||||
n.startsWith("renderer.") ||
|
||||
n.startsWith(`${PERF_LOG_FILENAME_PREFIX}.`)
|
||||
);
|
||||
}
|
||||
|
||||
const LATEST_LOG_FILENAME = "latest.log";
|
||||
|
||||
/**
|
||||
* 使 latest.log 指向当日活跃日志 main.YYYY-MM-DD.log
|
||||
* - macOS/Linux:符号链接(相对路径)
|
||||
* - Windows:硬链接
|
||||
*/
|
||||
function updateLatestLog(logDir: string): void {
|
||||
const dateStr = todayDateStr();
|
||||
const mainName = `main.${dateStr}.log`;
|
||||
const mainPath = path.join(logDir, mainName);
|
||||
if (!fs.existsSync(mainPath)) return;
|
||||
const latestPath = path.join(logDir, LATEST_LOG_FILENAME);
|
||||
try {
|
||||
// 用 lstatSync 检测:existsSync 对 dangling symlink 返回 false,导致无法删除旧链接
|
||||
try {
|
||||
fs.lstatSync(latestPath);
|
||||
fs.unlinkSync(latestPath);
|
||||
} catch {
|
||||
/* 不存在 */
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
fs.linkSync(mainPath, latestPath);
|
||||
} else {
|
||||
fs.symlinkSync(mainName, latestPath, "file");
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("[LogConfig] latest.log create/update failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的 updateLatestLog,确保日志文件已创建后再建立链接
|
||||
* Windows 平台:electron-log 轮转后异步创建新文件,可能需要等待
|
||||
*/
|
||||
function updateLatestLogWithRetry(
|
||||
logDir: string,
|
||||
retries = 20,
|
||||
delayMs = 100,
|
||||
): void {
|
||||
const dateStr = todayDateStr();
|
||||
const mainPath = path.join(logDir, `main.${dateStr}.log`);
|
||||
|
||||
if (fs.existsSync(mainPath)) {
|
||||
updateLatestLog(logDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retries > 0) {
|
||||
setTimeout(
|
||||
() => updateLatestLogWithRetry(logDir, retries - 1, delayMs),
|
||||
delayMs,
|
||||
);
|
||||
} else {
|
||||
log.warn(
|
||||
`[LogConfig] latest.log update failed: main.${dateStr}.log does not exist, retries exhausted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 logDir 下过期的归档日志
|
||||
*/
|
||||
function cleanupOldLogs(logDir: string, maxAgeMs: number): void {
|
||||
if (!fs.existsSync(logDir)) return;
|
||||
const now = Date.now();
|
||||
const entries = fs.readdirSync(logDir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
if (!isArchiveLogName(e.name)) continue;
|
||||
const fullPath = path.join(logDir, e.name);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (now - stat.mtimeMs > maxAgeMs) {
|
||||
fs.unlinkSync(fullPath);
|
||||
log.info("[LogConfig] Deleted expired log:", e.name);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("[LogConfig] Failed to clean log:", e.name, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧 main.log 一次性迁移:按 mtime 重命名为 main.YYYY-MM-DD.legacy.log
|
||||
*/
|
||||
function migrateOldMainLog(logDir: string): void {
|
||||
const oldMainPath = path.join(logDir, "main.log");
|
||||
if (!fs.existsSync(oldMainPath)) return;
|
||||
try {
|
||||
const stat = fs.statSync(oldMainPath);
|
||||
const mtime = stat.mtime;
|
||||
const dateStr = `${mtime.getFullYear()}-${String(mtime.getMonth() + 1).padStart(2, "0")}-${String(mtime.getDate()).padStart(2, "0")}`;
|
||||
const legacyName = `main.${dateStr}.legacy.log`;
|
||||
const legacyPath = path.join(logDir, legacyName);
|
||||
fs.renameSync(oldMainPath, legacyPath);
|
||||
log.info("[LogConfig] Old main.log migrated to:", legacyName);
|
||||
} catch (e) {
|
||||
log.warn("[LogConfig] Old main.log migration failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PERF 专用日志 ====================
|
||||
|
||||
let _perfLogger: ReturnType<typeof log.create> | null = null;
|
||||
|
||||
/**
|
||||
* 初始化 PERF 专用 logger,写入 perf.YYYY-MM-DD.log
|
||||
* 由 initLogging() 内部调用,logDir 已保证存在
|
||||
*/
|
||||
function initPerfLogging(logDir: string): void {
|
||||
_perfLogger = log.create({ logId: "perf" });
|
||||
_perfLogger.transports.file.resolvePathFn = () => {
|
||||
const dateStr = todayDateStr();
|
||||
return path.join(logDir, `${PERF_LOG_FILENAME_PREFIX}.${dateStr}.log`);
|
||||
};
|
||||
// perf 日志无论开发/正式均写 info 级别(性能数据本身有价值)
|
||||
_perfLogger.transports.file.level = "info";
|
||||
// 不重复打到控制台(main logger 已输出);electron-log v5 level=false 禁用该 transport
|
||||
(_perfLogger.transports.console as any).level = false;
|
||||
_perfLogger.transports.file.maxSize = 50 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PERF 专用 logger(main 进程使用)
|
||||
* 若 initLogging() 未调用(如测试环境),返回默认 log 作为降级
|
||||
*/
|
||||
export function getPerfLogger(): ReturnType<typeof log.create> {
|
||||
return _perfLogger ?? log;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 electron-log 文件输出:按日分割、大小轮转、TTL 清理
|
||||
*/
|
||||
export function initLogging(): void {
|
||||
const dev = isDev();
|
||||
const qimingHome = path.join(app.getPath("home"), APP_DATA_DIR_NAME);
|
||||
const logDir = path.join(qimingHome, LOGS_DIR_NAME);
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 按日分割:写入 main.YYYY-MM-DD.log,跨午夜自动切换
|
||||
log.transports.file.resolvePathFn = () => {
|
||||
const dateStr = todayDateStr();
|
||||
if (dateStr !== lastDateStr) {
|
||||
lastDateStr = dateStr;
|
||||
setImmediate(() => updateLatestLogWithRetry(logDir));
|
||||
}
|
||||
return path.join(logDir, `main.${dateStr}.log`);
|
||||
};
|
||||
|
||||
// 开发:文件打 debug;正式:文件打 info。控制台始终可看 debug
|
||||
log.transports.file.level = dev ? "debug" : "info";
|
||||
log.transports.console.level = "debug";
|
||||
|
||||
const maxSize = dev ? MAX_SIZE_DEV : MAX_SIZE_PROD;
|
||||
const ttlMs = dev ? TTL_MS_DEV : TTL_MS_PROD;
|
||||
|
||||
log.transports.file.maxSize = maxSize;
|
||||
|
||||
// 大小轮转:当日文件超 maxSize 时,扫描目录找最大序号 N,重命名为 main.YYYY-MM-DD.(N+1).log
|
||||
let isArchiving = false;
|
||||
log.transports.file.archiveLogFn = (oldLogFile: {
|
||||
path: string;
|
||||
crop?: (n: number) => void;
|
||||
}) => {
|
||||
// 防止重入:catch 中的日志写入会再次触发 archiveLogFn,导致无限递归
|
||||
if (isArchiving) {
|
||||
oldLogFile.crop?.(256 * 1024);
|
||||
return;
|
||||
}
|
||||
isArchiving = true;
|
||||
try {
|
||||
const oldPath = oldLogFile.path;
|
||||
const parsed = path.parse(oldPath);
|
||||
// 从当前文件名提取日期(main.YYYY-MM-DD.log → YYYY-MM-DD)
|
||||
const baseMatch = parsed.name.match(/^main\.(\d{4}-\d{2}-\d{2})$/);
|
||||
const dateStr = baseMatch ? baseMatch[1] : todayDateStr();
|
||||
|
||||
// 扫描目录找当日最大序号
|
||||
let maxSeq = 0;
|
||||
try {
|
||||
const seqPattern = new RegExp(
|
||||
`^main\\.${escapeRegExp(dateStr)}\\.(\\d+)\\.log$`,
|
||||
);
|
||||
const files = fs.readdirSync(parsed.dir);
|
||||
for (const f of files) {
|
||||
const m = f.match(seqPattern);
|
||||
if (m) {
|
||||
const seq = parseInt(m[1], 10);
|
||||
if (seq > maxSeq) maxSeq = seq;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录读取失败,从 1 开始
|
||||
}
|
||||
|
||||
const newSeq = maxSeq + 1;
|
||||
const archiveName = `main.${dateStr}.${newSeq}.log`;
|
||||
const archivePath = path.join(parsed.dir, archiveName);
|
||||
try {
|
||||
fs.renameSync(oldPath, archivePath);
|
||||
log.info("[LogConfig] Log rotated:", archiveName);
|
||||
// Windows 硬链接指向 inode,轮转后需重新让 latest.log 指向新文件
|
||||
if (process.platform === "win32") {
|
||||
updateLatestLogWithRetry(parsed.dir);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("[LogConfig] Rotation failed, attempting truncation:", e);
|
||||
const quarter = Math.round(maxSize / 4);
|
||||
oldLogFile.crop?.(Math.min(quarter, 256 * 1024));
|
||||
}
|
||||
} finally {
|
||||
isArchiving = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 旧 main.log 一次性迁移(必须在 TTL 清理之前,避免旧文件被直接删除)
|
||||
migrateOldMainLog(logDir);
|
||||
|
||||
// 启动时按 TTL 清理过期归档
|
||||
cleanupOldLogs(logDir, ttlMs);
|
||||
|
||||
log.info(
|
||||
"[LogConfig] Logging initialized",
|
||||
dev ? "(development)" : "(production)",
|
||||
"fileLevel=",
|
||||
log.transports.file.level,
|
||||
"maxSize=",
|
||||
Math.round(maxSize / 1024 / 1024) + "MB",
|
||||
"ttlDays=",
|
||||
Math.round(ttlMs / (24 * 60 * 60 * 1000)),
|
||||
);
|
||||
|
||||
// 首次写入后让 latest.log 指向当日日志
|
||||
updateLatestLogWithRetry(logDir);
|
||||
|
||||
// 初始化 perf 专用日志
|
||||
initPerfLogging(logDir);
|
||||
}
|
||||
|
||||
/** 供 IPC/客户端解析:优先读取的日志入口文件名(始终为当前主进程日志) */
|
||||
export const LATEST_LOG_BASENAME = LATEST_LOG_FILENAME;
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 单元测试: migrate (数据目录迁移)
|
||||
*
|
||||
* 覆盖迁移路径:
|
||||
* 1. .qiming-agent → .qimingclaw (优先级最高)
|
||||
* 2. .qimingbot → .qimingclaw
|
||||
* 3. 无旧目录 → 跳过
|
||||
* 4. 新目录已存在但 DB 为空 → 从旧目录导入 DB
|
||||
* 5. 新目录已存在且 DB 有数据 → 跳过
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/mock/home') },
|
||||
}));
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockExistsSync = vi.fn(() => false);
|
||||
const mockRenameSync = vi.fn();
|
||||
const mockCopyFileSync = vi.fn();
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: (p: string) => mockExistsSync(p),
|
||||
renameSync: (o: string, n: string) => mockRenameSync(o, n),
|
||||
copyFileSync: (o: string, n: string) => mockCopyFileSync(o, n),
|
||||
}));
|
||||
|
||||
const mockReadSetting = vi.fn(() => null);
|
||||
const mockWriteSetting = vi.fn();
|
||||
|
||||
vi.mock('../db', () => ({
|
||||
readSetting: (...args: unknown[]) => mockReadSetting(...args),
|
||||
writeSetting: (...args: unknown[]) => mockWriteSetting(...args),
|
||||
}));
|
||||
|
||||
// Mock better-sqlite3 for isDbEmpty checks
|
||||
const mockDbPrepare = vi.fn();
|
||||
const mockDbClose = vi.fn();
|
||||
|
||||
vi.mock('better-sqlite3', () => ({
|
||||
default: vi.fn(() => ({
|
||||
prepare: mockDbPrepare,
|
||||
close: mockDbClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('migrateDataDir', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: isDbEmpty returns true (count=0)
|
||||
mockDbPrepare.mockReturnValue({ get: () => ({ count: 0 }) });
|
||||
});
|
||||
|
||||
it('should skip when no legacy directory exists', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
expect(mockRenameSync).not.toHaveBeenCalled();
|
||||
expect(mockCopyFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when new directory exists and DB has data', async () => {
|
||||
// new dir exists, new DB exists and has data
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.includes('.qimingclaw')) return true;
|
||||
return false;
|
||||
});
|
||||
// DB has data (count > 0)
|
||||
mockDbPrepare.mockReturnValue({ get: () => ({ count: 5 }) });
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
expect(mockRenameSync).not.toHaveBeenCalled();
|
||||
expect(mockCopyFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should import legacy DB when new dir exists but DB is empty', async () => {
|
||||
const newDb = path.join('/mock/home', '.qimingclaw', 'qimingclaw.db');
|
||||
const oldDb = path.join('/mock/home', '.qimingbot', 'qimingbot.db');
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === path.join('/mock/home', '.qimingclaw')) return true;
|
||||
if (p === newDb) return true;
|
||||
if (p === oldDb) return true;
|
||||
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
// First call: new DB is empty (count=0), second call: old DB has data (count=5)
|
||||
mockDbPrepare
|
||||
.mockReturnValueOnce({ get: () => ({ count: 0 }) })
|
||||
.mockReturnValueOnce({ get: () => ({ count: 5 }) });
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
expect(mockCopyFileSync).toHaveBeenCalledWith(oldDb, newDb);
|
||||
expect(mockRenameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should migrate .qiming-agent → .qimingclaw (priority 1)', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === path.join('/mock/home', '.qimingclaw')) return false;
|
||||
if (p === path.join('/mock/home', '.qiming-agent')) return true;
|
||||
if (p.endsWith('qiming-agent.db')) return true;
|
||||
if (p.endsWith('qimingclaw.db')) return false;
|
||||
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
// Directory rename
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qiming-agent'),
|
||||
path.join('/mock/home', '.qimingclaw'),
|
||||
);
|
||||
|
||||
// DB rename
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db'),
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate .qimingbot → .qimingclaw (priority 2)', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === path.join('/mock/home', '.qimingclaw')) return false;
|
||||
if (p === path.join('/mock/home', '.qiming-agent')) return false;
|
||||
if (p === path.join('/mock/home', '.qimingbot')) return true;
|
||||
if (p.endsWith('qimingbot.db')) return true;
|
||||
if (p.endsWith('qimingclaw.db')) return false;
|
||||
if (p.endsWith('qimingbot.json')) return true;
|
||||
if (p.endsWith('qimingclaw.json')) return false;
|
||||
if (p.endsWith('-wal') || p.endsWith('-shm')) return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
// Directory rename
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingbot'),
|
||||
path.join('/mock/home', '.qimingclaw'),
|
||||
);
|
||||
|
||||
// DB rename
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingbot.db'),
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db'),
|
||||
);
|
||||
|
||||
// Config rename
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingbot.json'),
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingclaw.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should also rename WAL and SHM files if they exist', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === path.join('/mock/home', '.qimingclaw')) return false;
|
||||
if (p === path.join('/mock/home', '.qiming-agent')) return true;
|
||||
if (p.endsWith('qiming-agent.db')) return true;
|
||||
if (p.endsWith('qimingclaw.db')) return false;
|
||||
if (p.endsWith('qiming-agent.db-wal')) return true;
|
||||
if (p.endsWith('qiming-agent.db-shm')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db-wal'),
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db-wal'),
|
||||
);
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingclaw', 'qiming-agent.db-shm'),
|
||||
path.join('/mock/home', '.qimingclaw', 'qimingclaw.db-shm'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer .qiming-agent over .qimingbot when both exist', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === path.join('/mock/home', '.qimingclaw')) return false;
|
||||
if (p === path.join('/mock/home', '.qiming-agent')) return true;
|
||||
if (p === path.join('/mock/home', '.qimingbot')) return true;
|
||||
if (p.endsWith('qiming-agent.db')) return true;
|
||||
if (p.endsWith('qimingclaw.db')) return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
const { migrateDataDir } = await import('./migrate');
|
||||
migrateDataDir();
|
||||
|
||||
// Should rename .qiming-agent, NOT .qimingbot
|
||||
expect(mockRenameSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qiming-agent'),
|
||||
path.join('/mock/home', '.qimingclaw'),
|
||||
);
|
||||
expect(mockRenameSync).not.toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.qimingbot'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateSettingsPaths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should skip when step1_config is null', async () => {
|
||||
mockReadSetting.mockReturnValue(null);
|
||||
|
||||
const { migrateSettingsPaths } = await import('./migrate');
|
||||
migrateSettingsPaths();
|
||||
|
||||
expect(mockWriteSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when workspaceDir is not a string', async () => {
|
||||
mockReadSetting.mockReturnValue({ serverHost: 'example.com' });
|
||||
|
||||
const { migrateSettingsPaths } = await import('./migrate');
|
||||
migrateSettingsPaths();
|
||||
|
||||
expect(mockWriteSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when workspaceDir does not match legacy prefix', async () => {
|
||||
mockReadSetting.mockReturnValue({ workspaceDir: '/Users/user/projects' });
|
||||
|
||||
const { migrateSettingsPaths } = await import('./migrate');
|
||||
migrateSettingsPaths();
|
||||
|
||||
expect(mockWriteSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update workspaceDir from .qimingbot prefix', async () => {
|
||||
mockReadSetting.mockReturnValue({
|
||||
workspaceDir: path.join('/mock/home', '.qimingbot', 'workspace'),
|
||||
serverHost: 'example.com',
|
||||
});
|
||||
|
||||
const { migrateSettingsPaths } = await import('./migrate');
|
||||
migrateSettingsPaths();
|
||||
|
||||
expect(mockWriteSetting).toHaveBeenCalledWith('step1_config', {
|
||||
workspaceDir: path.join('/mock/home', '.qimingclaw', 'workspace'),
|
||||
serverHost: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update workspaceDir from .qiming-agent prefix', async () => {
|
||||
mockReadSetting.mockReturnValue({
|
||||
workspaceDir: path.join('/mock/home', '.qiming-agent', 'workspace'),
|
||||
});
|
||||
|
||||
const { migrateSettingsPaths } = await import('./migrate');
|
||||
migrateSettingsPaths();
|
||||
|
||||
expect(mockWriteSetting).toHaveBeenCalledWith('step1_config', {
|
||||
workspaceDir: path.join('/mock/home', '.qimingclaw', 'workspace'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 数据目录迁移:~/.qiming-agent 或 ~/.qimingbot → ~/.qimingclaw
|
||||
*
|
||||
* 必须在 initDatabase() 之前同步执行,确保 DB 从新路径打开。
|
||||
*
|
||||
* 两种场景:
|
||||
* 1. 新目录不存在 → 整体 rename 旧目录
|
||||
* 2. 新目录已存在但 DB 为空(依赖安装等先创建了目录)→ 从旧目录复制 DB
|
||||
*
|
||||
* 优先级:.qiming-agent > .qimingbot(找到第一个存在的旧目录即迁移)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import log from 'electron-log';
|
||||
import Database from 'better-sqlite3';
|
||||
import { APP_NAME_IDENTIFIER } from '@shared/constants';
|
||||
import { readSetting, writeSetting } from '../db';
|
||||
|
||||
interface LegacySource {
|
||||
dirName: string;
|
||||
dbName: string;
|
||||
configName: string | null;
|
||||
}
|
||||
|
||||
const LEGACY_SOURCES: LegacySource[] = [
|
||||
{ dirName: '.qiming-agent', dbName: 'qiming-agent.db', configName: null },
|
||||
{ dirName: '.qimingbot', dbName: 'qimingbot.db', configName: 'qimingbot.json' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查 DB 文件是否包含有效的 settings 数据
|
||||
*/
|
||||
function isDbEmpty(dbPath: string): boolean {
|
||||
if (!fs.existsSync(dbPath)) return true;
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM settings').get() as { count: number };
|
||||
db.close();
|
||||
return row.count === 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制 DB 文件(主文件 + WAL/SHM)
|
||||
*/
|
||||
function copyDbFiles(oldDb: string, newDb: string): void {
|
||||
fs.copyFileSync(oldDb, newDb);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
const oldAux = oldDb + suffix;
|
||||
const newAux = newDb + suffix;
|
||||
if (fs.existsSync(oldAux)) {
|
||||
fs.copyFileSync(oldAux, newAux);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名 DB 文件(主文件 + WAL/SHM)
|
||||
*/
|
||||
function renameDbFiles(oldDb: string, newDb: string): void {
|
||||
fs.renameSync(oldDb, newDb);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
const oldAux = oldDb + suffix;
|
||||
const newAux = newDb + suffix;
|
||||
if (fs.existsSync(oldAux)) {
|
||||
fs.renameSync(oldAux, newAux);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateDataDir(): void {
|
||||
const home = app.getPath('home');
|
||||
const newDir = path.join(home, `.${APP_NAME_IDENTIFIER}`);
|
||||
const newDbName = `${APP_NAME_IDENTIFIER}.db`;
|
||||
const newDb = path.join(newDir, newDbName);
|
||||
|
||||
if (fs.existsSync(newDir)) {
|
||||
// 新目录已存在 — 若 DB 为空,仍需从旧目录导入数据
|
||||
if (!isDbEmpty(newDb)) {
|
||||
return; // DB 有数据,无需迁移
|
||||
}
|
||||
log.info('[Migrate] New dir exists but DB is empty, importing from legacy DB...');
|
||||
importLegacyDb(home, newDb);
|
||||
return;
|
||||
}
|
||||
|
||||
// 新目录不存在 → 整体 rename 旧目录
|
||||
for (const source of LEGACY_SOURCES) {
|
||||
const oldDir = path.join(home, source.dirName);
|
||||
if (!fs.existsSync(oldDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`[Migrate] Found legacy data dir: ${oldDir}, renaming → ${newDir}`);
|
||||
try {
|
||||
fs.renameSync(oldDir, newDir);
|
||||
} catch (e) {
|
||||
log.error('[Migrate] Failed to rename data directory:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重命名 DB 文件
|
||||
const oldDb = path.join(newDir, source.dbName);
|
||||
if (fs.existsSync(oldDb) && !fs.existsSync(newDb)) {
|
||||
try {
|
||||
renameDbFiles(oldDb, newDb);
|
||||
log.info(`[Migrate] Renamed DB: ${source.dbName} → ${newDbName}`);
|
||||
} catch (e) {
|
||||
log.error('[Migrate] Failed to rename database file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名 config 文件(如果存在)
|
||||
if (source.configName) {
|
||||
const newConfigName = `${APP_NAME_IDENTIFIER}.json`;
|
||||
const oldConfig = path.join(newDir, source.configName);
|
||||
const newConfig = path.join(newDir, newConfigName);
|
||||
if (fs.existsSync(oldConfig) && !fs.existsSync(newConfig)) {
|
||||
try {
|
||||
fs.renameSync(oldConfig, newConfig);
|
||||
log.info(`[Migrate] Renamed config: ${source.configName} → ${newConfigName}`);
|
||||
} catch (e) {
|
||||
log.error('[Migrate] Failed to rename config file:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('[Migrate] Data directory migration completed');
|
||||
return; // 只迁移第一个找到的旧目录
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新目录已存在但 DB 为空时,从旧目录复制 DB 文件
|
||||
*/
|
||||
function importLegacyDb(home: string, newDb: string): void {
|
||||
for (const source of LEGACY_SOURCES) {
|
||||
const oldDir = path.join(home, source.dirName);
|
||||
const oldDb = path.join(oldDir, source.dbName);
|
||||
if (!fs.existsSync(oldDb) || isDbEmpty(oldDb)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
copyDbFiles(oldDb, newDb);
|
||||
log.info(`[Migrate] Imported legacy DB: ${oldDb} → ${newDb}`);
|
||||
} catch (e) {
|
||||
log.error('[Migrate] Failed to import legacy DB:', e);
|
||||
}
|
||||
return; // 只导入第一个有效的旧 DB
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修补 DB 中 step1_config.workspaceDir 的旧路径引用
|
||||
*
|
||||
* 当用户手动选择的工作空间目录包含旧数据目录前缀时,替换为新前缀。
|
||||
* 必须在 initDatabase() 之后调用。
|
||||
*/
|
||||
export function migrateSettingsPaths(): void {
|
||||
const home = app.getPath('home');
|
||||
const newPrefix = path.join(home, `.${APP_NAME_IDENTIFIER}`);
|
||||
const LEGACY_DIR_NAMES = ['.qiming-agent', '.qimingbot'];
|
||||
|
||||
const step1Config = readSetting('step1_config') as Record<string, unknown> | null;
|
||||
if (!step1Config || typeof step1Config.workspaceDir !== 'string') return;
|
||||
|
||||
for (const legacyName of LEGACY_DIR_NAMES) {
|
||||
const oldPrefix = path.join(home, legacyName);
|
||||
if (step1Config.workspaceDir.startsWith(oldPrefix)) {
|
||||
step1Config.workspaceDir = newPrefix + step1Config.workspaceDir.slice(oldPrefix.length);
|
||||
writeSetting('step1_config', step1Config);
|
||||
log.info(`[Migrate] Updated step1_config.workspaceDir → ${step1Config.workspaceDir}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockExistsSync = vi.fn(() => false);
|
||||
const mockReadFileSync = vi.fn(() => '');
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
},
|
||||
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: { homedir: () => '/mock/home' },
|
||||
homedir: () => '/mock/home',
|
||||
}));
|
||||
|
||||
// Env vars to clean up after each test
|
||||
const QUICK_INIT_ENV_KEYS = [
|
||||
'QIMING_SERVER_HOST',
|
||||
'QIMING_SAVED_KEY',
|
||||
'QIMING_USER_NAME',
|
||||
'QIMING_AGENT_PORT',
|
||||
'QIMING_FILE_SERVER_PORT',
|
||||
'QIMING_WORKSPACE_DIR',
|
||||
];
|
||||
|
||||
describe('readQuickInitConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
// Clean env vars
|
||||
for (const key of QUICK_INIT_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of QUICK_INIT_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
/** Helper: dynamic import (cache resets via vi.resetModules) */
|
||||
async function loadReader() {
|
||||
const mod = await import('./quickInit');
|
||||
return mod.readQuickInitConfig;
|
||||
}
|
||||
|
||||
// ==================== 无配置 ====================
|
||||
|
||||
it('should return null when no JSON file and no env vars', async () => {
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
// ==================== JSON 配置 ====================
|
||||
|
||||
it('should read config from JSON with all fields', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
serverHost: 'https://agent.qiming.com',
|
||||
savedKey: 'key-123',
|
||||
username: 'user@test.com',
|
||||
agentPort: 9001,
|
||||
fileServerPort: 9002,
|
||||
workspaceDir: '/custom/workspace',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
serverHost: 'https://agent.qiming.com',
|
||||
savedKey: 'key-123',
|
||||
username: 'user@test.com',
|
||||
agentPort: 9001,
|
||||
fileServerPort: 9002,
|
||||
workspaceDir: '/custom/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fill defaults for optional JSON fields', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
serverHost: 'https://agent.qiming.com',
|
||||
savedKey: 'key-123',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.serverHost).toBe('https://agent.qiming.com');
|
||||
expect(config!.savedKey).toBe('key-123');
|
||||
expect(config!.username).toBe('');
|
||||
expect(config!.agentPort).toBe(60006);
|
||||
expect(config!.fileServerPort).toBe(60005);
|
||||
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
|
||||
});
|
||||
|
||||
it('should return null when JSON has enabled: false', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
enabled: false,
|
||||
serverHost: 'https://agent.qiming.com',
|
||||
savedKey: 'key-123',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('should NOT disable when enabled is true', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
enabled: true,
|
||||
serverHost: 'https://agent.qiming.com',
|
||||
savedKey: 'key-123',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when JSON has no quickInit scope', async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({ otherConfig: {} }));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when JSON quickInit missing required fields', async () => {
|
||||
const json = { quickInit: { username: 'user' } };
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when JSON is malformed', async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('{ broken json');
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
// ==================== 环境变量 ====================
|
||||
|
||||
it('should read config from env vars when no JSON', async () => {
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key-456';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.serverHost).toBe('https://env.qiming.com');
|
||||
expect(config!.savedKey).toBe('env-key-456');
|
||||
expect(config!.agentPort).toBe(60006);
|
||||
expect(config!.fileServerPort).toBe(60005);
|
||||
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
|
||||
});
|
||||
|
||||
it('should read optional env vars', async () => {
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
process.env.QIMING_USER_NAME = 'envuser';
|
||||
process.env.QIMING_AGENT_PORT = '7001';
|
||||
process.env.QIMING_FILE_SERVER_PORT = '7002';
|
||||
process.env.QIMING_WORKSPACE_DIR = '/env/workspace';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
serverHost: 'https://env.qiming.com',
|
||||
savedKey: 'env-key',
|
||||
username: 'envuser',
|
||||
agentPort: 7001,
|
||||
fileServerPort: 7002,
|
||||
workspaceDir: '/env/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when only QIMING_SERVER_HOST set (no savedKey)', async () => {
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when only QIMING_SAVED_KEY set (no serverHost)', async () => {
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore invalid QIMING_AGENT_PORT', async () => {
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
process.env.QIMING_AGENT_PORT = 'notanumber';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config!.agentPort).toBe(60006); // fallback to default
|
||||
});
|
||||
|
||||
// ==================== 优先级: JSON > env > default ====================
|
||||
|
||||
it('should prefer JSON fields over env vars', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
serverHost: 'https://json.qiming.com',
|
||||
savedKey: 'json-key',
|
||||
agentPort: 8001,
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
process.env.QIMING_AGENT_PORT = '9999';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config!.serverHost).toBe('https://json.qiming.com');
|
||||
expect(config!.savedKey).toBe('json-key');
|
||||
expect(config!.agentPort).toBe(8001);
|
||||
});
|
||||
|
||||
it('should fill missing JSON fields from env vars', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
serverHost: 'https://json.qiming.com',
|
||||
savedKey: 'json-key',
|
||||
// agentPort missing — should come from env
|
||||
// workspaceDir missing — should come from env
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
process.env.QIMING_AGENT_PORT = '7777';
|
||||
process.env.QIMING_WORKSPACE_DIR = '/env/ws';
|
||||
process.env.QIMING_USER_NAME = 'envuser';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config!.serverHost).toBe('https://json.qiming.com');
|
||||
expect(config!.agentPort).toBe(7777);
|
||||
expect(config!.workspaceDir).toBe('/env/ws');
|
||||
expect(config!.username).toBe('envuser');
|
||||
});
|
||||
|
||||
it('should use defaults when neither JSON nor env provides optional fields', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
serverHost: 'https://json.qiming.com',
|
||||
savedKey: 'json-key',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const config = readQuickInitConfig();
|
||||
|
||||
expect(config!.agentPort).toBe(60006);
|
||||
expect(config!.fileServerPort).toBe(60005);
|
||||
expect(config!.workspaceDir).toBe(path.join('/mock/home', '.qimingclaw', 'workspace'));
|
||||
expect(config!.username).toBe('');
|
||||
});
|
||||
|
||||
// ==================== enabled: false 阻断环境变量 ====================
|
||||
|
||||
it('should NOT fall through to env vars when JSON has enabled: false', async () => {
|
||||
const json = {
|
||||
quickInit: {
|
||||
enabled: false,
|
||||
serverHost: 'https://json.qiming.com',
|
||||
savedKey: 'json-key',
|
||||
},
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(json));
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
expect(readQuickInitConfig()).toBeNull();
|
||||
});
|
||||
|
||||
// ==================== 缓存 ====================
|
||||
|
||||
it('should cache result and return same value on second call', async () => {
|
||||
process.env.QIMING_SERVER_HOST = 'https://env.qiming.com';
|
||||
process.env.QIMING_SAVED_KEY = 'env-key';
|
||||
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const first = readQuickInitConfig();
|
||||
const second = readQuickInitConfig();
|
||||
|
||||
expect(first).toBe(second); // same reference
|
||||
// fs should not be called (no JSON file)
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cache null result', async () => {
|
||||
const readQuickInitConfig = await loadReader();
|
||||
const first = readQuickInitConfig();
|
||||
const second = readQuickInitConfig();
|
||||
|
||||
expect(first).toBeNull();
|
||||
expect(second).toBeNull();
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Quick Init — Main 进程读取快捷初始化配置
|
||||
*
|
||||
* 配置优先级(per-field): qimingclaw.json (quickInit scope) → 环境变量 → 客户端默认值
|
||||
*
|
||||
* 环境变量:
|
||||
* QIMING_SERVER_HOST → serverHost (必填)
|
||||
* QIMING_SAVED_KEY → savedKey (必填)
|
||||
* QIMING_AGENT_PORT → agentPort (可选, 默认 60006)
|
||||
* QIMING_FILE_SERVER_PORT → fileServerPort (可选, 默认 60005)
|
||||
* QIMING_WORKSPACE_DIR → workspaceDir (可选, 默认 ~/.qimingclaw/workspace)
|
||||
* QIMING_USER_NAME → username (可选, 默认 '')
|
||||
*
|
||||
* 最低必填: serverHost + savedKey(来自任意源),其余用客户端默认值
|
||||
* agentPort → DEFAULT_AGENT_RUNNER_PORT (60006)
|
||||
* fileServerPort → DEFAULT_FILE_SERVER_PORT (60005)
|
||||
* workspaceDir → ~/.qimingclaw/workspace
|
||||
* username → ''
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import log from "electron-log";
|
||||
import { APP_DATA_DIR_NAME } from "../services/constants";
|
||||
import {
|
||||
DEFAULT_AGENT_RUNNER_PORT,
|
||||
DEFAULT_FILE_SERVER_PORT,
|
||||
} from "@shared/constants";
|
||||
import { type QuickInitConfig } from "@shared/types/quickInit";
|
||||
|
||||
let cachedConfig: QuickInitConfig | null | undefined;
|
||||
|
||||
/** 读取环境变量,返回各字段(未设置的为 undefined) */
|
||||
function readEnvVars() {
|
||||
const port = parseInt(process.env.QIMING_AGENT_PORT || "", 10);
|
||||
const fsPort = parseInt(process.env.QIMING_FILE_SERVER_PORT || "", 10);
|
||||
return {
|
||||
serverHost: process.env.QIMING_SERVER_HOST || undefined,
|
||||
savedKey: process.env.QIMING_SAVED_KEY || undefined,
|
||||
username: process.env.QIMING_USER_NAME || undefined,
|
||||
agentPort: port > 0 ? port : undefined,
|
||||
fileServerPort: fsPort > 0 ? fsPort : undefined,
|
||||
workspaceDir: process.env.QIMING_WORKSPACE_DIR || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取第一个有效的 string 值 */
|
||||
function pickStr(...values: unknown[]): string {
|
||||
for (const v of values) {
|
||||
if (typeof v === "string" && v.length > 0) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 取第一个有效的正整数值 */
|
||||
function pickPort(...values: unknown[]): number {
|
||||
for (const v of values) {
|
||||
if (typeof v === "number" && v > 0) return v;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取快捷初始化配置
|
||||
*
|
||||
* 优先级(per-field): qimingclaw.json → 环境变量 → 默认值
|
||||
* 结果缓存,每次启动只读一次
|
||||
*/
|
||||
export function readQuickInitConfig(): QuickInitConfig | null {
|
||||
if (cachedConfig !== undefined) return cachedConfig;
|
||||
|
||||
const appDataDir = path.join(os.homedir(), APP_DATA_DIR_NAME);
|
||||
const defaultWorkspace = path.join(appDataDir, "workspace");
|
||||
const filePath = path.join(appDataDir, "qimingclaw.json");
|
||||
|
||||
// --- 读取 qimingclaw.json (quickInit scope) ---
|
||||
let json: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const scope = parsed?.quickInit;
|
||||
|
||||
if (scope && typeof scope === "object") {
|
||||
if (scope.enabled === false) {
|
||||
log.info("[QuickInit] Config disabled (enabled: false)");
|
||||
cachedConfig = null;
|
||||
return null;
|
||||
}
|
||||
json = scope;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("[QuickInit] Failed to read JSON config:", error);
|
||||
}
|
||||
|
||||
// --- 读取环境变量 ---
|
||||
const env = readEnvVars();
|
||||
|
||||
// --- 合并: JSON > env > default ---
|
||||
const serverHost = pickStr(json?.serverHost, env.serverHost);
|
||||
const savedKey = pickStr(json?.savedKey, env.savedKey);
|
||||
|
||||
if (!serverHost || !savedKey) {
|
||||
log.info(
|
||||
"[QuickInit] No quick config detected (missing serverHost or savedKey)",
|
||||
);
|
||||
cachedConfig = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedConfig = {
|
||||
serverHost,
|
||||
savedKey,
|
||||
username: pickStr(json?.username, env.username),
|
||||
agentPort:
|
||||
pickPort(json?.agentPort, env.agentPort) || DEFAULT_AGENT_RUNNER_PORT,
|
||||
fileServerPort:
|
||||
pickPort(json?.fileServerPort, env.fileServerPort) ||
|
||||
DEFAULT_FILE_SERVER_PORT,
|
||||
workspaceDir:
|
||||
pickStr(json?.workspaceDir, env.workspaceDir) || defaultWorkspace,
|
||||
};
|
||||
|
||||
const source = json
|
||||
? env.serverHost || env.savedKey
|
||||
? "JSON + env"
|
||||
: "JSON"
|
||||
: "env";
|
||||
log.info(`[QuickInit] Config loaded (${source}):`, {
|
||||
serverHost: cachedConfig.serverHost,
|
||||
username: cachedConfig.username || "(empty)",
|
||||
agentPort: cachedConfig.agentPort,
|
||||
fileServerPort: cachedConfig.fileServerPort,
|
||||
workspaceDir: cachedConfig.workspaceDir,
|
||||
});
|
||||
|
||||
return cachedConfig;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import log from "electron-log";
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import { getDb, readSetting } from "../db";
|
||||
import { startComputerServer } from "../services/computerServer";
|
||||
import {
|
||||
mcpProxyManager,
|
||||
DEFAULT_MCP_PROXY_CONFIG,
|
||||
} from "../services/packages/mcp";
|
||||
import { getConfiguredPorts } from "../services/startupPorts";
|
||||
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
|
||||
import {
|
||||
startSandboxService,
|
||||
stopSandboxService,
|
||||
} from "../services/sandbox/serviceBootstrap";
|
||||
|
||||
/** 依赖同步是否正在进行 */
|
||||
let _depsSyncInProgress = false;
|
||||
export function isDepsSyncInProgress(): boolean {
|
||||
return _depsSyncInProgress;
|
||||
}
|
||||
|
||||
export async function runStartupTasks(): Promise<void> {
|
||||
// 从 SQLite 恢复镜像配置
|
||||
const {
|
||||
setMirrorConfig,
|
||||
getInitDepsState,
|
||||
syncInitDependencies,
|
||||
getSetupRequiredDependencies,
|
||||
} = await import("../services/system/dependencies");
|
||||
const mirrorConfig = readSetting("mirror_config");
|
||||
if (mirrorConfig) {
|
||||
try {
|
||||
setMirrorConfig(mirrorConfig);
|
||||
} catch (e) {
|
||||
log.warn("[Mirror] Failed to apply mirror config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 尽早启动 Computer HTTP Server(对齐 rcoder /computer/* API),端口来自聚合配置
|
||||
{
|
||||
const { agent: agentPort } = getConfiguredPorts();
|
||||
startComputerServer(agentPort).then((r) => {
|
||||
if (r.success)
|
||||
log.info(`[Init] Computer HTTP server listening on port ${agentPort}`);
|
||||
else log.warn(`[Init] Computer HTTP server failed: ${r.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化 MCP Proxy 配置(从数据库加载)
|
||||
try {
|
||||
const db = getDb();
|
||||
const savedConfig = db
|
||||
?.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("mcp_proxy_config") as { value: string } | undefined;
|
||||
if (savedConfig) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConfig.value);
|
||||
// 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在
|
||||
const merged = {
|
||||
...parsed,
|
||||
mcpServers: {
|
||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||
...(parsed.mcpServers || {}),
|
||||
},
|
||||
};
|
||||
mcpProxyManager.setConfig(merged);
|
||||
} catch (e) {
|
||||
log.warn("[McpProxy] Init config parse failed:", e);
|
||||
}
|
||||
}
|
||||
log.info("[McpProxy] Config loaded");
|
||||
} catch (e) {
|
||||
log.warn("[McpProxy] Init config failed:", e);
|
||||
}
|
||||
|
||||
// 初始化沙箱服务(后台启动,不阻塞主流程)
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await startSandboxService();
|
||||
log.info("[Sandbox] Sandbox service started");
|
||||
} catch (e) {
|
||||
log.warn("[Sandbox] Sandbox service start failed (non-fatal):", e);
|
||||
// 沙箱服务失败不阻塞应用,只是功能降级
|
||||
}
|
||||
});
|
||||
|
||||
// 客户端升级后:若 appVersion 或 installVersion 变化,后台同步初始化依赖到写死版本
|
||||
// 同步检查是否需要 dep sync,提前设置标志,避免 renderer 在 setImmediate 之前
|
||||
// 读到 syncInProgress=false 而过早启动服务(竞态条件)
|
||||
const state = getInitDepsState();
|
||||
const currentVersion = app.getVersion();
|
||||
const versionChanged = !state || state.appVersion !== currentVersion;
|
||||
let packagesChanged = false;
|
||||
if (state?.packages) {
|
||||
for (const dep of getSetupRequiredDependencies()) {
|
||||
if (!dep.installVersion) continue;
|
||||
if (state.packages[dep.name] !== dep.installVersion) {
|
||||
packagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
packagesChanged = true;
|
||||
}
|
||||
const needsSync = versionChanged || packagesChanged;
|
||||
if (needsSync) {
|
||||
_depsSyncInProgress = true;
|
||||
}
|
||||
|
||||
setImmediate(async () => {
|
||||
if (!needsSync) return;
|
||||
try {
|
||||
try {
|
||||
let syncTimer: ReturnType<typeof setTimeout>;
|
||||
const { updated } = await Promise.race([
|
||||
syncInitDependencies(),
|
||||
new Promise<never>((_, reject) => {
|
||||
syncTimer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Dependency sync timeout after ${DEPS_SYNC_TIMEOUT}ms`,
|
||||
),
|
||||
),
|
||||
DEPS_SYNC_TIMEOUT,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(syncTimer!));
|
||||
if (updated.length > 0)
|
||||
log.info("[Init] Init dependencies synced:", updated);
|
||||
} finally {
|
||||
_depsSyncInProgress = false;
|
||||
// 通知所有渲染进程依赖同步完成,重新检测
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send("deps:syncCompleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_depsSyncInProgress = false;
|
||||
// 同步失败也通知渲染进程重新检测实际状态
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send("deps:syncCompleted");
|
||||
}
|
||||
}
|
||||
log.warn("[Init] Init dependencies sync failed:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user