Files
peoplelib/src/_test/main.test.js
T
lofyer 381c07733a feat: 支持 macOS arm64 打包,修复图标未入库
新增 build-mac.js,产出 ad-hoc 签名的 .app 与 DMG。只能在 macOS 上
构建:DMG 依赖 hdiutil,且 Apple Silicon 拒绝执行未签名二进制,改名
与改 plist 后必须用 codesign 重签。

按官方 darwin-arm64 运行时的真实结构处理四处易错点:用 ditto 解压以
保留 framework 符号链接、删除重打包后失效的 ElectronAsarIntegrity、
为改名后的 helper 补上 CFBundleExecutable、签名由内向外且单独签
Squirrel 的 ShipIt。

打包版数据目录改为按平台决定:macOS 走 appData,避免写进 DMG 挂载后
只读的 .app 内部并在升级覆盖时丢失书库;Windows 便携版行为不变。
窗口图标在非 Windows 平台改用 PNG。

.gitignore 的 dist 规则补上前导斜杠。此前它同时匹配 icons/dist/,
导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
2026-08-03 16:18:47 +08:00

372 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/ASquirrel 里的 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\)/);
});