const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); const h = require('./helpers'); const mainFile = path.join(__dirname, '..', '..', 'main.js'); const mainSrc = fs.readFileSync(mainFile, 'utf8'); const { compareVersion } = h.extractFns( mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion'] ); const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']); test('wrap 捕获同步抛出,不让 invoke reject', async () => { const r = await wrap(() => { throw new Error('未知数据源: nope'); }); assert.deepStrictEqual(r, { ok: false, error: '未知数据源: nope' }); }); test('wrap 捕获异步拒绝', async () => { const r = await wrap(() => Promise.reject(new Error('boom'))); assert.strictEqual(r.ok, false); assert.strictEqual(r.error, 'boom'); }); test('wrap 正常返回包成 { ok:true, data }', async () => { assert.deepStrictEqual(await wrap(() => 42), { ok: true, data: 42 }); assert.deepStrictEqual(await wrap(() => Promise.resolve('x')), { ok: true, data: 'x' }); }); test('wrap 对非 Error 抛出也能给出字符串', async () => { const r = await wrap(() => { throw 'plain string'; }); assert.strictEqual(r.ok, false); assert.strictEqual(r.error, 'plain string'); }); test('所有 IPC handler 都通过 thunk 调用 wrap', () => { assert.ok(/function wrap\(fn\)/.test(mainSrc), 'wrap 未改成接收函数'); assert.ok(!/wrap\(sources\.getSource/.test(mainSrc), '仍有同步求值的 getSource 传进 wrap'); assert.ok(!/wrap\(Promise\.resolve/.test(mainSrc), '仍有 Promise.resolve 被提前求值'); // 直接返回 { ok: true, ... } 而不过 wrap 的 handler 会绕开错误处理 const bare = mainSrc.match(/ipcMain\.handle\([^)]*=>\s*\(\{\s*ok:\s*true/g) || []; assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare); }); test('版本比较:预发布版本低于同号正式版', () => { assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1); assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1); assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0-beta'), 0); }); test('版本比较:常规大小与位数不等', () => { assert.strictEqual(compareVersion('1.2.0', '1.1.9'), 1); assert.strictEqual(compareVersion('1.1.0', '1.1.0'), 0); assert.strictEqual(compareVersion('2.0', '1.9.9'), 1); assert.strictEqual(compareVersion('1.10.0', '1.9.0'), 1, '按数值而非字典序比较'); assert.strictEqual(compareVersion('v1.1.1', '1.1.0'), 1, '应容忍 v 前缀'); }); test('窗口控制 handler 检查 isDestroyed', () => { assert.ok(/function liveWindow\(\)/.test(mainSrc), '缺少 liveWindow 守卫'); assert.ok(/isDestroyed\(\)\s*\?\s*null\s*:\s*mainWindow/.test(mainSrc.replace(/\s+/g, ' ')) || /!mainWindow\.isDestroyed\(\)/.test(mainSrc), 'liveWindow 未检查 isDestroyed'); assert.ok(!/mainWindow && mainWindow\.minimize\(\)/.test(mainSrc), '仍有未加守卫的窗口调用'); assert.ok(!/dialog\.show\w+\(mainWindow,/.test(mainSrc), '对话框仍直接引用可能已销毁的窗口'); }); test('下载校验协议,拒绝 file:// 等非 http(s)', () => { assert.ok(/仅支持 HTTP 或 HTTPS 下载链接/.test(mainSrc)); assert.ok(/仅允许打开 HTTP 或 HTTPS 链接/.test(mainSrc), 'openExternal 缺协议校验'); }); test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => { const start = mainSrc.indexOf("ipcMain.handle('library:remove'"); const end = mainSrc.indexOf('// 下载文件', start); const segment = mainSrc.slice(start, end); assert.match(segment, /options\.deleteReadingData\s*===\s*true/); const guard = segment.indexOf('if (deleteReadingData)'); assert.ok(guard >= 0, '缺少显式清理守卫'); assert.ok(segment.indexOf('readerStore.forget', guard) > guard); assert.ok(segment.indexOf('annotations.forget', guard) > guard); }); test('书库列表附带阅读记录中的最近阅读时间', () => { const start = mainSrc.indexOf("ipcMain.handle('library:list'"); const end = mainSrc.indexOf("ipcMain.handle('library:get'", start); const segment = mainSrc.slice(start, end); assert.match(segment, /lastReadAt:\s*readerStore\.getLastReadAt\(item\.id\)/); }); test('Z-Library 登录通过受限同源浏览器完成反机器人验证', () => { const start = mainSrc.indexOf('async function browserZlibLogin'); const end = mainSrc.indexOf('// Z-Library 凭据', start); const segment = mainSrc.slice(start, end); assert.ok(start > 0 && end > start); assert.match(segment, /show:\s*false/); assert.match(segment, /contextIsolation:\s*true/); assert.match(segment, /nodeIntegration:\s*false/); assert.match(segment, /sandbox:\s*true/); assert.match(segment, /setWindowOpenHandler\(\(\)\s*=>\s*\(\{\s*action:\s*'deny'/); assert.match(segment, /new URL\(target\)\.origin\s*!==\s*origin/); assert.match(segment, /executeJavaScriptInIsolatedWorld/); assert.match(segment, /session\.defaultSession\.cookies\.get/); assert.match(segment, /if \(!win\.isDestroyed\(\)\) win\.destroy\(\)/); }); test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => { assert.match(mainSrc, /event\.sender\.send\('download:progress'/); assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/); assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/); assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/); }); test('窗口使用 icons/dist 主题图标并同步界面主题', () => { const iconDir = path.join(__dirname, '..', '..', 'icons', 'dist'); for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) { const bytes = fs.readFileSync(path.join(iconDir, name)); assert.deepStrictEqual([...bytes.subarray(0, 4)], [0, 0, 1, 0], `${name} 不是 ICO`); } assert.match(mainSrc, /function iconForTheme\(theme\)/); assert.match(mainSrc, /icon:\s*iconForTheme\(currentUiTheme\)/); assert.match(mainSrc, /ipcMain\.handle\('ui:setTheme'/); assert.match(mainSrc, /settings\.set\('ui\.theme', theme\)/); assert.match(mainSrc, /settings\.set\('reader\.uiTheme', theme\)/); const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8'); assert.match(build, /rcedit\(executable,[\s\S]*book-ai-dark\.ico/); assert.match(build, /book-ai-light\.ico/); }); test('标准构建入口固定输出目录并保留便携数据', () => { const root = path.join(__dirname, '..', '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const build = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8'); assert.strictEqual(pkg.scripts.build, 'node build-portable.js'); assert.match(build, /const OUT = REQUESTED_OUT/); assert.match(build, /if \(entry\.name === 'data'\) continue/); assert.match(build, /clearOutput\(OUT\)/); assert.match(build, /请先关闭其中正在运行的/); assert.doesNotMatch(build, /nextAvailableOutput|-rebuild/); // 目录名固定为平台标识,升级版本不再产生新目录,data/ 也就不会被落在旧目录里 assert.match(build, /const TARGET = `\$\{PRODUCT\}-windows-x64`/); assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/); }); function loadPathResolvers(platform, isPackaged) { return h.extractFns( mainFile, 'const APP_ICON_DIR =', 'const userDataDir = resolveUserDataDir()', ['iconForTheme', 'resolveUserDataDir'], `const path = require('path'); const __dirname = ${JSON.stringify(path.join('/opt', 'app'))}; const process = { platform: ${JSON.stringify(platform)} }; const app = { isPackaged: ${isPackaged}, getPath: (key) => { if (key === 'exe') return ${JSON.stringify(path.join('/opt', 'portable', 'PeopleLib.exe'))}; if (key === 'appData') return ${JSON.stringify(path.join('/home', 'u', 'AppData'))}; throw new Error('未预期的 getPath: ' + key); } };` ); } test('macOS 打包版把用户数据放到 appData,不写进只读的 .app 内部', () => { const appData = path.join('/home', 'u', 'AppData', 'PeopleLib'); // macOS 上 exe 位于 PeopleLib.app/Contents/MacOS/,DMG 挂载只读, // 且覆盖升级会连同用户书库一起删掉,所以绝不能落在可执行文件旁边 assert.strictEqual(loadPathResolvers('darwin', true).resolveUserDataDir(), appData); assert.strictEqual(loadPathResolvers('linux', true).resolveUserDataDir(), appData); // Windows 便携版仍旧放在程序同级 data/ assert.strictEqual( loadPathResolvers('win32', true).resolveUserDataDir(), path.join('/opt', 'portable', 'data') ); for (const platform of ['darwin', 'win32']) { assert.strictEqual(loadPathResolvers(platform, false).resolveUserDataDir(), appData); } }); test('窗口图标按平台取用,macOS 不会拿到 .ico', () => { const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist'); for (const theme of ['dark', 'light']) { const win = loadPathResolvers('win32', true).iconForTheme(theme); assert.strictEqual(path.extname(win), '.ico'); assert.strictEqual(path.basename(win), `book-ai-${theme}.ico`); const mac = loadPathResolvers('darwin', true).iconForTheme(theme); assert.strictEqual(path.extname(mac), '.png'); // 打包脚本只复制 32 与 256 两个尺寸,取用的那个必须真实存在 assert.ok( fs.existsSync(path.join(iconRoot, theme, path.basename(mac))), `缺少 macOS 窗口图标 ${theme}/${path.basename(mac)}` ); } // 未知主题回落到 dark,不能拼出不存在的路径 assert.strictEqual( path.basename(loadPathResolvers('darwin', true).iconForTheme('nope')), 'icon-256.png' ); }); test('icns 图标容器结构合法且尺寸齐全', () => { const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist'); const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); for (const theme of ['dark', 'light']) { const buf = fs.readFileSync(path.join(iconRoot, `book-ai-${theme}.icns`)); assert.strictEqual(buf.toString('latin1', 0, 4), 'icns', `${theme} 魔数错误`); // 长度字段写错时 Finder 会静默显示默认图标,必须逐字节核对 assert.strictEqual(buf.readUInt32BE(4), buf.length, `${theme} 长度字段与文件不符`); const slots = new Map(); for (let off = 8; off < buf.length;) { const type = buf.toString('latin1', off, off + 4); const len = buf.readUInt32BE(off + 4); assert.ok(len >= 8 && off + len <= buf.length, `${theme} 槽 ${type} 长度非法`); const payload = buf.subarray(off + 8, off + len); assert.ok(payload.subarray(0, 8).equals(PNG), `${theme} 槽 ${type} 不是 PNG`); slots.set(type, payload.readUInt32BE(16)); off += len; } for (const [type, size] of [['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024]]) { assert.strictEqual(slots.get(type), size, `${theme} 缺少 ${type}/${size}`); } } }); test('图标产物不被 dist 规则忽略,构建脚本引用的文件都在仓库里', () => { const root = path.join(__dirname, '..', '..'); const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); // 不加前导斜杠时 dist/ 会连 icons/dist/ 一起忽略, // 新克隆的仓库缺少图标,构建直接失败 assert.match(ignore, /^\/dist\/$/m, '.gitignore 的 dist 规则必须锚定到仓库根'); assert.doesNotMatch(ignore, /^dist\/$/m); const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8'); const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8'); const required = [ 'book-ai-dark.ico', 'book-ai-light.ico', 'book-ai-dark.icns', path.join('dark', 'icon-32.png'), path.join('light', 'icon-32.png'), path.join('dark', 'icon-256.png'), path.join('light', 'icon-256.png') ]; for (const rel of required) { assert.ok( fs.existsSync(path.join(root, 'icons', 'dist', rel)), `构建需要的图标缺失: icons/dist/${rel}` ); } assert.ok(portable.includes('book-ai-dark.ico')); assert.ok(mac.includes('book-ai-dark.icns')); }); test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => { const root = path.join(__dirname, '..', '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const build = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8'); assert.strictEqual(pkg.scripts['build:mac'], 'node build-mac.js'); assert.match(build, /const TARGET = `\$\{PRODUCT\}-macos-\$\{ARCH\}`/); assert.match(build, /const ARCH = 'arm64'/); // 交叉打包做不出可用产物:hdiutil 与 codesign 都只有 macOS 才有 assert.match(build, /process\.platform !== 'darwin'/); for (const tool of ['hdiutil', 'codesign', 'ditto']) assert.ok(build.includes(tool), `缺少 ${tool} 检查`); // Electron Framework 带符号链接,用 Node 解压会展开成副本并让签名失效 assert.match(build, /run\('ditto', \['-x', '-k', zip, cacheDir\]\)/); // 重打包后 asar 哈希对不上,留着该字段会启动即报完整性错误 assert.match(build, /ElectronAsarIntegrity/); // 官方 helper plist 没有 CFBundleExecutable,改名后必须补上 assert.match(build, /CFBundleExecutable/); // 带版本的 framework 要签 Versions/A;Squirrel 里的 ShipIt 是独立可执行文件 assert.match(build, /Versions', 'A'/); assert.match(build, /ShipIt/); // 签名必须由内向外,外层 .app 最后签 assert.match(build, /targets\.push\(APP_BUNDLE\)/); assert.match(build, /'--sign', '-'/); assert.match(build, /'--verify', '--deep', '--strict'/); }); test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => { assert.match(mainSrc, /await requestReaderPurge\(id\)/); assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/); assert.match(mainSrc, /function ensureReaderWritable\(entryId\)/); assert.match(mainSrc, /ipcMain\.on\('reader:purgeReady'/); const forgetAt = mainSrc.indexOf('readerStore.forget(id)'); const removeAt = mainSrc.indexOf('library.remove(id, deleteFiles)'); assert.ok(forgetAt > 0 && removeAt > forgetAt, '显式阅读资料清理必须在移除书库条目前成功'); assert.doesNotMatch(mainSrc, /readerStore\.forget\(id\);\s*\}\s*catch\s*\(e\)\s*\{\s*\/\*[^*]*不该阻断移除/); }); test('笔记文档指纹失配时拒绝回退到其它文件', () => { assert.match(mainSrc, /if \(matched < 0\) throw new Error\('笔记关联的原始文件已变更或不存在'\)/); }); test('启动扫描和旧库导入在首屏配置加载后延迟执行', () => { assert.match(mainSrc, /webContents\.once\('did-finish-load'/); assert.match(mainSrc, /setTimeout\(runStartupMaintenance,\s*1500\)/); const maintenanceAt = mainSrc.indexOf('function runStartupMaintenance()'); const legacyAt = mainSrc.indexOf('library.importLegacy(userDataDir)', maintenanceAt); const scanAt = mainSrc.indexOf('library.scan()', maintenanceAt); assert.ok(maintenanceAt > 0 && legacyAt > maintenanceAt && scanAt > legacyAt); }); test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌', () => { const start = mainSrc.indexOf("ipcMain.handle('dialog:pickLocal'"); const end = mainSrc.indexOf('// --- 阅读器 ---', start); const segment = mainSrc.slice(start, end); assert.ok(start > 0 && end > start); assert.match(segment, /localImport\.discover\(r\.filePaths\)/); assert.match(segment, /senderId:\s*event\.sender\.id/); assert.match(segment, /pending\.senderId\s*!==\s*event\.sender\.id/); assert.match(segment, /pendingLocalImports\.delete\(id\)/); assert.match(segment, /10\s*\*\s*60\s*\*\s*1000/); assert.match(segment, /library\.importLocal\(records,\s*organization\)/); }); test('内置阅读器允许 PDF、EPUB 和无 DRM Kindle 容器并保留外部回退', () => { assert.match( mainSrc, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/ ); assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/); assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/); assert.match(mainSrc, /if \(resolved\.format !== 'pdf'\) throw new Error\('只有 PDF 支持页面批注'\)/); }); test('PDF 使用发送者隔离的分段读取且不再整文件经过 IPC', () => { assert.match(mainSrc, /ipcMain\.handle\('reader:rangeOpen'/); assert.match(mainSrc, /ipcMain\.handle\('reader:rangeRead'/); assert.match(mainSrc, /ipcMain\.handle\('reader:rangeClose'/); assert.match(mainSrc, /isReaderSender\(event\.sender\)/); assert.match(mainSrc, /rangeSessions\.closeSender\(senderId\)/); const start = mainSrc.indexOf("ipcMain.handle('reader:bytes'"); const end = mainSrc.indexOf("ipcMain.handle('reader:openExternal'", start); const segment = mainSrc.slice(start, end); assert.match(segment, /format === 'pdf'/); assert.match(segment, /readBoundedFile\(abs, MAX_BUFFERED_READER_BYTES\)/); assert.doesNotMatch(segment, /fs\.readFileSync\(abs\)/); const ranges = fs.readFileSync(path.join(__dirname, '..', 'reader', 'range-sessions.js'), 'utf8'); assert.match(ranges, /MAX_RANGE_BYTES\s*=\s*4\s*\*\s*1024\s*\*\s*1024/); assert.match(ranges, /session\.senderId\s*!==\s*senderIdOf\(senderId\)/); assert.match(ranges, /session\.handle\.read\(buffer,\s*offset,\s*length - offset,\s*start \+ offset\)/); assert.match(ranges, /PDF 文件在阅读期间发生变化/); }); test('AI 图像与取消请求受阅读器发送者和资源边界保护', () => { assert.match(mainSrc, /function isReaderSender\(webContents\)/); assert.match(mainSrc, /ipcMain\.handle\('reader:captureRect'/); assert.match(mainSrc, /function canonicalVisualContexts\(raw\)/); assert.match(mainSrc, /nativeImage\.createFromBuffer/); assert.match(mainSrc, /decoded\.toJPEG\(85\)/); assert.match(mainSrc, /function aiRunKey\(senderId, runId\)/); assert.match(mainSrc, /aiRunKey\(event\.sender\.id/); assert.match(mainSrc, /aiRunKey\(e\.sender\.id/); assert.match(mainSrc, /wc\.once\('destroyed', abortOnDestroy\)/); assert.match(mainSrc, /只有阅读器可以使用 AI 助手/); assert.match(mainSrc, /function notifyAiChanged\(status\)/); assert.match(mainSrc, /webContents\.send\('ai:changed', status\)/); });