feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+361
View File
@@ -0,0 +1,361 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
const cfgPath = require.resolve('../reader/ai-config.js');
const clientPath = require.resolve('../reader/ai-client.js');
const dirs = [];
function tmp() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-ai-'));
dirs.push(d);
return d;
}
test.after(() => {
for (const d of dirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
const storage = {
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from('E' + s),
decryptString: (b) => b.toString().slice(1)
};
function setup({
protocol = 'chat-completions',
baseUrl = 'https://api.test.com/v1',
model = 'm',
apiKey = 'sk-1',
vision = false
} = {}) {
delete require.cache[cfgPath];
delete require.cache[clientPath];
const cfg = require(cfgPath);
cfg.init(tmp(), storage);
cfg.save({ protocol, baseUrl, model, apiKey, vision });
return require(clientPath);
}
function visualContext(overrides = {}) {
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
return {
kind: 'page',
image: {
mimeType: 'image/png',
base64,
width: 1,
height: 1,
bytes: Buffer.from(base64, 'base64').length
},
ocr: { status: 'idle', text: '', include: false },
...overrides
};
}
function sseBody(chunks, { done = true } = {}) {
const lines = chunks.map((c) => `data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
if (done) lines.push('data: [DONE]\n\n');
return lines.join('');
}
// 把字符串切成多个 chunk,模拟真实网络分片(含跨 chunk 断行)
function streamResponse(text, { status = 200, pieces = 3 } = {}) {
const buf = Buffer.from(text, 'utf8');
const size = Math.ceil(buf.length / pieces);
const parts = [];
for (let i = 0; i < buf.length; i += size) parts.push(buf.subarray(i, i + size));
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null, getSetCookie: () => [] },
text: async () => text,
json: async () => JSON.parse(text),
body: (async function* () { for (const p of parts) yield p; })()
};
}
test('流式增量按顺序回调并拼出完整文本', async () => {
const ai = setup();
h.setHandler(() => streamResponse(sseBody(['你', '好', '世界']), { pieces: 5 }));
const seen = [];
const full = await ai.stream({ task: 'translate', text: 'hello', onDelta: (d) => seen.push(d) });
assert.strictEqual(full, '你好世界');
assert.deepStrictEqual(seen, ['你', '好', '世界']);
});
test('SSE 分片跨 chunk 断开也能正确解析', async () => {
const ai = setup();
// 每个字节一个 chunk,保证 data: 行被切碎
h.setHandler(() => streamResponse(sseBody(['abc', 'def']), { pieces: 200 }));
const full = await ai.stream({ task: 'explain', text: 'x' });
assert.strictEqual(full, 'abcdef');
});
test('遇到 [DONE] 立即结束,不解析后续内容', async () => {
const ai = setup();
const body = sseBody(['一'], { done: true }) + sseBody(['不该出现'], { done: false });
h.setHandler(() => streamResponse(body));
assert.strictEqual(await ai.stream({ task: 'summarize', text: 'x' }), '一');
});
test('HTTP 错误体里的 message 会被提取为中文可读错误', async () => {
const ai = setup();
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: 'model not found' } }), { status: 404 }));
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /model not found/);
});
test('401 无 JSON 体时给出可读提示', async () => {
const ai = setup();
h.setHandler(() => streamResponse('Unauthorized', { status: 401 }));
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key 无效/);
});
test('流内返回 error 字段也会抛出', async () => {
const ai = setup();
h.setHandler(() => streamResponse('data: ' + JSON.stringify({ error: { message: '额度不足' } }) + '\n\n'));
await assert.rejects(() => ai.stream({ task: 'ask', text: 'x', question: 'q' }), /额度不足/);
});
test('未配置 Key 且非本地端点时拒绝请求', async () => {
const ai = setup({ apiKey: '' });
await assert.rejects(() => ai.stream({ task: 'translate', text: 'x' }), /API Key/);
});
test('本地端点无 Key 也允许请求,且不带 Authorization 头', async () => {
const ai = setup({ baseUrl: 'http://localhost:11434/v1', apiKey: '' });
let seenHeaders = null;
h.setHandler((_u, o) => { seenHeaders = o.headers; return streamResponse(sseBody(['ok'])); });
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
assert.ok(!seenHeaders.Authorization, '本地模型不该发送 Authorization');
});
test('请求体包含模型名与 stream 标志,且 Key 放在头里', async () => {
const ai = setup({ model: 'deepseek-chat', apiKey: 'sk-abc' });
let seen = null;
h.setHandler((u, o) => { seen = { u, o }; return streamResponse(sseBody(['x'])); });
await ai.stream({ task: 'translate', text: 'hi' });
const body = JSON.parse(seen.o.body);
assert.strictEqual(body.model, 'deepseek-chat');
assert.strictEqual(body.stream, true);
assert.strictEqual(seen.o.headers.Authorization, 'Bearer sk-abc');
assert.ok(seen.u.endsWith('/chat/completions'), '端点拼接错误: ' + seen.u);
assert.ok(!seen.u.includes('sk-abc'), 'Key 不该出现在 URL 中');
});
test('启用图像输入后使用 OpenAI 兼容的 image_url 消息', async () => {
const ai = setup({ vision: true });
let body = null;
h.setHandler((_u, options) => {
body = JSON.parse(options.body);
return streamResponse(sseBody(['看到了']));
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '看到了');
assert.ok(Array.isArray(body.messages[1].content));
assert.strictEqual(body.messages[1].content[0].type, 'text');
assert.strictEqual(body.messages[1].content[1].type, 'image_url');
assert.match(body.messages[1].content[1].image_url.url, /^data:image\/png;base64,/);
assert.deepStrictEqual(Object.keys(body.messages[1].content[1].image_url), ['url']);
});
test('Anthropic 接口使用原生 Messages 图像 source 和流式事件', async () => {
const ai = setup({ protocol: 'anthropic', vision: true });
let seen = null;
h.setHandler((url, options) => {
seen = { url, options, body: JSON.parse(options.body) };
return streamResponse([
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '识别' } })}\n\n`,
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '成功' } })}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
].join(''), { pieces: 11 });
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '识别成功');
assert.ok(seen.url.endsWith('/messages'), seen.url);
assert.strictEqual(seen.options.headers['x-api-key'], 'sk-1');
assert.strictEqual(seen.options.headers['anthropic-version'], '2023-06-01');
assert.ok(!seen.options.headers.Authorization);
assert.strictEqual(seen.body.system.includes('文档页面图像'), true);
assert.strictEqual(seen.body.messages.length, 1);
assert.strictEqual(seen.body.messages[0].content[0].type, 'text');
const image = seen.body.messages[0].content[1];
assert.strictEqual(image.type, 'image');
assert.deepStrictEqual(Object.keys(image.source), ['type', 'media_type', 'data']);
assert.strictEqual(image.source.type, 'base64');
assert.strictEqual(image.source.media_type, 'image/png');
assert.ok(image.source.data.length > 0);
});
test('OpenAI Responses 接口使用 input_image 和响应增量事件', async () => {
const ai = setup({ protocol: 'openai-responses', vision: true });
let seen = null;
h.setHandler((url, options) => {
seen = { url, options, body: JSON.parse(options.body) };
return streamResponse([
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '看见' })}\n\n`,
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: '图片' })}\n\n`,
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`
].join(''), { pieces: 13 });
});
const full = await ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
});
assert.strictEqual(full, '看见图片');
assert.ok(seen.url.endsWith('/responses'), seen.url);
assert.strictEqual(seen.options.headers.Authorization, 'Bearer sk-1');
assert.strictEqual(seen.body.instructions.includes('文档页面图像'), true);
assert.strictEqual(seen.body.max_output_tokens, 1024);
assert.strictEqual(seen.body.store, false);
assert.strictEqual(seen.body.input[0].content[0].type, 'input_text');
const image = seen.body.input[0].content[1];
assert.strictEqual(image.type, 'input_image');
assert.match(image.image_url, /^data:image\/png;base64,/);
});
test('OpenAI Responses 失败事件不会被当作空回答', async () => {
const ai = setup({ protocol: 'openai-responses' });
h.setHandler(() => streamResponse(
`event: response.failed\ndata: ${JSON.stringify({
type: 'response.failed',
response: { error: { message: 'responses failed' } }
})}\n\n`
));
await assert.rejects(
() => ai.stream({ task: 'translate', text: 'x' }),
/responses failed/
);
});
test('协议端点追加在查询参数之前并保留参数', async () => {
const ai = setup({
protocol: 'openai-responses',
baseUrl: 'https://gateway.example.com/v1?api-version=2026-01-01'
});
let requestUrl = '';
h.setHandler((url) => {
requestUrl = url;
return streamResponse(
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'ok' })}\n\n`
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
);
});
assert.strictEqual(await ai.stream({ task: 'translate', text: 'x' }), 'ok');
const url = new URL(requestUrl);
assert.strictEqual(url.pathname, '/v1/responses');
assert.strictEqual(url.searchParams.get('api-version'), '2026-01-01');
});
test('未显式启用图像能力时拒绝发送图片', async () => {
const ai = setup({ vision: false });
await assert.rejects(
() => ai.stream({
task: 'ask',
text: '',
question: '图中是什么?',
visualContexts: [visualContext()]
}),
/未启用图像输入/
);
});
test('图像上下文拒绝伪造尺寸、远程地址和多图输入', () => {
const ai = setup({ vision: true });
const badSize = visualContext();
badSize.image.width = 2;
assert.throws(() => ai.buildMessages('ask', '', 'q', [badSize]), /声明尺寸不匹配/);
assert.throws(
() => ai.buildMessages('ask', '', 'q', [{ kind: 'page', image: { url: 'https://example.com/a.png' } }]),
/JPEG 或 PNG/
);
assert.throws(
() => ai.buildMessages('ask', '', 'q', [visualContext(), visualContext()]),
/最多发送 1 张/
);
});
test('OCR 预留契约仅在识别完成且勾选后附加文字', () => {
const ai = setup({ vision: true });
const context = visualContext({
ocr: { status: 'ready', text: '校对后的 OCR 内容', include: true }
});
const messages = ai.buildMessages('ask', '', '这是什么?', [context]);
const textPart = messages[1].content.find((part) => part.type === 'text');
assert.match(textPart.text, /OCR 识别文字/);
assert.match(textPart.text, /校对后的 OCR 内容/);
});
test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
const ai = setup({ vision: false });
let body = null;
h.setHandler((_url, options) => {
body = JSON.parse(options.body);
return streamResponse(sseBody(['文字回答']));
});
const context = visualContext({
includeImage: false,
ocr: { status: 'ready', text: '仅发送 OCR', include: true }
});
assert.strictEqual(await ai.stream({
task: 'ask',
text: '',
question: '内容是什么?',
visualContexts: [context]
}), '文字回答');
assert.strictEqual(typeof body.messages[1].content, 'string');
assert.match(body.messages[1].content, /仅发送 OCR/);
assert.doesNotMatch(body.messages[1].content, /data:image/);
});
test('超长上下文被截断且保留首尾', () => {
const ai = setup();
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
const clipped = ai.clipContext(long, 2000);
assert.ok(clipped.length < long.length);
assert.ok(clipped.startsWith('A'), '开头丢失');
assert.ok(clipped.includes('TAIL_MARK'), '结尾丢失了,结论性内容会被切掉');
assert.ok(clipped.includes('省略'), '未标注截断');
});
test('不支持的任务类型被拒绝', () => {
const ai = setup();
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
});
test('ask 任务把问题与片段一起送出', () => {
const ai = setup();
const msgs = ai.buildMessages('ask', '文档内容', '这讲了什么');
assert.strictEqual(msgs.length, 2);
assert.ok(msgs[1].content.includes('文档内容'));
assert.ok(msgs[1].content.includes('这讲了什么'));
assert.ok(/编造|没有提到/.test(msgs[0].content), '缺少防幻觉约束');
});
test('取消请求时抛出 AbortError 而不是静默返回', async () => {
const ai = setup();
const ctl = new AbortController();
h.setHandler(() => { ctl.abort(); return streamResponse(sseBody(['x'])); });
await assert.rejects(
() => ai.stream({ task: 'translate', text: 'x', signal: ctl.signal }),
(e) => e.name === 'AbortError'
);
});
+164
View File
@@ -0,0 +1,164 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const modulePath = require.resolve('../reader/annotations.js');
const dirs = [];
function fresh() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotations-'));
dirs.push(dir);
delete require.cache[modulePath];
const store = require(modulePath);
store.init(dir);
return { store, dir };
}
function key(name) {
return crypto.createHash('sha256').update(name).digest('hex');
}
test.after(() => {
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('每个条目保存到独立批注文件', () => {
const { store, dir } = fresh();
store.setPage('book_a', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 10 }] });
store.setPage('book_b', key('b.pdf'), 2, { objects: [{ type: 'Path' }] });
const files = fs.readdirSync(path.join(dir, 'reader-annotations')).sort();
assert.deepStrictEqual(files, ['book_a.json', 'book_b.json']);
assert.strictEqual(store.get('book_a', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
assert.strictEqual(store.get('book_b', key('b.pdf')).pages['2'].objects[0].type, 'Path');
});
test('文档指纹在文件移动后保持稳定,内容变化后更新', () => {
const { store, dir } = fresh();
const first = path.join(dir, 'first.pdf');
const moved = path.join(dir, 'moved.pdf');
const bytes = Buffer.alloc(256 * 1024, 1);
fs.writeFileSync(first, bytes);
const before = store.documentKey(first);
fs.renameSync(first, moved);
assert.strictEqual(store.documentKey(moved), before);
bytes[128 * 1024] = 2;
fs.writeFileSync(moved, bytes);
assert.notStrictEqual(store.documentKey(moved), before);
});
test('大文档指纹只采样首中尾且小文档保持完整 SHA-256', () => {
const { store, dir } = fresh();
const small = path.join(dir, 'small.pdf');
const smallBytes = Buffer.alloc(4096, 7);
fs.writeFileSync(small, smallBytes);
assert.strictEqual(
store.hashDocumentFile(small, smallBytes.length, 8192),
crypto.createHash('sha256').update(smallBytes).digest('hex')
);
const large = path.join(dir, 'large.pdf');
const largeBytes = Buffer.alloc(12 * 1024 * 1024, 3);
fs.writeFileSync(large, largeBytes);
const before = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
const fd = fs.openSync(large, 'r+');
try {
fs.writeSync(fd, Buffer.from([9]), 0, 1, 6 * 1024 * 1024);
} finally {
fs.closeSync(fd);
}
const after = store.hashDocumentFile(large, largeBytes.length, 1024 * 1024);
assert.notStrictEqual(after, before);
});
test('生成指纹期间文件持续变化时拒绝返回混合版本标识', () => {
const { store, dir } = fresh();
const file = path.join(dir, 'changing.pdf');
fs.writeFileSync(file, Buffer.alloc(4096, 1));
const originalStat = fs.statSync;
let calls = 0;
fs.statSync = function (target, ...args) {
const stat = originalStat.call(fs, target, ...args);
if (path.resolve(String(target)) === path.resolve(file)) {
Object.defineProperty(stat, 'mtimeMs', { value: stat.mtimeMs + calls++ });
}
return stat;
};
try {
assert.throws(() => store.documentKey(file), /生成指纹期间发生变化/);
} finally {
fs.statSync = originalStat;
}
});
test('同一条目的不同 PDF 文件与页码互相隔离', () => {
const { store } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'IText', text: 'A' }] });
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'IText', text: 'B' }] });
store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Rect' }] });
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].text, 'A');
assert.strictEqual(store.get('book', key('b.pdf')).pages['1'].objects[0].text, 'B');
assert.strictEqual(store.get('book', key('a.pdf')).pages['2'].objects[0].type, 'Rect');
});
test('空对象列表删除当前页但保留其它页', () => {
const { store } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
store.setPage('book', key('a.pdf'), 2, { objects: [{ type: 'Path' }] });
store.setPage('book', key('a.pdf'), 1, { objects: [] });
const pages = store.get('book', key('a.pdf')).pages;
assert.strictEqual(pages['1'], undefined);
assert.strictEqual(pages['2'].objects.length, 1);
});
test('get 返回深拷贝,外部修改不污染缓存文件', () => {
const { store } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect', left: 5 }] });
const first = store.get('book', key('a.pdf'));
first.pages['1'].objects[0].left = 999;
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].left, 5);
});
test('拒绝路径穿越、非法页码和异常大的单页数据', () => {
const { store } = fresh();
assert.throws(() => store.get('../outside', key('a.pdf')), /ID/);
assert.throws(() => store.setPage('book', 'bad', 1, { objects: [] }), /标识/);
assert.throws(() => store.setPage('book', key('a.pdf'), 0, { objects: [] }), /页码/);
assert.throws(() => store.setPage('book', key('a.pdf'), 1, { objects: [{ text: 'x'.repeat(2 * 1024 * 1024) }] }), /过大/);
});
test('损坏文件回退为空,后续写入可恢复', () => {
const { store, dir } = fresh();
const folder = path.join(dir, 'reader-annotations');
fs.mkdirSync(folder, { recursive: true });
fs.writeFileSync(path.join(folder, 'book.json'), '{ bad');
assert.deepStrictEqual(store.get('book', key('a.pdf')).pages, {});
assert.ok(fs.readdirSync(folder).some((name) => name.startsWith('book.json.corrupt-')));
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects.length, 1);
});
test('主文件损坏时优先从原子写入备份恢复', () => {
const { store, dir } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
const file = path.join(dir, 'reader-annotations', 'book.json');
fs.copyFileSync(file, `${file}.bak`);
fs.writeFileSync(file, '{ bad');
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
});
test('forget 删除条目批注及备份残留', () => {
const { store, dir } = fresh();
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
const file = path.join(dir, 'reader-annotations', 'book.json');
fs.writeFileSync(`${file}.bak`, '{}');
fs.writeFileSync(`${file}.corrupt-1`, '{ bad');
assert.strictEqual(store.forget('book'), true);
assert.strictEqual(fs.existsSync(file), false);
assert.strictEqual(fs.existsSync(`${file}.bak`), false);
assert.strictEqual(fs.existsSync(`${file}.corrupt-1`), false);
});
+191
View File
@@ -0,0 +1,191 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const authPath = require.resolve('../sources/zlib-auth.js');
const keyPath = require.resolve('../sources/semantic-key.js');
const dirs = [];
function tmp() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-auth-'));
dirs.push(d);
return d;
}
test.after(() => {
for (const d of dirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
// 模拟 Electron safeStorage:加密就是加个前缀 + base64,能验证"没有明文落盘"
function fakeStorage(available = true) {
return {
isEncryptionAvailable: () => available,
encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
decryptString: (buf) => {
const s = buf.toString();
if (!s.startsWith('ENC:')) throw new Error('bad ciphertext');
return Buffer.from(s.slice(4), 'base64').toString('utf8');
}
};
}
function freshAuth() {
delete require.cache[authPath];
return require(authPath);
}
test('凭据加密落盘,磁盘上没有明文密码', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'me@example.com', password: 'SuperSecret123', userId: '7', userKey: 'k' });
const all = fs.readdirSync(d).map((f) => fs.readFileSync(path.join(d, f)).toString());
for (const content of all) {
assert.ok(!content.includes('SuperSecret123'), '磁盘上出现了明文密码: ' + content.slice(0, 120));
assert.ok(!content.includes(Buffer.from('SuperSecret123').toString('base64')),
'密码只做了 base64 混淆');
}
const back = auth.read();
assert.strictEqual(back.password, 'SuperSecret123');
assert.strictEqual(back.email, 'me@example.com');
assert.strictEqual(back.userId, '7');
});
test('旧版 base64 数据自动迁移并抹掉明文', () => {
const d = tmp();
const legacy = {
email: Buffer.from('old@example.com').toString('base64'),
password: Buffer.from('OldPass').toString('base64'),
userId: '1', userKey: 'ukey', mirror: 'https://z-lib.fm'
};
fs.writeFileSync(path.join(d, 'zlib-auth.json'), JSON.stringify(legacy));
const auth = freshAuth();
auth.init(d, fakeStorage());
const c = auth.read();
assert.strictEqual(c.email, 'old@example.com', '迁移后邮箱丢失');
assert.strictEqual(c.password, 'OldPass', '迁移后密码丢失');
assert.strictEqual(c.userKey, 'ukey', '会话字段应保留');
const json = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
assert.ok(!json.includes(legacy.password), '旧的明文/混淆密码没有被抹掉');
assert.ok(fs.existsSync(path.join(d, 'zlib-auth.cred')), '未生成加密文件');
});
test('系统不支持加密时绝不把密码写到磁盘', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage(false));
auth.write({ email: 'a@b.c', password: 'PlainSecret' });
for (const f of fs.readdirSync(d)) {
const content = fs.readFileSync(path.join(d, f)).toString();
assert.ok(!content.includes('PlainSecret'), `${f} 里落了明文密码`);
}
// 本进程内仍可用
assert.strictEqual(auth.read().password, 'PlainSecret');
assert.strictEqual(auth.hasCreds(), true);
});
test('setSession 不会因为读取失败清空凭据', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'x@y.z', password: 'Keep', userId: '', userKey: '' });
auth.setSession('99', 'newkey', 'https://z-lib.fm');
const c = auth.read();
assert.strictEqual(c.password, 'Keep', 'setSession 吞掉了密码');
assert.strictEqual(c.email, 'x@y.z');
assert.strictEqual(c.userId, '99');
assert.strictEqual(c.mirror, 'https://z-lib.fm');
// setSession 只应改会话字段,绝不能把凭据顺手写进明文 meta 文件
const meta = fs.readFileSync(path.join(d, 'zlib-auth.json'), 'utf8');
assert.ok(!meta.includes('Keep'), 'setSession 把明文密码写进了 json');
assert.ok(!meta.includes('x@y.z'), 'setSession 把明文邮箱写进了 json');
});
test('clearSession 保留凭据,clear 全部清掉', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'x@y.z', password: 'Keep', userId: '1', userKey: 'k', mirror: 'm' });
auth.clearSession();
assert.strictEqual(auth.getSession(), null, '会话未清除');
assert.strictEqual(auth.hasCreds(), true, 'clearSession 不该动凭据');
assert.strictEqual(auth.read().password, 'Keep');
auth.clear();
assert.strictEqual(auth.hasCreds(), false);
assert.strictEqual(auth.read(), null);
assert.ok(!fs.existsSync(path.join(d, 'zlib-auth.cred')), '密文文件未删除');
});
test('customMirrors 往返不丢失', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'a@b.c', password: 'p', customMirrors: ['https://m1', 'https://m2'] });
assert.deepStrictEqual(auth.read().customMirrors, ['https://m1', 'https://m2']);
});
test('损坏的密文不影响会话字段读取', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'a@b.c', password: 'p', userId: '5', userKey: 'kk' });
fs.writeFileSync(path.join(d, 'zlib-auth.cred'), 'garbage');
const c = auth.read();
assert.strictEqual(c.password, '', '损坏密文应视为无凭据');
assert.strictEqual(c.userId, '5', '会话字段不该受影响');
assert.strictEqual(auth.hasCreds(), false);
});
test('写入是原子的,不留 .tmp 残留', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'a@b.c', password: 'p', userId: '1', userKey: 'k' });
const leftovers = fs.readdirSync(d).filter((f) => f.endsWith('.tmp'));
assert.deepStrictEqual(leftovers, [], '存在临时文件残留');
});
// --- semantic-key ---
test('semantic-key: 解密失败不被永久缓存,可自愈', () => {
const d = tmp();
delete require.cache[keyPath];
const sk = require(keyPath);
const storage = fakeStorage();
sk.init(d, storage);
sk.write('real-api-key');
assert.strictEqual(sk.read(), 'real-api-key');
// 模拟一次临时读取失败(文件被占用等)
const file = path.join(d, 'semantic-scholar-key.bin');
const good = fs.readFileSync(file);
fs.writeFileSync(file, 'corrupted');
delete require.cache[keyPath];
const sk2 = require(keyPath);
sk2.init(d, storage);
assert.strictEqual(sk2.read(), '', '损坏时应返回空');
// 恢复后同一进程内必须能重新读到,不能被空值缓存钉死
fs.writeFileSync(file, good);
assert.strictEqual(sk2.read(), 'real-api-key', '临时失败被永久缓存了');
});
test('semantic-key: 未配置时稳定返回空', () => {
const d = tmp();
delete require.cache[keyPath];
const sk = require(keyPath);
sk.init(d, fakeStorage());
assert.strictEqual(sk.read(), '');
assert.strictEqual(sk.status().configured, false);
});
+436
View File
@@ -0,0 +1,436 @@
// 验证 AI 上下文范围由用户选择,且大上下文必须确认后才外发。
// 用真实的本地 OpenAI 兼容服务接收请求,断言"实际离开进程的内容",而不是 stub 渲染层。
const { app, BrowserWindow, clipboard, shell, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const http = require('http');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'plscope-'));
app.setPath('userData', TMP);
app.setPath('appData', TMP);
const results = [];
function chk(name, cond, extra = '') { results.push([cond ? 'OK' : 'FAIL', name, extra]); }
const openedExternal = [];
const openExternalStub = async (url) => { openedExternal.push(url); };
shell.openExternal = openExternalStub;
if (shell.openExternal !== openExternalStub) throw new Error('无法隔离外部链接测试');
const AI_MARKDOWN = [
'# 回答\n\n',
'1. **第一项**\n2. 第二项\n\n',
'```js\nconsole.log("safe")\n```\n\n',
'| 项目 | 结论 |\n| --- | --- |\n| A | 可用 |\n\n',
'[安全链接](https://example.com/path)\n\n',
'[危险链接](javascript:alert(1))\n\n',
'<img src=x onerror="window.__aiXss=true">\n\n',
'![远程图片](https://untrusted.example/tracker.png)'
].join('');
// 真实的本地模型服务:记录每次收到的 body
const received = [];
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let parsed;
try { parsed = JSON.parse(body); } catch (e) { parsed = { parseError: body.slice(0, 80) }; }
received.push(parsed);
requests.push({ url: req.url, headers: req.headers, body: parsed });
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (req.url.endsWith('/messages')) {
res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Anthropic 正常' } })}\n\n`);
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
res.end();
return;
}
if (req.url.endsWith('/responses')) {
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'Responses 正常' })}\n\n`);
res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { status: 'completed' } })}\n\n`);
res.end();
return;
}
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(0, 80) } }] })}\n\n`);
setTimeout(() => {
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: AI_MARKDOWN.slice(80) } }] })}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}, 300);
});
});
function charsOf(request) {
const msgs = (request && request.messages) || [];
return msgs.reduce((k, m) => k + (typeof (m && m.content) === 'string' ? m.content.length : 0), 0);
}
app.whenReady().then(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const epubPath = path.join(os.tmpdir(), 'plscope-cache', 's.epub');
fs.mkdirSync(path.dirname(epubPath), { recursive: true });
if (!fs.existsSync(epubPath)) {
const { fetch: uf, ProxyAgent } = require('undici');
const r = await uf('https://www.gutenberg.org/ebooks/11.epub.noimages', {
dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
});
fs.writeFileSync(epubPath, Buffer.from(await r.arrayBuffer()));
}
require(path.join(ROOT, 'main.js'));
const settings = require(path.join(ROOT, 'src', 'settings'));
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
settings.init(TMP);
readerStore.init(TMP);
aiConfig.init(TMP, require('electron').safeStorage);
library.init(path.join(TMP, 'library'));
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
aiConfig.save({
protocol: 'chat-completions',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'test-model',
apiKey: '',
vision: true
});
const e = library.add({ title: 'Alice', authors: [], files: [{ path: epubPath, name: 's.epub', format: 'EPUB' }] });
await new Promise((r) => setTimeout(r, 2500));
for (const w of BrowserWindow.getAllWindows()) w.hide();
const win = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
const errs = [];
win.webContents.on('console-message', (event) => {
const { level, message } = event;
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
errs.push(message.slice(0, 120));
}
});
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const js = async (code) => {
try { return await win.webContents.executeJavaScript(code); }
catch (err) { return 'ERR ' + err.message.slice(0, 90); }
};
await js("(function(){var n=document.querySelectorAll('#tocList [data-idx], #tocList .toc-item, #tocList button');if(n[3])n[3].click();return n.length})()");
await new Promise((r) => setTimeout(r, 3000));
chk('上下文选择器存在', (await js("!!document.getElementById('aiScope')")) === true);
chk('默认范围是"仅选中文本"', (await js("document.getElementById('aiScope').value")) === 'selection');
chk('文本和图像五个范围选项齐全',
(await js("Array.from(document.getElementById('aiScope').options).map(o=>o.value).join(',')")) === 'selection,page,document,page-image,region-image');
await js("document.querySelector('[data-pane=\"ai\"]').click()");
await new Promise((r) => setTimeout(r, 800));
chk('未选中文本时给出提示', String(await js("document.getElementById('aiCost').textContent")).includes('未选中'));
// 打开阅读器并静置:不应有任何请求发往模型
await new Promise((r) => setTimeout(r, 3000));
chk('空闲时不会自动调用模型', received.length === 0, '请求数=' + received.length);
// 页面/全文范围不依赖选中文本:此时正文里没有任何选区
chk('切换范围前确实没有选中文本',
(await js("String(window.getSelection() ? window.getSelection().toString() : '').trim().length")) === 0);
await js("var s=document.getElementById('aiScope'); s.value='page'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 1500));
const pageCostText = String(await js("document.getElementById('aiCost').textContent"));
chk('未选中文本时当前页范围仍可估算', /字.*tokens/.test(pageCostText), pageCostText);
const pageChars = Number((/([\d,]+)\s*字/.exec(pageCostText) || [0, '0'])[1].replace(/,/g, ''));
chk('当前页范围估算出非空正文', pageChars > 0, '字数=' + pageChars);
await js("var s=document.getElementById('aiScope'); s.value='document'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 8000));
const costText = String(await js("document.getElementById('aiCost').textContent"));
chk('全文范围显示字数与 token 估算', /字.*tokens/.test(costText), costText);
chk('全文范围提示可能超过模型限制', /可能超过模型限制/.test(costText), costText);
const docChars = Number((/([\d,]+)\s*字/.exec(costText) || [0, '0'])[1].replace(/,/g, ''));
chk('全文范围覆盖整本而不仅当前页', docChars > pageChars * 5, `全文=${docChars} 当前页=${pageChars}`);
// 全文提问 + 用户拒绝 => 一个字都不该发出去
await js("document.getElementById('aiQuestion').value='这章讲了什么';document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 2500));
chk('大上下文会显示应用内确认框',
(await js("!document.getElementById('aiConfirmModal').classList.contains('hidden')")) === true);
const summary = String(await js(
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
));
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
chk('确认框明确警告全文可能超限',
/可能超过模型的上下文限制/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
chk('确认框使用应用按钮而非原生弹窗',
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
fs.writeFileSync(path.join(captureDir, 'ai-send-confirmation.png'), (await win.webContents.capturePage()).toPNG());
await js("document.getElementById('aiConfirmCancelBtn').click()");
await new Promise((r) => setTimeout(r, 500));
chk('用户拒绝后没有任何外发请求', received.length === 0, '请求数=' + received.length);
chk('提问框内容在取消后保留', (await js("document.getElementById('aiQuestion').value")) === '这章讲了什么');
// 用户同意 => 才真正发送全文
await js("document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 1200));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 180));
chk('流式传输过程中稳定渲染不完整 Markdown', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.classList.contains('streaming')
&& output.querySelector('h1')?.textContent === '回答'
&& output.textContent.length > 0;
})()`));
await new Promise((r) => setTimeout(r, 3820));
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
chk('超长全文按上限截断后才外发', charsOf(received[0]) <= 12000 + 2000, '字符=' + charsOf(received[0]));
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
const output = document.getElementById('aiOutput');
return output.querySelector('h1')?.textContent === '回答'
&& output.querySelector('strong')?.textContent === '第一项'
&& output.querySelectorAll('ol > li').length === 2
&& output.querySelector('pre code')?.textContent.includes('console.log')
&& output.querySelectorAll('table th').length === 2;
})()`));
chk('Markdown 链接和图片执行安全策略', await js(`(() => {
const output = document.getElementById('aiOutput');
const safe = output.querySelector('a[data-external-url]');
return safe?.dataset.externalUrl === 'https://example.com/path'
&& safe.getAttribute('href') === '#'
&& !output.querySelector('a[href^="javascript:"], img, script, iframe, object')
&& !!output.querySelector('.ai-md-image-placeholder')
&& window.__aiXss !== true;
})()`));
await js("document.querySelector('#aiOutput a[data-external-url]').click()");
await new Promise((r) => setTimeout(r, 200));
chk('安全链接通过主进程校验后打开', openedExternal.join(',') === 'https://example.com/path');
await js("document.getElementById('aiCopyBtn').click()");
await new Promise((r) => setTimeout(r, 200));
chk('复制 AI 回答保留原始 Markdown', clipboard.readText() === AI_MARKDOWN);
await js("var s=document.getElementById('aiScope'); s.value='page-image'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 2000));
const pageVisual = await js(`(() => {
const card = document.getElementById('aiVisualCard');
const image = document.getElementById('aiVisualPreview');
return {
visible: !card.classList.contains('hidden'),
source: image.getAttribute('src') || '',
meta: document.getElementById('aiVisualMeta').textContent,
ocrDisabled: document.getElementById('aiOcrBtn').disabled
};
})()`);
chk('当前页面图像生成内存预览并保留 OCR 入口',
pageVisual.visible
&& pageVisual.source.startsWith('data:image/jpeg;base64,')
&& /\d+ × \d+/.test(pageVisual.meta)
&& pageVisual.ocrDisabled);
await js("document.getElementById('aiQuestion').value='这张页面图像讲了什么';document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 600));
chk('发送图像前明确显示上传尺寸和数量', await js(`(() => {
const modal = document.getElementById('aiConfirmModal');
return !modal.classList.contains('hidden')
&& document.getElementById('aiConfirmScope').textContent.includes('图像')
&& document.getElementById('aiConfirmCost').textContent.includes('1 张图像');
})()`));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 4000));
const pageContent = received[1] && received[1].messages && received[1].messages[1].content;
const pageImage = Array.isArray(pageContent)
? pageContent.find((part) => part && part.type === 'image_url')
: null;
const pageImageUrl = pageImage && pageImage.image_url && pageImage.image_url.url;
const pageImageBytes = typeof pageImageUrl === 'string'
? Buffer.from(pageImageUrl.slice(pageImageUrl.indexOf(',') + 1), 'base64')
: Buffer.alloc(0);
chk('当前页面仅以内嵌受限图像发送给视觉模型',
received.length === 2
&& /^data:image\/jpeg;base64,/.test(pageImageUrl || '')
&& pageImageBytes.length > 100
&& pageImageBytes.length <= 3 * 1024 * 1024);
const pageImageSize = nativeImage.createFromBuffer(pageImageBytes).getSize();
chk('页面图像压到目标体积以内并限制在 1600px',
pageImageBytes.length <= 400 * 1024
&& Math.max(pageImageSize.width, pageImageSize.height) <= 1600,
`${pageImageSize.width}x${pageImageSize.height} ${Math.round(pageImageBytes.length / 1024)}KB`);
await js("var s=document.getElementById('aiScope'); s.value='region-image'; s.dispatchEvent(new Event('change'));");
await new Promise((r) => setTimeout(r, 500));
const selectionReady = await js(`(() => {
const overlay = document.querySelector('.visual-select-overlay');
const viewport = document.querySelector('.epub-scroll').getBoundingClientRect();
if (!overlay) return false;
const x1 = viewport.left + 80;
const y1 = viewport.top + 100;
const x2 = Math.min(viewport.right - 40, x1 + 300);
const y2 = Math.min(viewport.bottom - 40, y1 + 220);
overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 41, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 41, buttons: 1, clientX: x2, clientY: y2 }));
overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 41, button: 0, clientX: x2, clientY: y2 }));
const box = overlay.querySelector('.visual-select-box');
const initial = box.getBoundingClientRect();
box.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 42, button: 0, buttons: 1,
clientX: initial.left + initial.width / 2, clientY: initial.top + initial.height / 2
}));
overlay.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, pointerId: 42, buttons: 1,
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
}));
overlay.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, pointerId: 42, button: 0,
clientX: initial.left + initial.width / 2 + 16, clientY: initial.top + initial.height / 2 + 12
}));
const moved = box.getBoundingClientRect();
const handle = box.querySelector('.handle-se');
handle.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 43, button: 0, buttons: 1,
clientX: moved.right, clientY: moved.bottom
}));
overlay.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, pointerId: 43, buttons: 1,
clientX: moved.right + 20, clientY: moved.bottom + 16
}));
overlay.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, pointerId: 43, button: 0,
clientX: moved.right + 20, clientY: moved.bottom + 16
}));
const resized = box.getBoundingClientRect();
return !overlay.querySelector('.visual-select-actions').classList.contains('hidden')
&& overlay.querySelectorAll('.visual-select-handle').length === 4
&& moved.left > initial.left
&& moved.top > initial.top
&& resized.width > moved.width
&& resized.height > moved.height;
})()`);
chk('框选区域支持创建、移动及四角调整', selectionReady);
await js("document.querySelector('.visual-select-actions .tb-btn').click()");
await new Promise((r) => setTimeout(r, 2000));
const regionVisual = await js(`(() => ({
visible: !document.getElementById('aiVisualCard').classList.contains('hidden'),
label: document.getElementById('aiVisualLabel').textContent,
meta: document.getElementById('aiVisualMeta').textContent,
overlayGone: !document.querySelector('.visual-select-overlay')
}))()`);
chk('确认框选后恢复 AI 面板并显示区域预览',
regionVisual.visible && regionVisual.label === '框选区域' && regionVisual.overlayGone);
await js("document.getElementById('aiQuestion').value='这个框选区域是什么';document.getElementById('aiSendBtn').click();");
await new Promise((r) => setTimeout(r, 600));
await js("document.getElementById('aiConfirmSendBtn').click()");
await new Promise((r) => setTimeout(r, 4000));
const regionContent = received[2] && received[2].messages && received[2].messages[1].content;
const regionImage = Array.isArray(regionContent)
? regionContent.find((part) => part && part.type === 'image_url')
: null;
chk('框选区域作为单张图像上下文发送', received.length === 3
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
const output = document.getElementById('aiOutput');
const text = 'x'.repeat(256 * 1024 + 1);
window.AiMarkdown.mount(output, text);
return output.classList.contains('ai-output-plain')
&& output.textContent.length === text.length
&& output.children.length === 0;
})()`));
const saved = await js("window.api.settings.get('reader.aiScope','selection').then(r=>r.data)");
chk('范围选择已持久化', saved === 'region-image', String(saved));
// 旧版本存过 chapter,升级后必须迁移到 document,而不是回落成 selection
await js("window.api.settings.set('reader.aiScope','chapter')");
const migrationWin = new BrowserWindow({
show: false, width: 1200, height: 860,
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
});
await migrationWin.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
await new Promise((r) => setTimeout(r, 9000));
const migratedValue = await migrationWin.webContents.executeJavaScript("document.getElementById('aiScope').value");
const migratedSaved = await migrationWin.webContents.executeJavaScript(
"window.api.settings.get('reader.aiScope','selection').then(r=>r.data)"
);
chk('旧 chapter 设置迁移为全文', migratedValue === 'document' && migratedSaved === 'document',
`${migratedValue}/${migratedSaved}`);
migrationWin.destroy();
const regionDataUrl = regionImage?.image_url?.url || '';
const encoded = regionDataUrl.slice(regionDataUrl.indexOf(',') + 1);
const imageBytes = Buffer.from(encoded, 'base64');
const imageSize = nativeImage.createFromBuffer(imageBytes).getSize();
const visualContext = {
kind: 'region',
includeImage: true,
image: {
mimeType: 'image/jpeg',
base64: encoded,
width: imageSize.width,
height: imageSize.height,
bytes: imageBytes.length
},
ocr: { status: 'idle', text: '', include: false }
};
const aiClient = require(path.join(ROOT, 'src', 'reader', 'ai-client'));
aiConfig.save({
protocol: 'anthropic',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'claude-fixture',
apiKey: '',
vision: true
});
const anthropicText = await aiClient.stream({
task: 'ask',
text: '',
question: '测试 Anthropic 图片',
visualContexts: [visualContext]
});
const anthropicRequest = requests.at(-1);
const anthropicImage = anthropicRequest?.body?.messages?.[0]?.content?.[1];
chk('Anthropic Messages API 真实请求使用 base64 source',
anthropicText === 'Anthropic 正常'
&& anthropicRequest?.url === '/v1/messages'
&& anthropicRequest?.headers?.['anthropic-version'] === '2023-06-01'
&& anthropicImage?.type === 'image'
&& anthropicImage?.source?.type === 'base64'
&& anthropicImage?.source?.media_type === 'image/jpeg');
aiConfig.save({
protocol: 'openai-responses',
baseUrl: `http://127.0.0.1:${port}/v1`,
model: 'responses-fixture',
apiKey: '',
vision: true
});
const responsesText = await aiClient.stream({
task: 'ask',
text: '',
question: '测试 Responses 图片',
visualContexts: [visualContext]
});
const responsesRequest = requests.at(-1);
const responsesImage = responsesRequest?.body?.input?.[0]?.content?.[1];
chk('OpenAI Responses API 真实请求使用 input_image',
responsesText === 'Responses 正常'
&& responsesRequest?.url === '/v1/responses'
&& responsesImage?.type === 'input_image'
&& /^data:image\/jpeg;base64,/.test(responsesImage?.image_url || ''));
chk('无渲染层报错', errs.length === 0, errs.slice(0, 2).join(' | '));
console.log('\n========== AI 上下文控制验证 ==========');
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
const bad = results.filter((r) => r[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - bad}/${results.length}`);
server.close();
app.exit(bad ? 1 : 0);
}).catch((e) => { console.error('异常:', e); app.exit(1); });
@@ -0,0 +1,427 @@
const { app, BrowserWindow, safeStorage } = require('electron');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-annotation-ui-'));
const PDF_CACHE = path.join(os.tmpdir(), 'peoplelib-fixtures', 'dummy.pdf');
app.setPath('userData', TMP);
app.setPath('appData', TMP);
const results = [];
function check(name, condition, detail = '') {
results.push([condition ? 'OK' : 'FAIL', name, detail]);
}
async function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function ensurePdf() {
if (fs.existsSync(PDF_CACHE)) return;
fs.mkdirSync(path.dirname(PDF_CACHE), { recursive: true });
const { fetch, ProxyAgent } = require('undici');
const response = await fetch('https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', {
dispatcher: new ProxyAgent({ uri: 'http://127.0.0.1:7890', connectTimeout: 30000 })
});
if (!response.ok) throw new Error(`PDF 下载失败:${response.status}`);
fs.writeFileSync(PDF_CACHE, Buffer.from(await response.arrayBuffer()));
}
async function openReader(entryId) {
const win = new BrowserWindow({
show: false,
width: 1280,
height: 900,
webPreferences: {
preload: path.join(ROOT, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
const errors = [];
win.webContents.on('console-message', (event) => {
const { level, message } = event;
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
errors.push(message);
console.error('RENDERER:', message);
}
});
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), {
query: { entryId }
});
await wait(7000);
return { win, errors };
}
async function js(win, source) {
try {
return await win.webContents.executeJavaScript(source);
} catch (error) {
console.error('脚本失败:', source.slice(0, 180), error.message);
throw error;
}
}
async function tool(win, name) {
await js(win, `document.querySelector('[data-annotation-tool="${name}"]').click()`);
await wait(150);
}
async function drag(win, x1, y1, x2, y2) {
await js(win, `(() => {
const canvas = document.querySelector('.pdfx-annotation .upper-canvas');
const rect = canvas.getBoundingClientRect();
const fire = (type, x, y, buttons) => canvas.dispatchEvent(new MouseEvent(type, {
bubbles: true, cancelable: true, button: 0, buttons,
clientX: rect.left + x, clientY: rect.top + y
}));
fire('mousedown', ${x1}, ${y1}, 1);
fire('mousemove', ${x2}, ${y2}, 1);
fire('mouseup', ${x2}, ${y2}, 0);
})()`);
await wait(350);
}
async function clickCanvas(win, x, y) {
await drag(win, x, y, x, y);
}
async function fireTouch(win, type, points, changedPoints = points) {
return js(win, `(() => {
const target = document.querySelector('.pdfx-annotation .upper-canvas');
const rect = target.getBoundingClientRect();
const make = (point) => new Touch({
identifier: point.id,
target,
clientX: rect.left + point.x,
clientY: rect.top + point.y,
screenX: rect.left + point.x,
screenY: rect.top + point.y,
pageX: rect.left + point.x,
pageY: rect.top + point.y,
radiusX: 2,
radiusY: 2,
force: 1
});
const touches = ${JSON.stringify(points)}.map(make);
const changedTouches = ${JSON.stringify(changedPoints)}.map(make);
const event = new TouchEvent(${JSON.stringify(type)}, {
bubbles: true,
cancelable: true,
composed: true,
touches,
targetTouches: touches,
changedTouches
});
target.dispatchEvent(event);
return event.defaultPrevented;
})()`);
}
app.whenReady().then(async () => {
await ensurePdf();
require(path.join(ROOT, 'main.js'));
const settings = require(path.join(ROOT, 'src', 'settings'));
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
settings.init(TMP);
readerStore.init(TMP);
annotations.init(TMP);
aiConfig.init(TMP, safeStorage);
library.init(path.join(TMP, 'library'));
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
const entry = library.add({
title: 'Annotation Fixture',
authors: [],
files: [{ path: PDF_CACHE, name: 'dummy.pdf', format: 'PDF' }]
});
await wait(1500);
for (const window of BrowserWindow.getAllWindows()) window.hide();
const first = await openReader(entry.id);
const win = first.win;
check('PDF 页面成功渲染', await js(win, "!!document.querySelector('.pdfx-page .pdfx-canvas')"));
check('批注入口仅在 PDF 中显示', !(await js(win, "document.getElementById('annotationToggleBtn').classList.contains('hidden')")));
check('右上角提供界面主题按钮', await js(win, `(() => {
const button = document.getElementById('uiThemeBtn');
return !!button && !!button.querySelector('svg') && button.title === '切换到明亮主题';
})()`));
check('阅读器默认使用暗色界面', (await js(win, "document.documentElement.dataset.uiTheme")) === 'dark');
const documentTheme = await js(win, "document.getElementById('themeSelect').value");
await js(win, "document.getElementById('uiThemeBtn').click()");
await wait(300);
check('主题按钮可切换为明亮界面', await js(win, `document.documentElement.dataset.uiTheme === 'light'
&& document.getElementById('uiThemeBtn').title === '切换到暗色主题'
&& getComputedStyle(document.body).color === 'rgb(31, 41, 55)'`));
check('界面主题不改变文档阅读主题',
(await js(win, "document.getElementById('themeSelect').value")) === documentTheme);
check('界面主题选择已持久化', settings.get('reader.uiTheme', 'dark') === 'light');
await js(win, "document.getElementById('annotationToggleBtn').click()");
await wait(300);
check('批注工具栏可展开', !(await js(win, "document.getElementById('annotationToolbar').classList.contains('hidden')")));
check('完整工具齐全', (await js(win, "document.querySelectorAll('[data-annotation-tool]').length")) === 8);
check('批注工具使用纯图标并提供悬浮提示', await js(win, `Array.from(
document.querySelectorAll('[data-annotation-tool]')
).every(button => button.querySelector('svg') && !button.textContent.trim()
&& button.title && button.getAttribute('aria-label'))`));
check('撤销、重做、清空与入口均使用提示图标', await js(win, `[
'annotationUndoBtn','annotationRedoBtn','annotationClearBtn','annotationToggleBtn'
].every(id => {
const button = document.getElementById(id);
return button.querySelector('svg') && !button.textContent.trim()
&& button.title && button.getAttribute('aria-label');
})`));
check('手形工具默认启用且不遮挡页面',
await js(win, `document.querySelector('[data-annotation-tool="pan"]').classList.contains('active')
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-pan')
&& getComputedStyle(document.querySelector('.pdfx-annotation')).pointerEvents === 'none'
&& getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'none'`));
const panResult = await js(win, `(() => {
const scroller = document.querySelector('.pdfx-scroller');
const page = document.querySelector('.pdfx-page');
scroller.scrollTop = Math.min(180, scroller.scrollHeight - scroller.clientHeight);
const before = scroller.scrollTop;
const rect = page.getBoundingClientRect();
const fire = (target, type, x, y, buttons) => target.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId: 17, pointerType: 'mouse',
button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y
}));
fire(page, 'pointerdown', 200, 300, 1);
fire(scroller, 'pointermove', 200, 360, 1);
fire(scroller, 'pointerup', 200, 360, 0);
return { before, after: scroller.scrollTop };
})()`);
check('手形工具可拖拽 PDF 页面', panResult.after < panResult.before,
`${panResult.before} -> ${panResult.after}`);
await tool(win, 'text-select');
check('文本指针工具恢复正文选择且使用独立图标',
await js(win, `document.querySelector('[data-annotation-tool="text-select"]').classList.contains('active')
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-tool-text-select')
&& getComputedStyle(document.querySelector('.pdfx-text span')).userSelect === 'text'`));
await tool(win, 'rectangle');
await drag(win, 100, 100, 250, 190);
check('矩形工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('1 项'));
await js(win, "document.getElementById('annotationColor').value='#00aa00';document.getElementById('annotationColor').dispatchEvent(new Event('change'))");
await js(win, "document.getElementById('annotationWidth').value='5';document.getElementById('annotationWidth').dispatchEvent(new Event('change'))");
await tool(win, 'pen');
await drag(win, 120, 240, 280, 280);
check('画笔工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('2 项'));
await tool(win, 'highlight');
await drag(win, 140, 320, 330, 320);
check('高亮工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
await tool(win, 'text');
await clickCanvas(win, 340, 130);
win.webContents.insertText('批注文本');
await wait(200);
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait(500);
check('文本工具创建对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await tool(win, 'select');
await clickCanvas(win, 102, 102);
await js(win, "document.getElementById('progressRange').dispatchEvent(new Event('change'))");
await wait(500);
const beforeStyleSync = annotations.get(entry.id, annotations.documentKey(PDF_CACHE)).pages['1'].objects;
check('状态刷新不会误改旧选中批注的样式',
beforeStyleSync.some((object) => object.annotationKind === 'rectangle' && object.stroke === '#ff4d4f'));
await drag(win, 350, 140, 390, 170);
check('选择工具可移动批注', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await tool(win, 'eraser');
await clickCanvas(win, 102, 102);
check('橡皮工具删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
await js(win, "document.getElementById('annotationUndoBtn').click()");
await wait(400);
check('撤销恢复删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await js(win, "document.getElementById('annotationRedoBtn').click()");
await wait(400);
check('重做再次删除对象', (await js(win, "document.getElementById('annotationStatus').textContent")).includes('3 项'));
const beforeWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
await js(win, "document.getElementById('zoomInBtn').click()");
await wait(2500);
const afterWidth = await js(win, "document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
check('缩放后批注层同步缩放', afterWidth > beforeWidth, `${beforeWidth} -> ${afterWidth}`);
check('缩放后撤销历史仍保留', !(await js(win, "document.getElementById('annotationUndoBtn').disabled")));
await wait(1000);
const stored = annotations.get(entry.id, annotations.documentKey(PDF_CACHE));
const objects = stored.pages['1'] && stored.pages['1'].objects;
check('批注写入 data 对应文件', Array.isArray(objects) && objects.length === 3, `对象=${objects && objects.length}`);
check('画笔、高亮和文本类型被持久化',
['pen', 'highlight', 'text'].every((kind) => objects.some((object) => object.annotationKind === kind)));
check('编辑后的文本内容被持久化',
objects.some((object) => object.annotationKind === 'text' && object.text === '批注文本'));
check('颜色与粗细设置写入新批注',
objects.some((object) => object.annotationKind === 'pen' && object.stroke === '#00aa00' && object.strokeWidth === 5));
const annotationFile = path.join(TMP, 'reader-annotations', `${entry.id}.json`);
check('批注文件位于 reader-annotations 目录', fs.existsSync(annotationFile), annotationFile);
await tool(win, 'text');
await clickCanvas(win, 460, 210);
win.webContents.insertText('立即关闭也保存');
win.close();
await wait(900);
const second = await openReader(entry.id);
check('重开阅读器后恢复明亮界面',
(await js(second.win, "document.documentElement.dataset.uiTheme")) === 'light');
await js(second.win, "document.getElementById('annotationToggleBtn').click()");
await wait(500);
check('编辑文本后立即关闭仍保存最后状态',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await js(second.win, "document.getElementById('annotationClearBtn').click()");
await wait(200);
check('清空本页使用应用内确认框',
!(await js(second.win, "document.getElementById('annotationClearModal').classList.contains('hidden')")));
await js(second.win, "document.getElementById('annotationClearCancelBtn').click()");
check('取消清空保留全部批注',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await js(second.win, "document.getElementById('annotationClearBtn').click();document.getElementById('annotationClearConfirmBtn').click()");
await wait(350);
check('确认清空删除当前页批注',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('0 项'));
await js(second.win, "document.getElementById('annotationUndoBtn').click()");
await wait(350);
check('清空后可撤销恢复',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes('4 项'));
await js(second.win, "document.querySelector('[data-pane=\"annotations\"]').click()");
check('标注页签列出已标注页面', await js(second.win, `(() => {
const row = document.querySelector('#annotationList .list-item');
return !!row && row.textContent.includes('第 1 页') && row.textContent.includes('4 项标注');
})()`));
await js(second.win, `document.querySelector('[data-pane="notes"]').click();
document.getElementById('addNoteBtn').click();
document.querySelector('#noteTypeChooser [data-note-type="reading"]').click();
document.getElementById('noteTitleInput').value = '人工笔记';
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
.setText('通过阅读器直接记录');
document.getElementById('noteTagsInput').value = '集成, 手工';
document.getElementById('noteEditorSaveBtn').click()`);
await wait(500);
const manualNotes = readerStore.getState(entry.id).notes;
check('阅读器可直接新建结构化人工笔记',
manualNotes.some((note) => note.source === 'manual'
&& note.title === '人工笔记'
&& note.tags.includes('集成')));
await tool(second.win, 'text-select');
const selectionText = await js(second.win, `(() => {
const span = Array.from(document.querySelectorAll('.pdfx-text span'))
.find((node) => node.textContent.trim());
if (!span) return '';
const range = document.createRange();
range.selectNodeContents(span);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return selection.toString().trim();
})()`);
await wait(100);
check('正文划选显示摘录与记笔记操作',
!!selectionText && await js(second.win, `!document.getElementById('selBar').classList.contains('hidden')
&& !!document.querySelector('[data-sel="excerpt"]')
&& !!document.querySelector('[data-sel="note"]')`));
await js(second.win, `document.querySelector('[data-sel="excerpt"]').click()`);
await wait(500);
check('摘录保留正文引用和精确位置',
readerStore.getState(entry.id).notes.some((note) => note.source === 'selection'
&& note.quote.includes(selectionText) && note.locator && note.locator.page === 1));
await tool(second.win, 'pen');
const touchBase = 4;
await fireTouch(second.win, 'touchstart', [{ id: 1, x: 120, y: 380 }]);
await fireTouch(second.win, 'touchmove', [{ id: 1, x: 210, y: 410 }]);
await fireTouch(second.win, 'touchend', [], [{ id: 1, x: 210, y: 410 }]);
await wait(500);
check('单指触摸仍可完成画笔批注',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1}`));
const beforePinchWidth = await js(second.win,
"document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
await fireTouch(second.win, 'touchstart', [{ id: 11, x: 150, y: 460 }]);
await fireTouch(second.win, 'touchmove', [{ id: 11, x: 210, y: 480 }]);
await fireTouch(second.win, 'touchstart', [
{ id: 11, x: 210, y: 480 },
{ id: 12, x: 310, y: 480 }
], [{ id: 12, x: 310, y: 480 }]);
await fireTouch(second.win, 'touchmove', [
{ id: 11, x: 190, y: 480 },
{ id: 12, x: 330, y: 480 }
]);
check('双指缩放提供即时预览',
await js(second.win, "document.querySelector('.host-pdf').classList.contains('pinch-preview')"));
await fireTouch(second.win, 'touchend', [
{ id: 11, x: 190, y: 480 }
], [{ id: 12, x: 330, y: 480 }]);
await fireTouch(second.win, 'touchend', [], [{ id: 11, x: 190, y: 480 }]);
await wait(2600);
const afterPinchWidth = await js(second.win,
"document.querySelector('.pdfx-annotation .upper-canvas').getBoundingClientRect().width");
check('PDF 双指缩放提交新比例并保持焦点页',
afterPinchWidth > beforePinchWidth
&& (await js(second.win, "document.getElementById('posLabel').textContent")) === '第 1 页',
`${beforePinchWidth} -> ${afterPinchWidth}`);
check('第二指介入回滚未完成笔画',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1}`));
check('双指缩放后可见页面不会变成黑色画布', await js(second.win, `(() => {
const visible = Array.from(document.querySelectorAll('.pdfx-page')).filter((page) => {
const rect = page.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
return visible.length > 0 && visible.every((page) => {
const canvas = page.querySelector('.pdfx-canvas');
if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
const pixel = canvas.getContext('2d').getImageData(
Math.floor(canvas.width / 2),
Math.floor(canvas.height / 2),
1,
1
).data;
return pixel[0] + pixel[1] + pixel[2] > 90;
});
})()`));
await tool(second.win, 'text');
await fireTouch(second.win, 'touchstart', [{ id: 21, x: 420, y: 390 }]);
await fireTouch(second.win, 'touchstart', [
{ id: 21, x: 420, y: 390 },
{ id: 22, x: 520, y: 390 }
], [{ id: 22, x: 520, y: 390 }]);
await fireTouch(second.win, 'touchend', [], [
{ id: 21, x: 420, y: 390 },
{ id: 22, x: 520, y: 390 }
]);
await wait(1800);
check('文本工具下第二指介入不会误留文字批注',
(await js(second.win, "document.getElementById('annotationStatus').textContent")).includes(`${touchBase + 1}`));
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
console.log('\n========== PDF 批注集成验证 ==========');
for (const [status, name, detail] of results) {
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
}
const failed = results.filter((result) => result[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - failed}/${results.length}`);
app.exit(failed ? 1 : 0);
}).catch((error) => {
console.error('异常:', error);
app.exit(1);
});
+287
View File
@@ -0,0 +1,287 @@
const { app, BrowserWindow, nativeImage } = require('electron');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const JSZip = require('jszip');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-cover-ui-'));
app.setPath('userData', TMP);
app.setPath('appData', TMP);
const results = [];
function check(name, condition, detail = '') {
results.push([condition ? 'OK' : 'FAIL', name, detail]);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makePdf(file) {
const stream = 'q\n0.12 0.35 0.78 rg\n0 0 400 600 re f\nQ\nBT\n/F1 34 Tf\n1 1 1 rg\n74 300 Td\n(PDF COVER) Tj\nET\n';
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
];
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let index = 1; index <= objects.length; index++) {
pdf += `${String(offsets[index]).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
fs.writeFileSync(file, pdf);
}
async function makeEpub(file) {
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`);
zip.file('OEBPS/content.opf', `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>EPUB Cover Fixture</dc:title></metadata>
<manifest>
<item id="cover" href="cover.svg" media-type="image/svg+xml" properties="cover-image"/>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="chapter"/></spine>
</package>`);
zip.file('OEBPS/cover.svg', `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="360">
<rect width="240" height="360" fill="#d43d32"/>
<rect x="20" y="20" width="200" height="320" fill="none" stroke="#fff" stroke-width="4"/>
</svg>`);
zip.file('OEBPS/chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><body>Fixture</body></html>');
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
}
async function makeFirstPageEpub(file) {
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', `<?xml version="1.0"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`);
zip.file('OPS/book.opf', `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>First Page Fixture</dc:title></metadata>
<manifest>
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
<item id="art" href="art.svg" media-type="image/svg+xml"/>
</manifest>
<spine><itemref idref="title"/></spine>
</package>`);
zip.file('OPS/title.xhtml', `<html xmlns="http://www.w3.org/1999/xhtml"><body>
<img src="art.svg" alt="First page"/>
</body></html>`);
zip.file('OPS/art.svg', `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="360">
<rect width="240" height="360" fill="#299657"/>
</svg>`);
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
}
async function makeTextEpub(file) {
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', `<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="book.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`);
zip.file('META-INF/encryption.xml', `<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container"
xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
<enc:EncryptedData>
<enc:EncryptionMethod Algorithm="http://www.idpf.org/2008/embedding"/>
<enc:CipherData><enc:CipherReference URI="fonts/obfuscated.otf"/></enc:CipherData>
</enc:EncryptedData>
</encryption>`);
zip.file('book.opf', `<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Text Only Fixture</dc:title></metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
<item id="font" href="fonts/obfuscated.otf" media-type="application/vnd.ms-opentype"/>
</manifest>
<spine><itemref idref="chapter"/></spine>
</package>`);
zip.file('chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><body>Text only</body></html>');
zip.file('fonts/obfuscated.otf', Buffer.from('fixture'));
fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer' }));
}
async function waitForCover(entryId, library) {
const deadline = Date.now() + 35000;
while (Date.now() < deadline) {
const entry = library.get(entryId);
if (entry && entry.cover && !/^https?:/i.test(entry.cover) && fs.existsSync(entry.cover)) return entry;
await wait(150);
}
return library.get(entryId);
}
function sampleCover(file) {
const image = nativeImage.createFromPath(file);
const size = image.getSize();
const pixel = Array.from(image.crop({
x: Math.floor(size.width / 2),
y: Math.floor(size.height / 2),
width: 1,
height: 1
}).toBitmap());
return { width: size.width, height: size.height, pixel };
}
app.whenReady().then(async () => {
const pdfPath = path.join(TMP, 'local.pdf');
const epubPath = path.join(TMP, 'local.epub');
const firstPageEpubPath = path.join(TMP, 'first-page.epub');
const textEpubPath = path.join(TMP, 'text-only.epub');
makePdf(pdfPath);
await makeEpub(epubPath);
await makeFirstPageEpub(firstPageEpubPath);
await makeTextEpub(textEpubPath);
require(path.join(ROOT, 'main.js'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
library.init(path.join(TMP, 'library'));
await wait(600);
const win = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'PeopleLib');
if (!win) throw new Error('主窗口未创建');
win.hide();
await win.webContents.executeJavaScript(
'(()=>{window.__coverChangeCount=0;window.api.library.onChanged(()=>window.__coverChangeCount++);return true})()'
);
const addResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'Local PDF',
authors: ['Fixture'],
files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
})`);
const pdfEntry = await waitForCover(addResult.data.id, library);
check('本地 PDF 自动生成封面', !!pdfEntry.cover && fs.existsSync(pdfEntry.cover), pdfEntry.cover);
const pdfSample = sampleCover(pdfEntry.cover);
check('PDF 封面来自第一页', pdfSample.pixel[0] > pdfSample.pixel[2] * 1.5, pdfSample.pixel.join(','));
check('PDF 缩略图尺寸受限', pdfSample.width <= 320 && pdfSample.height <= 440,
`${pdfSample.width}x${pdfSample.height}`);
check('异步生成完成后通知主界面刷新',
(await win.webContents.executeJavaScript('window.__coverChangeCount')) > 0);
const epubResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'Local EPUB',
authors: [],
files: [{ path: ${JSON.stringify(epubPath)}, name: 'local.epub', format: 'EPUB' }]
})`);
const epubEntry = await waitForCover(epubResult.data.id, library);
check('本地 EPUB 自动生成封面', !!epubEntry.cover && fs.existsSync(epubEntry.cover), epubEntry.cover);
const epubSample = sampleCover(epubEntry.cover);
check('EPUB 优先使用内嵌封面', epubSample.pixel[2] > epubSample.pixel[0] * 1.5, epubSample.pixel.join(','));
const firstPageResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'First Page EPUB',
authors: [],
files: [{ path: ${JSON.stringify(firstPageEpubPath)}, name: 'first-page.epub', format: 'EPUB' }]
})`);
const firstPageEntry = await waitForCover(firstPageResult.data.id, library);
const firstPageSample = sampleCover(firstPageEntry.cover);
check('EPUB 无封面元数据时使用首页图片',
firstPageSample.pixel[1] > firstPageSample.pixel[0] * 1.5, firstPageSample.pixel.join(','));
const textResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'Text Only EPUB',
authors: ['Fixture'],
files: [{ path: ${JSON.stringify(textEpubPath)}, name: 'text-only.epub', format: 'EPUB' }]
})`);
const textEntry = await waitForCover(textResult.data.id, library);
check('含字体混淆的纯文本 EPUB 生成标题封面', !!textEntry.cover && fs.existsSync(textEntry.cover));
const sourceCover = 'data:image/png;base64,iVBORw0KGgo=';
const sourceResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'Source Cover Priority',
cover: ${JSON.stringify(sourceCover)},
files: [{ path: ${JSON.stringify(pdfPath)}, name: 'local.pdf', format: 'PDF' }]
})`);
await wait(800);
check('已有来源封面不被生成封面替换', library.get(sourceResult.data.id).cover === sourceCover);
const badPdfPath = path.join(TMP, 'broken.pdf');
fs.writeFileSync(badPdfPath, 'not a pdf');
const badResult = await win.webContents.executeJavaScript(`window.api.library.add({
title: 'Broken PDF',
files: [{ path: ${JSON.stringify(badPdfPath)}, name: 'broken.pdf', format: 'PDF' }]
})`);
await coverGenerator.ensure(badResult.data.id).catch(() => {});
check('损坏 PDF 不阻断入库且不写入假封面', badResult.ok && !library.get(badResult.data.id).cover);
const changed = library.add({
title: 'Changing file',
files: [{ path: pdfPath, name: 'local.pdf', format: 'PDF' }]
});
const changingJob = coverGenerator.ensure(changed.id);
library.update(changed.id, {
title: 'Changed to EPUB',
files: [{ path: epubPath, name: 'local.epub', format: 'EPUB' }]
});
await changingJob;
const changedEntry = library.get(changed.id);
const changedSample = sampleCover(changedEntry.cover);
check('提取期间文件变更会丢弃旧结果并重新生成',
changedSample.pixel[2] > changedSample.pixel[0] * 1.5, changedSample.pixel.join(','));
const scanPdf = path.join(library.filesDir(), 'scanned.pdf');
fs.copyFileSync(pdfPath, scanPdf);
const scanResult = await win.webContents.executeJavaScript('window.api.library.scan()');
const scanned = library.list().find((entry) => entry.files.some((file) => file.path === scanPdf));
const scannedEntry = scanned && await waitForCover(scanned.id, library);
check('目录扫描条目自动生成封面', scanResult.data.added === 1
&& !!scannedEntry && fs.existsSync(scannedEntry.cover || ''));
const server = http.createServer((_request, response) => {
response.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="download.pdf"'
});
response.end(fs.readFileSync(pdfPath));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const downloadResult = await win.webContents.executeJavaScript(`window.api.downloadFile(
${JSON.stringify(`http://127.0.0.1:${server.address().port}/download.pdf`)},
'download.pdf',
undefined,
undefined,
{ title: 'Downloaded PDF', authors: [], cover: '', sourceId: 'fixture', sourcePostId: '1' }
)`);
const downloadedEntry = downloadResult.ok && await waitForCover(downloadResult.data.entryId, library);
check('来源下载并挂载后自动生成封面', !!downloadedEntry && fs.existsSync(downloadedEntry.cover || ''));
await new Promise((resolve) => server.close(resolve));
check('所有生成封面均为 JPEG',
[pdfEntry, epubEntry, firstPageEntry, textEntry, changedEntry, scannedEntry, downloadedEntry].every((entry) => {
if (!entry || !entry.cover) return false;
const bytes = fs.readFileSync(entry.cover);
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}));
console.log('\n========== 自动封面集成验证 ==========');
for (const [status, name, detail] of results) {
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
}
const failed = results.filter((result) => result[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - failed}/${results.length}`);
coverGenerator.close();
win.destroy();
app.exit(failed ? 1 : 0);
}).catch((error) => {
console.error('异常:', error);
app.exit(1);
});
+345
View File
@@ -0,0 +1,345 @@
// Validate the real main-process download stream, preload progress bridge, library
// attachment, and the completed-download button styling without external network.
const { app, BrowserWindow, safeStorage } = require('electron');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-download-ui-'));
const LIBRARY_DIR = path.join(TMP, 'library');
app.setPath('userData', TMP);
// main.js derives its development userData directory from appData. Redirect both
// before requiring it so even its initial module setup cannot touch the real profile.
app.setPath('appData', TMP);
const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
`known-chunk-${index}-` + String.fromCharCode(65 + index).repeat(24 * 1024)
));
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
));
const knownPayload = Buffer.concat(knownChunks);
const unknownPayload = Buffer.concat(unknownChunks);
const results = [];
let server;
let testWindow;
function check(name, condition, detail = '') {
results.push([condition ? 'OK' : 'FAIL', name, detail]);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function serveChunks(response, chunks, contentLength) {
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename="fixture.txt"',
Connection: 'close'
};
if (contentLength != null) headers['Content-Length'] = String(contentLength);
response.writeHead(200, headers);
if (response.socket) response.socket.setNoDelay(true);
let index = 0;
const sendNext = () => {
if (index >= chunks.length) {
response.end();
return;
}
response.write(chunks[index]);
index += 1;
setTimeout(sendNext, 130);
};
sendNext();
}
function monotonic(events) {
return events.every((event, index) => {
const current = Number(event.receivedBytes);
const previous = index ? Number(events[index - 1].receivedBytes) : 0;
return Number.isFinite(current) && current >= 0 && current >= previous;
});
}
function isWithin(base, target) {
const relative = path.relative(path.resolve(base), path.resolve(target));
return relative === '' || (!relative.startsWith(`..${path.sep}`)
&& relative !== '..' && !path.isAbsolute(relative));
}
function cssRule(css, selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
return match ? match[1] : '';
}
function declaration(rule, property) {
const match = rule.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i'));
return match ? match[1].trim() : '';
}
function parseCssColor(value, css) {
let color = String(value || '').trim();
const variable = color.match(/^var\((--[\w-]+)\)$/);
if (variable) {
const match = css.match(new RegExp(`${variable[1]}\\s*:\\s*([^;]+)`, 'i'));
color = match ? match[1].trim() : '';
}
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!hex) return null;
const digits = hex[1].length === 3
? hex[1].split('').map((digit) => digit + digit).join('')
: hex[1];
return [0, 2, 4].map((offset) => parseInt(digits.slice(offset, offset + 2), 16));
}
function luminance(rgb) {
if (!rgb) return NaN;
const channels = rgb.map((value) => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(a, b) {
const first = luminance(a);
const second = luminance(b);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}
async function downloadInRenderer(url, name, meta, slot) {
return testWindow.webContents.executeJavaScript(`(() => {
window[${JSON.stringify(slot)}] = [];
return window.api.downloadFile(
${JSON.stringify(url)},
${JSON.stringify(name)},
undefined,
undefined,
${JSON.stringify(meta)},
(event) => window[${JSON.stringify(slot)}].push({ ...event })
).then((result) => ({ result, events: window[${JSON.stringify(slot)}] }));
})()`);
}
async function closeServer() {
if (!server || !server.listening) return;
await new Promise((resolve) => server.close(resolve));
}
async function run() {
try {
server = http.createServer((request, response) => {
if (request.url === '/known.txt') {
serveChunks(response, knownChunks, knownPayload.length);
} else if (request.url === '/unknown.txt') {
serveChunks(response, unknownChunks, null);
} else {
response.writeHead(404, { Connection: 'close' });
response.end('not found');
}
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
require(path.join(ROOT, 'main.js'));
// main.js initializes these modules as a side effect. Reinitialize every
// profile-backed store against this harness's isolated temporary directory.
const settings = require(path.join(ROOT, 'src', 'settings'));
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
settings.init(TMP);
readerStore.init(TMP);
annotations.init(TMP);
aiConfig.init(TMP, safeStorage);
zlibAuth.init(TMP, safeStorage);
semanticKey.init(TMP, safeStorage);
library.init(LIBRARY_DIR);
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
const htmlPath = path.join(TMP, 'download-test.html');
fs.writeFileSync(htmlPath, `<!doctype html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Download integration</title>
</head><body><main id="ready">ready</main></body></html>`);
const rendererErrors = [];
testWindow = new BrowserWindow({
show: false,
width: 640,
height: 480,
webPreferences: {
preload: path.join(ROOT, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
testWindow.webContents.on('console-message', (event) => {
const { level, message } = event;
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
rendererErrors.push(message);
}
});
testWindow.webContents.on('preload-error', (_event, _preloadPath, error) => {
rendererErrors.push(`preload: ${error.message}`);
});
testWindow.webContents.on('render-process-gone', (_event, details) => {
rendererErrors.push(`renderer gone: ${details.reason}`);
});
testWindow.webContents.on('did-fail-load', (_event, code, description, validatedURL, isMainFrame) => {
if (isMainFrame) rendererErrors.push(`load ${code}: ${description} (${validatedURL})`);
});
await testWindow.loadFile(htmlPath);
await testWindow.webContents.executeJavaScript(`(() => {
window.__pageErrors = [];
addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
})()`);
check('preload 暴露下载 API',
await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
const port = server.address().port;
const known = await downloadInRenderer(
`http://127.0.0.1:${port}/known.txt`,
'known-fixture.txt',
{
title: 'Known Length Download',
authors: ['Integration Fixture'],
sourceId: 'download-test',
sourcePostId: 'known'
},
'__knownProgress'
);
const knownEvents = known.events || [];
const knownResult = known.result;
const knownFinal = knownEvents[knownEvents.length - 1] || {};
check('Content-Length 下载成功', !!(knownResult && knownResult.ok),
knownResult && knownResult.error);
check('Content-Length 下载产生多个进度事件',
knownEvents.length >= 4 && new Set(knownEvents.map((event) => event.receivedBytes)).size >= 3,
`事件=${knownEvents.length}`);
check('Content-Length 进度单调递增', monotonic(knownEvents),
knownEvents.map((event) => event.receivedBytes).join(','));
check('Content-Length 进度总量正确',
knownEvents.length > 0 && knownEvents.every((event) => event.totalBytes === knownPayload.length),
`期望=${knownPayload.length}`);
check('Content-Length 最终进度完整',
knownFinal.complete === true && knownFinal.percent === 1
&& knownFinal.receivedBytes === knownPayload.length,
JSON.stringify(knownFinal));
const knownPath = knownResult && knownResult.ok && knownResult.data.path;
check('Content-Length 下载字节完全一致',
!!knownPath && fs.existsSync(knownPath) && fs.readFileSync(knownPath).equals(knownPayload),
knownPath || '');
const knownEntry = knownResult && knownResult.ok
? library.get(knownResult.data.entryId) : null;
check('Content-Length 下载挂载到书库条目',
!!knownEntry && knownEntry.title === 'Known Length Download'
&& knownEntry.files.some((file) => file.path === knownPath && file.exists),
knownEntry && knownEntry.id);
check('下载文件仅写入隔离书库', !!knownPath && isWithin(LIBRARY_DIR, knownPath), knownPath || '');
const unknown = await downloadInRenderer(
`http://127.0.0.1:${port}/unknown.txt`,
'unknown-fixture.txt',
{
title: 'Unknown Length Download',
authors: [],
sourceId: 'download-test',
sourcePostId: 'unknown'
},
'__unknownProgress'
);
const unknownEvents = unknown.events || [];
const unknownResult = unknown.result;
const unknownFinal = unknownEvents[unknownEvents.length - 1] || {};
check('无 Content-Length 下载成功', !!(unknownResult && unknownResult.ok),
unknownResult && unknownResult.error);
check('无 Content-Length 下载产生多个单调进度事件',
unknownEvents.length >= 4 && monotonic(unknownEvents), `事件=${unknownEvents.length}`);
check('无 Content-Length 使用不确定进度',
unknownEvents.some((event) => !event.complete && event.receivedBytes > 0
&& event.totalBytes === null && event.percent === null),
JSON.stringify(unknownEvents.slice(0, 3)));
check('无 Content-Length 最终进度完整',
unknownFinal.complete === true && unknownFinal.percent === 1
&& unknownFinal.totalBytes === null
&& unknownFinal.receivedBytes === unknownPayload.length,
JSON.stringify(unknownFinal));
const unknownPath = unknownResult && unknownResult.ok && unknownResult.data.path;
check('无 Content-Length 下载字节完全一致',
!!unknownPath && fs.existsSync(unknownPath)
&& fs.readFileSync(unknownPath).equals(unknownPayload),
unknownPath || '');
const unknownEntry = unknownResult && unknownResult.ok
? library.get(unknownResult.data.entryId) : null;
check('无 Content-Length 下载挂载到书库条目',
!!unknownEntry && unknownEntry.title === 'Unknown Length Download'
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
unknownEntry && unknownEntry.id);
const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
const backgroundValue = declaration(downloadedRule, 'background');
const foregroundValue = declaration(downloadedRule, 'color');
const background = parseCssColor(backgroundValue, css);
const foreground = parseCssColor(foregroundValue, css);
check('下载完成按钮存在静态样式规则', !!downloadedRule, downloadedRule);
check('下载完成按钮使用非蓝绿色背景',
!!background && background[1] > background[0] + 20
&& background[1] > background[2] + 20
&& !/accent|blue/i.test(backgroundValue),
`${backgroundValue} -> ${background || '无法解析'}`);
check('下载完成按钮使用高对比暗色前景',
!!foreground && Math.max(...foreground) < 64 && contrast(background, foreground) >= 4.5,
`${foregroundValue}; 对比度=${contrast(background, foreground).toFixed(2)}`);
await wait(100);
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
check('下载流程没有渲染器错误',
rendererErrors.length === 0 && pageErrors.length === 0,
rendererErrors.concat(pageErrors).join(' | '));
} catch (error) {
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
} finally {
try {
const coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
coverGenerator.close();
} catch (error) { /* main.js may not have loaded */ }
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.destroy();
}
await closeServer().catch((error) => {
check('本地 HTTP 服务器正常关闭', false, error.message);
});
console.log('\n========== 下载进度集成验证 ==========');
for (const [status, name, detail] of results) {
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
}
const failed = results.filter((result) => result[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - failed}/${results.length}`);
app.exit(failed ? 1 : 0);
}
}
app.whenReady().then(run).catch((error) => {
console.error('异常:', error);
app.exit(1);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { app, BrowserWindow } = require('electron');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-startup-ui-'));
app.setPath('appData', TMP);
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
const results = [];
function check(name, pass, detail) {
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
}
async function waitUntil(fn, timeout = 10000) {
const end = Date.now() + timeout;
while (Date.now() < end) {
try {
const value = await fn();
if (value) return value;
} catch (e) { /* 窗口仍在加载 */ }
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`等待条件超时(${timeout}ms`);
}
function printSummary() {
console.log('\n========== 启动响应集成验证 ==========');
for (const [status, name, detail] of results) {
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
}
const failed = results.filter((item) => item[0] === 'FAIL').length;
console.log(`\n通过 ${results.length - failed}/${results.length}`);
return failed;
}
app.whenReady().then(async () => {
const library = require(path.join(ROOT, 'src', 'library', 'store'));
let scanStartedAt = 0;
library.scan = () => {
scanStartedAt = Date.now();
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2200);
return { added: 0, missing: 0, total: 0 };
};
const startedAt = Date.now();
require(path.join(ROOT, 'main.js'));
const win = await waitUntil(() => (
BrowserWindow.getAllWindows().find((item) => item.getTitle() === 'PeopleLib')
));
win.hide();
await waitUntil(() => win.webContents.executeJavaScript(
`document.readyState === 'complete'
&& ['dark', 'light'].includes(document.documentElement.dataset.uiTheme)`
));
const configReadyMs = Date.now() - startedAt;
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
await waitUntil(() => scanStartedAt > 0, 7000);
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
`${scanStartedAt - startedAt}ms`);
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.destroy();
}
const failed = printSummary();
app.exit(failed ? 1 : 0);
}).catch((error) => {
console.error('异常:', error);
check('启动验证未发生异常', false, error.message || String(error));
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.destroy();
}
printSummary();
app.exit(1);
});
+116
View File
@@ -0,0 +1,116 @@
// 测试用网络桩:在 undici 边界拦截,使 http.js 的超时/重试/cookie 逻辑全部走真实代码。
// 必须在 require('../sources/http') 之前调用 installFetchStub。
const path = require('path');
const Module = require('module');
const undiciPath = require.resolve('undici');
const httpPath = require.resolve(path.join(__dirname, '..', 'sources', 'http.js'));
let handler = null;
const calls = [];
function makeResponse({ status = 200, body = '', headers = {}, url = '' } = {}) {
const lower = {};
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
const text = typeof body === 'string' ? body : JSON.stringify(body);
return {
ok: status >= 200 && status < 300,
status,
url,
headers: {
get: (k) => (k.toLowerCase() in lower ? lower[k.toLowerCase()] : null),
getSetCookie: () => lower['set-cookie'] || []
},
text: async () => text,
json: async () => JSON.parse(text),
body: { cancel: async () => {} }
};
}
// 让桩 fetch 尊重 AbortSignal,这样超时与竞速中止是真的在被验证
function abortable(signal, work) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
const e = new Error('aborted');
e.name = 'AbortError';
return reject(e);
}
let done = false;
const onAbort = () => {
if (done) return;
done = true;
const e = new Error('aborted');
e.name = 'AbortError';
reject(e);
};
if (signal) signal.addEventListener('abort', onAbort, { once: true });
Promise.resolve()
.then(work)
.then((v) => { if (!done) { done = true; resolve(v); } })
.catch((e) => { if (!done) { done = true; reject(e); } })
.finally(() => { if (signal) signal.removeEventListener('abort', onAbort); });
});
}
function installFetchStub() {
const stub = {
exports: {
ProxyAgent: class { async close() {} },
fetch: (url, options = {}) => {
const u = String(url);
calls.push({ url: u, options });
if (!handler) throw new Error('未设置 fetch handler: ' + u);
return abortable(options.signal, () => handler(u, options));
}
},
loaded: true,
id: undiciPath,
filename: undiciPath,
paths: []
};
require.cache[undiciPath] = stub;
}
function setHandler(fn) { handler = fn; }
function getCalls() { return calls; }
function resetCalls() { calls.length = 0; }
// 按 URL 子串匹配的路由表,未命中则抛错(避免测试静默通过)
function routes(table) {
return (url) => {
for (const [pattern, value] of table) {
const hit = pattern instanceof RegExp ? pattern.test(url) : url.includes(pattern);
if (hit) return typeof value === 'function' ? value(url) : makeResponse(value);
}
throw new Error('未匹配的请求: ' + url);
};
}
// 清掉数据源与 http 的模块缓存,让每个用例拿到干净的镜像状态 / cookie jar
function freshRequire(relPath) {
const target = require.resolve(path.join(__dirname, '..', relPath));
delete require.cache[target];
delete require.cache[httpPath];
const mirrorPath = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
delete require.cache[mirrorPath];
return require(target);
}
// 从源文件里取出单个函数做隔离测试(用于未导出的内部函数与 main.js)
function extractFns(absFile, from, to, names, preamble = '') {
const src = require('fs').readFileSync(absFile, 'utf8');
const start = src.indexOf(from);
if (start < 0) throw new Error(`未找到起点: ${from}`);
const end = to ? src.indexOf(to, start) : src.length;
if (to && end < 0) throw new Error(`未找到终点: ${to}`);
const seg = src.slice(start, end);
const mod = { exports: {} };
new Function('module', 'require', `${preamble}\n${seg}\nmodule.exports = { ${names.join(', ')} };`)(mod, require);
return mod.exports;
}
module.exports = {
installFetchStub, setHandler, getCalls, resetCalls,
makeResponse, routes, freshRequire, extractFns, httpPath
};
+128
View File
@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert');
const h = require('./helpers');
h.installFetchStub();
const http = require('../sources/http');
test('外部 signal 不会顶替超时保护', async () => {
const outer = new AbortController();
h.setHandler(() => new Promise(() => {}));
const t0 = Date.now();
await assert.rejects(
http.fetchText('https://x/slow', { timeout: 120, retries: 0, signal: outer.signal }),
/请求超时/
);
assert.ok(Date.now() - t0 < 2000, '超时没有生效');
});
test('外部 signal 触发时报"已取消"而不是"超时"', async () => {
const outer = new AbortController();
h.setHandler(() => new Promise(() => {}));
setTimeout(() => outer.abort(), 30);
await assert.rejects(
http.fetchText('https://x/cancel', { timeout: 10000, retries: 0, signal: outer.signal }),
/请求已取消/
);
});
test('取消不可重试,超时可重试', () => {
assert.strictEqual(http.isRetryable(new Error('请求已取消')), false);
assert.strictEqual(http.isRetryable(new Error('请求超时,站点无响应')), true);
assert.strictEqual(http.isRetryable(new Error('站点网关错误(502')), true);
assert.strictEqual(http.isRetryable(new Error('资源不存在(404')), false);
});
test('取消后不会浪费一次重试', async () => {
const outer = new AbortController();
let n = 0;
h.setHandler(() => { n++; return new Promise(() => {}); });
setTimeout(() => outer.abort(), 30);
await assert.rejects(
http.fetchText('https://x/c2', { timeout: 10000, retries: 1, signal: outer.signal }),
/请求已取消/
);
assert.strictEqual(n, 1, `取消后仍重试了,共 ${n}`);
});
test('瞬时故障会按 retries 重试', async () => {
let n = 0;
h.setHandler(() => {
n++;
if (n === 1) return h.makeResponse({ status: 502 });
return h.makeResponse({ body: 'ok' });
});
const out = await http.fetchText('https://x/retry', { retries: 1, retryDelay: 1 });
assert.strictEqual(out, 'ok');
assert.strictEqual(n, 2);
});
test('4xx 不重试', async () => {
let n = 0;
h.setHandler(() => { n++; return h.makeResponse({ status: 404 }); });
await assert.rejects(http.fetchText('https://x/404', { retries: 1, retryDelay: 1 }), /404/);
assert.strictEqual(n, 1, '4xx 不应重试');
});
test('fetchJson 对非 JSON 给出可读错误', async () => {
h.setHandler(() => h.makeResponse({ body: '<html>nope</html>' }));
await assert.rejects(http.fetchJson('https://x/j', { retries: 0 }), /不是有效 JSON/);
});
test('setProxy 拒绝非 http(s) 协议', () => {
assert.throws(() => http.setProxy('socks5://127.0.0.1:1080'), /仅支持/);
http.setProxy('');
assert.strictEqual(http.getProxy(), '');
});
test('tooShort / clampPage 边界', () => {
assert.strictEqual(http.clampPage(0), 1);
assert.strictEqual(http.clampPage('abc'), 1);
assert.strictEqual(http.clampPage(-5), 1);
assert.strictEqual(http.clampPage('3'), 3);
assert.ok(http.tooShort('ab'));
assert.strictEqual(http.tooShort('abc'), null);
});
test('decodeEntities 先解数字实体再解 &amp;,不产生二次解码', () => {
assert.strictEqual(http.decodeEntities('a &amp;lt; b'), 'a &lt; b');
assert.strictEqual(http.decodeEntities('&lt;b&gt;'), '<b>');
});
test('cookie 按域存取', () => {
http.clearCookies();
http.setCookies('https://a.example.com/x', ['k=1; Path=/', 'j=2']);
http.setCookies('https://b.example.com/y', ['z=9']);
assert.match(http.getCookies('https://a.example.com/other'), /k=1/);
assert.match(http.getCookies('https://a.example.com/other'), /j=2/);
assert.strictEqual(http.getCookies('https://c.example.com/'), '');
});
test('clearCookies 接受完整 URL(回退前传 URL 永远清不掉)', () => {
http.clearCookies();
http.setCookies('https://z-lib.fm/a', ['s=1']);
http.clearCookies('https://z-lib.fm');
assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '', 'URL 形式的参数未生效');
});
test('clearCookies 也接受裸主机名,且不误伤其它域', () => {
http.clearCookies();
http.setCookies('https://z-lib.fm/a', ['s=1']);
http.setCookies('https://other.com/a', ['t=2']);
http.clearCookies('z-lib.fm');
assert.strictEqual(http.getCookies('https://z-lib.fm/a'), '');
assert.match(http.getCookies('https://other.com/a'), /t=2/, '误删了其它域的 cookie');
});
test('请求自动带上已存的 cookie', async () => {
http.clearCookies();
http.setCookies('https://ck.example.com/', ['sid=abc']);
let seen = null;
h.setHandler((url, opts) => {
seen = opts.headers.Cookie;
return h.makeResponse({ body: 'ok' });
});
await http.fetchText('https://ck.example.com/p', { retries: 0 });
assert.strictEqual(seen, 'sid=abc');
http.clearCookies();
});
+263
View File
@@ -0,0 +1,263 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const store = require('../library/store');
const created = [];
function freshRoot(tag) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-library-${tag}-`));
created.push(root);
store.init(root);
return root;
}
function indexPath(root) {
return path.join(root, 'library.json');
}
test.after(() => {
store.setChangeListener(null);
for (const root of created) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('managed tags support CRUD, validation, persistence, and zero-use entries', () => {
const root = freshRoot('crud');
const tag = store.addTag({ name: ' 技术 ' });
assert.match(tag.id, /^tag_[a-f0-9]{24}$/);
assert.strictEqual(tag.name, '技术');
assert.ok(Number.isFinite(tag.createdAt));
assert.ok(Number.isFinite(tag.updatedAt));
assert.deepStrictEqual(store.listTags(), [{ ...tag, count: 0 }]);
assert.throws(() => store.addTag(' '), /不能为空/);
assert.throws(() => store.addTag('技术'), /已存在/);
assert.throws(() => store.addTag(' 技术 '), /已存在/);
assert.throws(() => store.addTag('x'.repeat(65)), /64/);
assert.throws(() => store.updateTag(tag.id, { name: '' }), /不能为空/);
assert.throws(() => store.updateTag('missing', { name: '新标签' }), /不存在/);
const renamed = store.updateTag(tag.id, { name: ' 文学 ' });
assert.strictEqual(renamed.id, tag.id);
assert.strictEqual(renamed.name, '文学');
assert.strictEqual(renamed.createdAt, tag.createdAt);
assert.ok(renamed.updatedAt >= tag.updatedAt);
store.init(root);
assert.deepStrictEqual(store.listTags(), [{ ...renamed, count: 0 }]);
const persisted = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
assert.strictEqual(persisted.version, 4);
assert.deepStrictEqual(persisted.tags, [renamed]);
assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
assert.deepStrictEqual(store.removeTag(tag.id), { removed: false });
assert.deepStrictEqual(store.listTags(), []);
});
test('renaming and deleting tags update every item atomically without deleting books', () => {
freshRoot('propagation');
let changes = 0;
store.setChangeListener(() => { changes++; });
try {
const tag = store.addTag('Work');
const first = store.add({ title: '一', tags: ['work', 'Other'] });
const second = store.add({ title: '二', tags: ['WORK'] });
assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
const renamed = store.updateTag(tag.id, { name: 'Research' });
assert.strictEqual(renamed.name, 'Research');
assert.deepStrictEqual(store.get(first.id).tags, ['Research', 'Other']);
assert.deepStrictEqual(store.get(second.id).tags, ['Research']);
assert.strictEqual(store.listTags().find((entry) => entry.id === tag.id).count, 2);
assert.ok(!store.listTags().some((entry) => entry.name.toLowerCase() === 'work'));
assert.deepStrictEqual(store.removeTag(tag.id), { removed: true });
assert.strictEqual(store.list().length, 2);
assert.deepStrictEqual(store.get(first.id).tags, ['Other']);
assert.deepStrictEqual(store.get(second.id).tags, []);
assert.ok(store.listTags().some((entry) => entry.name === 'Other'));
assert.strictEqual(changes, 5, 'tag CRUD and tagged item organization changes should notify');
} finally {
store.setChangeListener(null);
}
});
test('add and update automatically catalog unseen item tags and retain them at zero use', () => {
const root = freshRoot('automatic');
const book = store.add({ title: '自动', tags: [' Alpha ', 'alpha'] });
store.update(book.id, { tags: ['Beta'] });
let listed = store.listTags();
assert.deepStrictEqual(
listed.map((entry) => [entry.name, entry.count]),
[['Beta', 1], ['Alpha', 0]]
);
store.remove(book.id, false);
listed = store.listTags();
assert.deepStrictEqual(
listed.map((entry) => [entry.name, entry.count]).sort(),
[['Alpha', 0], ['Beta', 0]]
);
store.init(root);
assert.deepStrictEqual(
store.listTags().map((entry) => [entry.name, entry.count]).sort(),
[['Alpha', 0], ['Beta', 0]]
);
const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
assert.deepStrictEqual(raw.items, []);
assert.deepStrictEqual(raw.tags.map((entry) => entry.name).sort(), ['Alpha', 'Beta']);
});
test('v1 through v3 indexes migrate to a normalized v4 tag catalog', () => {
const fixtures = [
{
version: 1,
data: [{ id: 'v1', title: '一', tags: [' Alpha ', 'alpha'] }],
expected: 'Alpha'
},
{
version: 2,
data: { version: 2, items: [{ id: 'v2', title: '二', tags: ['BETA'] }] },
expected: 'BETA'
},
{
version: 3,
data: {
version: 3,
shelves: [],
items: [{ id: 'v3', title: '三', tags: [' 伽马 ', '伽马'] }]
},
expected: '伽马'
}
];
for (const fixture of fixtures) {
const root = freshRoot(`schema-v${fixture.version}`);
fs.writeFileSync(indexPath(root), JSON.stringify(fixture.data));
store.init(root);
const migrated = store.listTags();
assert.strictEqual(migrated.length, 1);
assert.strictEqual(migrated[0].name, fixture.expected);
assert.strictEqual(migrated[0].count, 1);
store.addTag(`零使用-${fixture.version}`);
const raw = JSON.parse(fs.readFileSync(indexPath(root), 'utf8'));
assert.strictEqual(raw.version, 4);
assert.strictEqual(raw.tags.length, 2);
assert.strictEqual(raw.tags[0].name, fixture.expected);
assert.match(raw.tags[0].id, /^tag_[a-f0-9]{24}$/);
assert.deepStrictEqual(raw.items[0].tags, [fixture.expected]);
}
});
test('legacy import merges managed and item tags case-insensitively', () => {
const legacy = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-library-legacy-'));
created.push(legacy);
fs.writeFileSync(indexPath(legacy), JSON.stringify({
version: 4,
shelves: [],
tags: [
{ id: 'legacy-existing', name: 'existing', createdAt: 10, updatedAt: 20 },
{ id: 'legacy-zero', name: 'Legacy Zero', createdAt: 30, updatedAt: 40 }
],
items: [{ id: 'legacy-book', title: '旧书', tags: ['Imported Item'] }]
}));
freshRoot('legacy-destination');
const existing = store.addTag('Existing');
assert.strictEqual(store.importLegacy(legacy).imported, 1);
const listed = store.listTags();
assert.strictEqual(listed.filter((entry) => entry.name.toLowerCase() === 'existing').length, 1);
assert.strictEqual(listed.find((entry) => entry.id === existing.id).name, 'Existing');
assert.strictEqual(listed.find((entry) => entry.name === 'Legacy Zero').count, 0);
assert.strictEqual(listed.find((entry) => entry.name === 'Imported Item').count, 1);
assert.strictEqual(store.get('legacy-book').tags[0], 'Imported Item');
});
test('listTags is deterministically ordered and deeply cloned', () => {
freshRoot('list');
store.addTag('零');
store.add({ title: '一', tags: ['Zulu', '中文'] });
store.add({ title: '二', tags: ['zulu', 'Alpha'] });
const listed = store.listTags();
assert.deepStrictEqual(
listed.map((entry) => [entry.name, entry.count]),
[
['Zulu', 2],
...[
['Alpha', 1],
['中文', 1]
].sort((a, b) => a[0].localeCompare(b[0], 'zh-CN', { sensitivity: 'base' })),
['零', 0]
]
);
listed[0].name = '外部修改';
listed[0].count = 999;
listed.push({ id: 'fake', name: '假的', count: 1 });
const again = store.listTags();
assert.strictEqual(again.length, 4);
assert.strictEqual(again[0].name, 'Zulu');
assert.strictEqual(again[0].count, 2);
});
test('failed tag writes roll back both catalog and item references', () => {
const root = freshRoot('rollback');
const tag = store.addTag('Before');
const book = store.add({ title: '书', tags: ['before'] });
const file = indexPath(root);
const beforeFile = fs.readFileSync(file, 'utf8');
const beforeTags = store.listTags();
const originalRename = fs.renameSync;
let failed = false;
fs.renameSync = function renameWithFailure(source, destination) {
if (!failed && source === `${file}.tmp` && destination === file) {
failed = true;
throw new Error('simulated replace failure');
}
return originalRename.apply(this, arguments);
};
try {
assert.throws(
() => store.updateTag(tag.id, { name: 'After' }),
/书库索引写入失败/
);
} finally {
fs.renameSync = originalRename;
}
assert.ok(failed);
assert.strictEqual(fs.readFileSync(file, 'utf8'), beforeFile);
assert.deepStrictEqual(store.listTags(), beforeTags);
assert.deepStrictEqual(store.get(book.id).tags, ['before']);
assert.ok(!fs.existsSync(`${file}.tmp`));
assert.ok(!fs.existsSync(`${file}.bak`));
});
test('explicit tag creation enforces the existing catalog limit', () => {
const root = freshRoot('limit');
const now = Date.now();
fs.writeFileSync(indexPath(root), JSON.stringify({
version: 4,
shelves: [],
tags: Array.from({ length: 50 }, (_, i) => ({
id: `tag-seeded-${i}`,
name: `Tag ${i}`,
createdAt: now,
updatedAt: now
})),
items: []
}));
store.init(root);
assert.throws(() => store.addTag('One Too Many'), /50/);
assert.strictEqual(store.listTags().length, 50);
});
+156
View File
@@ -0,0 +1,156 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { discover } = require('../library/local-import');
const created = [];
function freshRoot(tag) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-local-import-${tag}-`));
created.push(root);
return root;
}
function write(root, relativePath, contents = '') {
const target = path.join(root, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
return target;
}
test.after(() => {
for (const root of created) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (error) { /* ignore */ }
}
});
test('discovers a directly selected supported file with a canonical record', async () => {
const root = freshRoot('direct');
const selected = write(root, 'A Book.PDF');
const canonical = fs.realpathSync(selected);
assert.deepStrictEqual(await discover([selected]), [{
path: canonical,
name: 'A Book.PDF',
format: 'pdf',
parentName: path.basename(root)
}]);
assert.ok(path.isAbsolute(canonical));
});
test('recursively discovers supported files and uses each immediate parent name', async () => {
const root = freshRoot('recursive');
const first = write(root, 'root.epub');
const second = write(root, path.join('Shelf One', 'nested.MOBI'));
const third = write(root, path.join('Shelf One', 'Deeper', 'last.fb2'));
const result = await discover([root]);
const byName = new Map(result.map((record) => [record.name, record]));
assert.deepStrictEqual(
new Set(result.map((record) => record.path)),
new Set([first, second, third].map((value) => fs.realpathSync(value)))
);
assert.strictEqual(byName.get('root.epub').parentName, path.basename(root));
assert.strictEqual(byName.get('nested.MOBI').parentName, 'Shelf One');
assert.strictEqual(byName.get('last.fb2').parentName, 'Deeper');
assert.deepStrictEqual(
Object.fromEntries(result.map((record) => [record.name, record.format])),
{ 'last.fb2': 'fb2', 'nested.MOBI': 'mobi', 'root.epub': 'epub' }
);
});
test('handles mixed file and directory inputs while skipping unsupported and non-files', async () => {
const root = freshRoot('mixed');
const folder = path.join(root, 'folder');
const inFolder = write(root, path.join('folder', 'comic.cbz'));
const azw = write(root, path.join('folder', 'legacy.azw'));
const direct = write(root, 'notes.txt');
write(root, path.join('folder', 'cover.jpg'));
write(root, 'README.md');
fs.mkdirSync(path.join(root, 'empty'));
const result = await discover([
path.join(root, 'missing.pdf'),
path.join(root, 'README.md'),
path.join(root, 'empty'),
direct,
folder
]);
assert.deepStrictEqual(
result.map((record) => record.path),
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
);
});
test('de-duplicates repeated selections', async () => {
const root = freshRoot('duplicate');
const selected = write(root, 'duplicate.djvu');
const result = await discover([selected, root, selected]);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].path, fs.realpathSync(selected));
});
test('does not follow symbolic links to files or directories when links are available', async (t) => {
const root = freshRoot('symlink');
const outside = freshRoot('symlink-target');
const ordinary = write(root, 'ordinary.cbr');
const linkedFileTarget = write(outside, 'linked.pdf');
const linkedDirectoryTarget = path.join(outside, 'books');
const nestedTarget = write(outside, path.join('books', 'nested.azw3'));
const fileLink = path.join(root, 'file-link.pdf');
const directoryLink = path.join(root, 'directory-link');
try {
fs.symlinkSync(linkedFileTarget, fileLink, 'file');
fs.symlinkSync(
linkedDirectoryTarget,
directoryLink,
process.platform === 'win32' ? 'junction' : 'dir'
);
} catch (error) {
t.skip(`symbolic links are unavailable: ${error.code || error.message}`);
return;
}
const result = await discover([root, fileLink, directoryLink]);
assert.deepStrictEqual(result.map((record) => record.path), [fs.realpathSync(ordinary)]);
assert.ok(!result.some((record) => record.path === fs.realpathSync(nestedTarget)));
});
test('returns a deterministic path-sorted order independent of selection order', async () => {
const root = freshRoot('order');
write(root, 'zeta.txt');
write(root, 'Alpha.pdf');
write(root, path.join('middle', 'beta.epub'));
const forward = await discover([path.join(root, 'zeta.txt'), path.join(root, 'middle'), root]);
const reverse = await discover([root, path.join(root, 'middle'), path.join(root, 'zeta.txt')]);
assert.deepStrictEqual(forward, reverse);
assert.deepStrictEqual(
forward.map((record) => record.path),
forward.map((record) => record.path).slice().sort((left, right) => {
const leftKey = process.platform === 'win32' ? left.toLowerCase() : left;
const rightKey = process.platform === 'win32' ? right.toLowerCase() : right;
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : left < right ? -1 : left > right ? 1 : 0;
})
);
});
test('throws a clear Chinese error when the supported-file maximum is exceeded', async () => {
const root = freshRoot('maximum');
write(root, 'one.pdf');
write(root, 'two.epub');
write(root, 'three.mobi');
await assert.rejects(
discover([root], { maxFiles: 2 }),
/本地导入文件数量超过上限(最多 2 个)/
);
});
+225
View File
@@ -0,0 +1,225 @@
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\}/);
});
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\)/);
});
+118
View File
@@ -0,0 +1,118 @@
const test = require('node:test');
const assert = require('node:assert');
const path = require('path');
function freshMirror() {
const p = require.resolve(path.join(__dirname, '..', 'sources', 'mirror.js'));
delete require.cache[p];
return require(p);
}
test('tryMirrors: 内容级错误立即返回,不再试其它镜像', async () => {
const m = freshMirror();
const tried = [];
await assert.rejects(
m.tryMirrors('p1', ['a', 'b', 'c'], async (x) => {
tried.push(x);
throw m.contentError('该 DOI 不存在');
}),
/不存在/
);
assert.deepStrictEqual(tried, ['a'], '内容级错误不该继续轮询');
});
test('tryMirrors: 内容级错误不拉黑镜像,下次仍优先使用', async () => {
const m = freshMirror();
await assert.rejects(m.tryMirrors('p2', ['a', 'b'], async () => {
throw m.contentError('没有这篇');
}));
const tried = [];
await m.tryMirrors('p2', ['a', 'b'], async (x) => { tried.push(x); return 'ok'; });
assert.strictEqual(tried[0], 'a', '健康镜像被误拉黑了');
});
test('tryMirrors: 真实网络故障会依次换镜像', async () => {
const m = freshMirror();
const tried = [];
const r = await m.tryMirrors('p3', ['a', 'b', 'c'], async (x) => {
tried.push(x);
if (x !== 'c') throw new Error('网络连接失败,请检查网络或代理设置');
return 'ok';
});
assert.strictEqual(r, 'ok');
assert.deepStrictEqual(tried, ['a', 'b', 'c']);
});
test('tryMirrors: 成功镜像会被记住并优先', async () => {
const m = freshMirror();
await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => {
if (x !== 'c') throw new Error('请求超时,站点无响应');
return 'ok';
});
const tried = [];
await m.tryMirrors('p4', ['a', 'b', 'c'], async (x) => { tried.push(x); return 'ok'; });
assert.strictEqual(tried[0], 'c', '上次成功的镜像没有被优先');
});
test('tryMirrors: 全部失败时抛出最后一个错误', async () => {
const m = freshMirror();
await assert.rejects(
m.tryMirrors('p5', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
/超时/
);
});
test('raceMirrors: 返回最快成功的结果', async () => {
const m = freshMirror();
const r = await m.raceMirrors('r1', ['slow', 'fast'], async (x) => {
if (x === 'slow') { await new Promise((s) => setTimeout(s, 200)); return 'slow'; }
return 'fast';
});
assert.strictEqual(r, 'fast');
});
test('raceMirrors: 胜出后中止其余在途请求', async () => {
const m = freshMirror();
let aborted = false;
const r = await m.raceMirrors('r2', ['loser', 'winner'], async (x, signal) => {
if (x === 'winner') return 'w';
return new Promise((_res, rej) => {
signal.addEventListener('abort', () => { aborted = true; rej(new Error('请求已取消')); });
});
});
assert.strictEqual(r, 'w');
await new Promise((s) => setTimeout(s, 20));
assert.ok(aborted, '败者没有被中止');
});
test('raceMirrors: 跳过已拉黑镜像', async () => {
const m = freshMirror();
// 先让 bad 因真实故障进黑名单
await m.raceMirrors('r3', ['bad', 'good'], async (x) => {
if (x === 'bad') throw new Error('网络连接失败,请检查网络或代理设置');
return 'ok';
});
const tried = [];
await m.raceMirrors('r3', ['bad', 'good'], async (x) => { tried.push(x); return 'ok'; });
assert.ok(!tried.includes('bad'), '黑名单在竞速模式下失效了');
});
test('raceMirrors: 全部失败时 reject 而不是挂起', async () => {
const m = freshMirror();
await assert.rejects(
m.raceMirrors('r4', ['a', 'b'], async () => { throw new Error('请求超时,站点无响应'); }),
/超时/
);
});
test('raceMirrors: 内容级错误直接结束竞速', async () => {
const m = freshMirror();
await assert.rejects(
m.raceMirrors('r5', ['a', 'b'], async (x) => {
if (x === 'a') throw m.contentError('页面结构无法识别');
await new Promise((s) => setTimeout(s, 500));
return 'late';
}),
/页面结构无法识别/
);
});
+96
View File
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
function fresh() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-note-assets-'));
delete require.cache[require.resolve('../reader/note-assets')];
const assets = require('../reader/note-assets');
assets.init(root);
return { root, assets };
}
function pdf(file, suffix = '') {
fs.writeFileSync(file, `%PDF-1.4\n${suffix}\n%%EOF\n`);
}
test('PDF 底版选择令牌绑定渲染进程且只能由保存流程解析', () => {
const { root, assets } = fresh();
const file = path.join(root, 'paper.pdf');
pdf(file, 'page one');
const staged = assets.stagePdf(file, 101);
assert.match(staged.token, /^[0-9a-f-]{36}$/);
assert.strictEqual(staged.name, 'paper.pdf');
assert.throws(() => assets.readDraft(staged.token, 202), /选择已失效/);
assert.match(assets.readDraft(staged.token, 101).subarray(0, 5).toString(), /^%PDF-/);
const canvasContent = {
version: 1,
pages: [{
id: 'pg_one',
width: 612,
height: 792,
background: { type: 'pdf', page: 1, draftToken: staged.token },
objects: []
}]
};
const resolved = assets.resolveDrafts(canvasContent, 101);
assert.match(resolved.content.pages[0].background.assetId, /^pdf_[a-f0-9]{64}$/);
assert.ok(!Object.prototype.hasOwnProperty.call(
resolved.content.pages[0].background,
'draftToken'
));
assert.deepStrictEqual(resolved.tokens, [staged.token]);
assets.commitTokens(resolved.tokens);
assert.throws(() => assets.readDraft(staged.token, 101), /选择已失效/);
assert.match(assets.readAsset(resolved.content.pages[0].background.assetId).toString(), /page one/);
});
test('相同 PDF 复用内容资源并按引用集合清理孤儿', () => {
const { root, assets } = fresh();
const first = path.join(root, 'first.pdf');
const copy = path.join(root, 'copy.pdf');
const other = path.join(root, 'other.pdf');
pdf(first, 'same bytes');
fs.copyFileSync(first, copy);
pdf(other, 'different');
const a = assets.stagePdf(first, 1);
const b = assets.stagePdf(copy, 1);
const c = assets.stagePdf(other, 1);
const resolve = (token) => assets.resolveDrafts({
version: 1,
pages: [{
id: 'pg_one',
width: 612,
height: 792,
background: { type: 'pdf', page: 1, draftToken: token },
objects: []
}]
}, 1);
const ar = resolve(a.token);
const br = resolve(b.token);
const cr = resolve(c.token);
assert.strictEqual(
ar.content.pages[0].background.assetId,
br.content.pages[0].background.assetId
);
assert.notStrictEqual(
ar.content.pages[0].background.assetId,
cr.content.pages[0].background.assetId
);
assets.commitTokens([...ar.tokens, ...br.tokens, ...cr.tokens]);
assert.strictEqual(assets.cleanup([ar.content.pages[0].background.assetId]), 1);
assert.doesNotThrow(() => assets.readAsset(ar.content.pages[0].background.assetId));
assert.throws(() => assets.readAsset(cr.content.pages[0].background.assetId));
});
test('PDF 底版拒绝伪造文件、非法资源 ID 和不存在资源', () => {
const { root, assets } = fresh();
const fake = path.join(root, 'fake.pdf');
fs.writeFileSync(fake, 'not a pdf');
assert.throws(() => assets.stagePdf(fake, 1), /不是有效 PDF/);
assert.throws(() => assets.readAsset('../escape'), /资源标识无效/);
assert.throws(() => assets.readAsset(`pdf_${'f'.repeat(64)}`));
});
+177
View File
@@ -0,0 +1,177 @@
const assert = require('node:assert');
const test = require('node:test');
const fs = require('fs');
const os = require('os');
const path = require('path');
const modulePath = require.resolve('../reader/range-sessions');
const dirs = [];
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-range-sessions-'));
dirs.push(dir);
const file = path.join(dir, 'fixture.pdf');
const bytes = Buffer.alloc(6 * 1024 * 1024);
for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251;
fs.writeFileSync(file, bytes);
delete require.cache[modulePath];
const sessions = require(modulePath);
sessions.init((_entryId, fileIndex) => ({
abs: file,
format: 'pdf',
fileIndex: Number.isInteger(fileIndex) ? fileIndex : 0
}));
return { sessions, file, bytes };
}
test.after(async () => {
try {
const sessions = require(modulePath);
await sessions.closeAll();
} catch (error) { /* ignore */ }
for (const dir of dirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (error) { /* ignore */ }
}
});
test('PDF 分段会话只返回请求范围并可显式关闭', async () => {
const { sessions, bytes } = fixture();
const opened = await sessions.open(10, 'entry', 0);
assert.match(opened.sessionId, /^[a-f0-9-]{36}$/);
assert.strictEqual(opened.size, bytes.length);
assert.strictEqual(opened.chunkSize, sessions.RANGE_CHUNK_BYTES);
const begin = 1024 * 1024 + 137;
const end = begin + 256 * 1024;
const result = await sessions.read(10, opened.sessionId, begin, end);
assert.deepStrictEqual(result, bytes.subarray(begin, end));
assert.strictEqual(await sessions.close(10, opened.sessionId), true);
assert.strictEqual(await sessions.close(10, opened.sessionId), false);
assert.strictEqual(sessions.status().sessions, 0);
});
test('PDF 分段会话绑定发送者并限制范围与并发会话数量', async () => {
const { sessions, bytes } = fixture();
const first = await sessions.open(20, 'entry', 0);
await assert.rejects(() => sessions.read(21, first.sessionId, 0, 1024), /无效或已关闭/);
await assert.rejects(() => sessions.read(20, first.sessionId, -1, 1024), /范围无效/);
await assert.rejects(
() => sessions.read(20, first.sessionId, 0, sessions.MAX_RANGE_BYTES + 1),
/不能超过 4 MB/
);
await assert.rejects(
() => sessions.read(20, first.sessionId, bytes.length - 10, bytes.length + 1),
/范围无效/
);
const ids = [first.sessionId];
for (let index = 0; index < sessions.MAX_SESSIONS_PER_SENDER; index++) {
ids.push((await sessions.open(20, 'entry', 0)).sessionId);
}
assert.strictEqual(sessions.status().sessions, sessions.MAX_SESSIONS_PER_SENDER);
await assert.rejects(() => sessions.read(20, ids[0], 0, 1024), /无效或已关闭/);
assert.strictEqual(await sessions.closeSender(20), sessions.MAX_SESSIONS_PER_SENDER);
assert.strictEqual(sessions.status().sessions, 0);
});
test('PDF 在阅读期间发生变化时拒绝继续提供旧会话数据', async () => {
const { sessions, file } = fixture();
const opened = await sessions.open(30, 'entry', 0);
fs.appendFileSync(file, Buffer.from([1]));
await assert.rejects(
() => sessions.read(30, opened.sessionId, 0, 1024),
/发生变化/
);
await sessions.closeSender(30);
});
test('40 GB 文件使用安全整数偏移按需读取而不分配整文件缓冲区', async () => {
delete require.cache[modulePath];
const sessions = require(modulePath);
const size = 40 * 1024 * 1024 * 1024;
let closed = false;
const stat = { size, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
const handle = {
stat: async () => stat,
read: async (buffer, offset, length, position) => {
for (let index = 0; index < length; index++) {
buffer[offset + index] = (position + index) % 251;
}
return { bytesRead: length, buffer };
},
close: async () => { closed = true; }
};
sessions.init(
() => ({ abs: 'virtual-40gb.pdf', format: 'pdf', fileIndex: 0 }),
{ promises: { open: async () => handle } }
);
const opened = await sessions.open(40, 'huge', 0);
assert.strictEqual(opened.size, size);
const begin = size - 8192;
const result = await sessions.read(40, opened.sessionId, begin, begin + 4096);
assert.strictEqual(result.length, 4096);
assert.strictEqual(result[0], begin % 251);
assert.strictEqual(result[4095], (begin + 4095) % 251);
await sessions.closeSender(40);
assert.strictEqual(closed, true);
});
test('发送者销毁与会话创建竞态不会遗留文件句柄', async () => {
delete require.cache[modulePath];
const sessions = require(modulePath);
const stat = { size: 4096, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
let releaseOpen;
let closed = false;
sessions.init(
() => ({ abs: 'delayed.pdf', format: 'pdf', fileIndex: 0 }),
{
promises: {
open: () => new Promise((resolve) => {
releaseOpen = () => resolve({
stat: async () => stat,
read: async () => ({ bytesRead: 0 }),
close: async () => { closed = true; }
});
})
}
}
);
const opening = sessions.open(50, 'entry', 0);
while (!releaseOpen) await new Promise((resolve) => setImmediate(resolve));
await sessions.closeSender(50);
releaseOpen();
await assert.rejects(opening, /窗口已关闭/);
assert.strictEqual(closed, true);
assert.strictEqual(sessions.status().sessions, 0);
});
test('范围读取完成后再次校验文件签名', async () => {
delete require.cache[modulePath];
const sessions = require(modulePath);
let changed = false;
let closed = false;
const handle = {
stat: async () => ({
size: 4096,
mtimeMs: changed ? 2 : 1,
ctimeMs: 1,
isFile: () => true
}),
read: async (buffer, offset, length) => {
buffer.fill(1, offset, offset + length);
changed = true;
return { bytesRead: length, buffer };
},
close: async () => { closed = true; }
};
sessions.init(
() => ({ abs: 'changing.pdf', format: 'pdf', fileIndex: 0 }),
{ promises: { open: async () => handle } }
);
const opened = await sessions.open(60, 'entry', 0);
await assert.rejects(
() => sessions.read(60, opened.sessionId, 0, 1024),
/发生变化/
);
while (!closed) await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(sessions.status().sessions, 0);
});
+100
View File
@@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert');
const EventEmitter = require('node:events');
const Module = require('node:module');
test('阅读器全局只创建一个窗口,新书与删除请求路由到标签事件', () => {
const instances = [];
let webContentsId = 0;
class FakeWindow extends EventEmitter {
constructor(options) {
super();
this.options = options;
this.destroyed = false;
this.focused = 0;
this.webContents = new EventEmitter();
this.webContents.id = ++webContentsId;
this.webContents.loading = true;
this.webContents.sent = [];
this.webContents.setWindowOpenHandler = (handler) => { this.webContents.windowOpenHandler = handler; };
this.webContents.isLoadingMainFrame = () => this.webContents.loading;
this.webContents.send = (channel, payload) => this.webContents.sent.push([channel, payload]);
instances.push(this);
}
loadFile(file, options) {
this.loaded = { file, options };
}
isDestroyed() { return this.destroyed; }
isMinimized() { return false; }
focus() { this.focused += 1; }
close() {
const event = { prevented: false, preventDefault() { this.prevented = true; } };
this.emit('close', event);
if (!event.prevented) {
this.destroyed = true;
this.emit('closed');
}
}
destroy() {
this.destroyed = true;
this.emit('closed');
}
}
const originalLoad = Module._load;
Module._load = function mock(request, parent, isMain) {
if (request === 'electron') return { BrowserWindow: FakeWindow };
return originalLoad.call(this, request, parent, isMain);
};
const modulePath = require.resolve('../reader/window.js');
delete require.cache[modulePath];
let windows;
try {
windows = require(modulePath);
} finally {
Module._load = originalLoad;
}
const firstLocator = { kind: 'pdf', page: 4 };
const secondLocator = { kind: 'epub', chapter: 2, offset: 180 };
const first = windows.open('book-a', 'C:\\app', 0, firstLocator);
const second = windows.open('book-b', 'C:\\app', 1, secondLocator);
assert.strictEqual(first, second);
assert.strictEqual(instances.length, 1);
assert.deepStrictEqual(first.loaded.options.query, {
entryId: 'book-a',
fileIndex: '0',
locator: JSON.stringify(firstLocator)
});
assert.deepStrictEqual(first.webContents.sent, [], '加载完成前不应丢失事件或过早发送');
assert.deepStrictEqual(first.webContents.windowOpenHandler(), { action: 'deny' });
const navigation = { prevented: false, preventDefault() { this.prevented = true; } };
first.webContents.emit('will-navigate', navigation, 'https://untrusted.example/');
assert.strictEqual(navigation.prevented, true);
first.webContents.loading = false;
assert.strictEqual(windows.markReady(first.webContents), true);
assert.strictEqual(windows.isReady(first), true);
assert.deepStrictEqual(first.webContents.sent[0], [
'reader:openEntry',
{ entryId: 'book-b', fileIndex: 1, locator: secondLocator }
]);
windows.closeFor('book-a');
assert.deepStrictEqual(first.webContents.sent[1], ['reader:closeEntry', 'book-a']);
windows.purgeFor('book-b', 'purge-1');
assert.deepStrictEqual(first.webContents.sent[2], [
'reader:purgeEntry',
{ entryId: 'book-b', requestId: 'purge-1' }
]);
assert.strictEqual(first.destroyed, false, '删除一个条目不应关闭整个阅读器窗口');
assert.strictEqual(windows.fromWebContents(first.webContents), 'reader');
assert.deepStrictEqual(windows.all(), [first]);
first.close();
assert.strictEqual(first.destroyed, false, '关闭前应等待渲染器排空保存队列');
assert.deepStrictEqual(first.webContents.sent[3], ['reader:prepareClose', null]);
assert.strictEqual(windows.shutdownReady(first.webContents), true);
assert.strictEqual(first.destroyed, true);
});
+979
View File
@@ -0,0 +1,979 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
const storePath = require.resolve('../reader/store.js');
const cfgPath = require.resolve('../reader/ai-config.js');
const dirs = [];
function tmp() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-'));
dirs.push(d);
return d;
}
test.after(() => {
for (const d of dirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
function freshStore() {
delete require.cache[storePath];
const s = require(storePath);
s.init(tmp());
return s;
}
function storeAt(dir) {
delete require.cache[storePath];
const s = require(storePath);
s.init(dir);
return s;
}
function fakeStorage(available = true) {
return {
isEncryptionAvailable: () => available,
encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
decryptString: (b) => {
const s = b.toString();
if (!s.startsWith('ENC:')) throw new Error('bad');
return Buffer.from(s.slice(4), 'base64').toString('utf8');
}
};
}
// --- reader/store ---
test('阅读进度可存取,百分比被夹在 0..1', () => {
const s = freshStore();
assert.strictEqual(s.getLastReadAt('e1'), 0);
s.setProgress('e1', { kind: 'pdf', page: 5 }, 2.5);
const st = s.getState('e1');
assert.strictEqual(st.progress.locator.page, 5);
assert.strictEqual(st.progress.percent, 1);
assert.strictEqual(s.getLastReadAt('e1'), st.progress.at);
s.setProgress('e1', { kind: 'pdf', page: 1 }, -3);
assert.strictEqual(s.getState('e1').progress.percent, 0);
});
test('书签与笔记的增删互不干扰', () => {
const s = freshStore();
const b = s.addBookmark('e1', { locator: { kind: 'epub', chapter: 2, offset: 10 }, label: '第 3 章' });
const n = s.addNote('e1', { locator: { kind: 'epub', chapter: 2 }, text: '这是笔记', kind: 'ai' });
let st = s.getState('e1');
assert.strictEqual(st.bookmarks.length, 1);
assert.strictEqual(st.notes.length, 1);
assert.strictEqual(st.notes[0].kind, 'ai');
s.removeBookmark('e1', b.id);
st = s.getState('e1');
assert.strictEqual(st.bookmarks.length, 0);
assert.strictEqual(st.notes.length, 1, '删书签不该动笔记');
assert.strictEqual(s.removeNote('e1', n.id), true);
});
test('缺少定位信息的书签被拒绝', () => {
const s = freshStore();
assert.throws(() => s.addBookmark('e1', { label: 'x' }), /定位/);
assert.throws(() => s.addNote('e1', { text: ' ' }), /内容为空/);
});
test('不同条目的阅读数据互相隔离', () => {
const s = freshStore();
s.addBookmark('a', { locator: { kind: 'pdf', page: 1 } });
s.addBookmark('b', { locator: { kind: 'pdf', page: 2 } });
assert.strictEqual(s.getState('a').bookmarks.length, 1);
assert.strictEqual(s.getState('b').bookmarks[0].locator.page, 2);
s.forget('a');
assert.strictEqual(s.getState('a').bookmarks.length, 0);
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
});
test('getState 返回副本,外部改动不污染存储', () => {
const s = freshStore();
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
const st = s.getState('e1');
st.bookmarks.push({ id: 'fake' });
assert.strictEqual(s.getState('e1').bookmarks.length, 1);
});
test('损坏的 reader.json 不会导致崩溃', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'reader.json'), '{ 这不是 json');
delete require.cache[storePath];
const s = require(storePath);
s.init(d);
assert.deepStrictEqual(s.getState('x').bookmarks, []);
s.setProgress('x', { kind: 'pdf', page: 1 }, 0.1);
assert.ok(s.getState('x').progress);
assert.strictEqual(
fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
true,
'损坏原文件应被隔离保留'
);
assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(d, 'reader.json'), 'utf8')));
});
test('同一条目的进度、书签和笔记按文档标识隔离', () => {
const s = freshStore();
s.setProgress('e1', 'doc-a', { kind: 'pdf', page: 2 }, 0.2);
s.setProgress('e1', 'doc-b', { kind: 'epub', chapter: 3, offset: 20 }, 0.7);
s.addBookmark('e1', {
documentKey: 'doc-a',
locator: { kind: 'pdf', page: 2 },
label: 'PDF'
});
s.addBookmark('e1', {
documentKey: 'doc-b',
locator: { kind: 'epub', chapter: 3, offset: 20 },
label: 'EPUB'
});
s.addNote('e1', { documentKey: 'doc-a', text: 'PDF 笔记' });
s.addNote('e1', { documentKey: 'doc-b', text: 'EPUB 笔记' });
assert.strictEqual(s.getState('e1', 'doc-a').progress.locator.page, 2);
assert.strictEqual(s.getState('e1', 'doc-b').progress.locator.chapter, 3);
assert.deepStrictEqual(s.getState('e1', 'doc-a').bookmarks.map((item) => item.label), ['PDF']);
assert.deepStrictEqual(s.getState('e1', 'doc-b').notes.map((item) => item.text), ['EPUB 笔记']);
});
test('主文件损坏时隔离原件并从有效备份恢复阅读资料', () => {
const d = tmp();
const file = path.join(d, 'reader.json');
fs.writeFileSync(file, '{ broken');
fs.writeFileSync(`${file}.bak`, JSON.stringify({
version: 2,
collections: [],
entries: {
restored: {
progress: null,
bookmarks: [],
notes: [{ id: 'note-1', text: '已恢复', kind: 'user', at: 10 }]
}
}
}));
const s = storeAt(d);
assert.strictEqual(s.getState('restored').notes[0].text, '已恢复');
assert.strictEqual(fs.existsSync(file), true);
assert.strictEqual(
fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
true
);
});
test('迁移会丢弃合法 JSON 中结构损坏的进度和书签', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'reader.json'), JSON.stringify({
version: 2,
collections: [],
entries: {
broken: {
progress: { locator: null, percent: 2 },
progressByDocument: {
bad: { locator: null },
good: { locator: { kind: 'pdf', page: 2 }, percent: 2 }
},
bookmarks: [
null,
{ id: 'bad', locator: null },
{ id: 'good', locator: { kind: 'pdf', page: 3 }, label: '有效书签' }
],
notes: []
}
}
}));
const s = storeAt(d);
assert.deepStrictEqual(s.getState('broken').bookmarks.map((item) => item.label), ['有效书签']);
assert.doesNotThrow(() => s.bindDocument('broken', 'doc-current'));
const state = s.getState('broken', 'good');
assert.strictEqual(state.progress.percent, 1);
});
test('首次绑定文档把旧版进度和书签安全迁移到该文档', () => {
const s = freshStore();
s.setProgress('legacy', { kind: 'pdf', page: 6 }, 0.5);
s.addBookmark('legacy', { locator: { kind: 'pdf', page: 6 }, label: '旧书签' });
assert.strictEqual(s.bindDocument('legacy', 'doc-key'), true);
const state = s.getState('legacy', 'doc-key');
assert.strictEqual(state.progress.locator.page, 6);
assert.strictEqual(state.bookmarks[0].documentKey, 'doc-key');
});
test('结构化笔记支持纯引用、来源、标签和上下文字段', () => {
const s = freshStore();
const note = s.addNote('e1', {
title: '重点',
quote: '只保存引用也可以',
context: '第二章',
source: 'selection',
documentKey: 'doc-1',
fileIndex: 2,
tags: ['方法', '方法', '研究'],
pinned: true,
locator: { kind: 'pdf', page: 3 }
});
assert.strictEqual(note.text, '');
assert.strictEqual(note.source, 'selection');
assert.strictEqual(note.kind, 'user', '保留旧渲染器使用的兼容别名');
assert.deepStrictEqual(note.tags, ['方法', '研究']);
assert.strictEqual(note.createdAt, note.updatedAt);
note.tags.push('外部修改');
note.locator.page = 99;
const stored = s.getState('e1').notes[0];
assert.deepStrictEqual(stored.tags, ['方法', '研究']);
assert.strictEqual(stored.locator.page, 3);
});
test('富文本笔记保存格式、纯文本索引和内嵌图片', () => {
const s = freshStore();
const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
const richContent = {
version: 1,
blocks: [
{
type: 'text',
style: 'heading1',
runs: [{ text: '富文本标题', bold: true }]
},
{
type: 'text',
style: 'paragraph',
runs: [{ text: '正文' }, { text: '强调', italic: true, underline: true }]
},
{ type: 'image', dataUrl: image, alt: '示例图片' }
]
};
const note = s.addNote('e1', { richContent, source: 'manual' });
assert.strictEqual(note.noteType, 'reading');
assert.strictEqual(note.text, '富文本标题\n正文强调');
assert.deepStrictEqual(note.richContent, {
version: 2,
ops: [
{ insert: '富文本标题', attributes: { bold: true } },
{ insert: '\n', attributes: { header: 1 } },
{ insert: '正文' },
{ insert: '强调', attributes: { italic: true, underline: true } },
{ insert: '\n' },
{ insert: { image } }
]
});
assert.strictEqual(s.listNotes({ query: '正文强调' })[0].id, note.id);
const stored = s.getState('e1').notes[0];
assert.strictEqual(stored.richContent.version, 2);
richContent.blocks[0].runs[0].text = '外部污染';
assert.strictEqual(s.getState('e1').notes[0].text, '富文本标题\n正文强调');
const imageOnly = s.addNote('e1', {
richContent: { version: 1, blocks: [{ type: 'image', dataUrl: image, alt: '' }] }
});
assert.strictEqual(imageOnly.text, '');
assert.strictEqual(imageOnly.richContent.ops[0].insert.image, image);
});
test('富文本笔记拒绝主动内容、远程图片和超限结构', () => {
const s = freshStore();
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: [{ type: 'image', dataUrl: 'https://example.com/x.png', alt: '' }]
}
}),
/图片格式无效/
);
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: [{ type: 'html', html: '<script>alert(1)</script>' }]
}
}),
/段落无效/
);
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: Array.from({ length: 501 }, () => ({
type: 'text', style: 'paragraph', runs: [{ text: 'x' }]
}))
}
}),
/内容过多/
);
});
test('Quill Delta 仅保留受支持格式并拒绝主动嵌入', () => {
const s = freshStore();
const note = s.addNote('e1', {
richContent: {
version: 2,
ops: [
{ insert: '一级标题' },
{ insert: '\n', attributes: { header: 1 } },
{ insert: '正文', attributes: { bold: true, italic: true } },
{ insert: '\n', attributes: { list: 'bullet' } },
{ insert: '代码' },
{ insert: '\n', attributes: { 'code-block': 'plain' } }
]
}
});
assert.strictEqual(note.text, '一级标题\n正文\n代码');
assert.strictEqual(note.richContent.version, 2);
assert.deepStrictEqual(
note.richContent.ops.at(-1),
{ insert: '\n', attributes: { 'code-block': 'plain' } }
);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ insert: '外链', attributes: { link: 'https://example.com' } }]
}
}), /不支持的格式/);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ insert: { video: 'https://example.com/video' } }]
}
}), /嵌入内容无效/);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ retain: 1, attributes: { bold: true } }]
}
}), /操作无效/);
});
test('画布笔记保存分页画布、PDF 底版并支持类型筛选和文本搜索', () => {
const s = freshStore();
const assetId = `pdf_${'a'.repeat(64)}`;
const canvasContent = {
version: 2,
flow: {
version: 1,
ops: [
{ insert: '全局文本关键词', attributes: { bold: true } },
{ insert: '\n' },
{ insert: { canvasPageBreak: 'pg_two' } }
]
},
pages: [
{
id: 'pg_one',
width: 794,
height: 1123,
background: { type: 'template', template: 'grid' },
objects: [{
type: 'IText',
canvasKind: 'text',
text: '画布关键词',
left: 10,
top: 20,
fill: '#222222',
fontSize: 18
}]
},
{
id: 'pg_two',
width: 612,
height: 792,
background: { type: 'pdf', assetId, page: 2 },
objects: []
}
]
};
const note = s.addStandaloneNote({
noteType: 'canvas',
canvasContent
});
assert.strictEqual(note.noteType, 'canvas');
assert.strictEqual(note.text, '全局文本关键词\n画布关键词');
assert.deepStrictEqual(note.canvasContent, canvasContent);
assert.deepStrictEqual(s.noteAssetIds(), [assetId]);
assert.strictEqual(s.listNotes({ query: '画布关键词' })[0].id, note.id);
assert.strictEqual(s.listNotes({ query: '全局文本关键词' })[0].id, note.id);
assert.deepStrictEqual(s.listNotes({ noteType: 'canvas' }).map((item) => item.id), [note.id]);
assert.deepStrictEqual(s.listNotes({ noteType: 'reading' }), []);
assert.strictEqual(s.removeNote(s.STANDALONE_ENTRY_ID, note.id), true);
assert.deepStrictEqual(s.noteAssetIds(), []);
});
test('读书笔记和画布笔记创建后保持独立且类型不可更改', () => {
const s = freshStore();
const canvasContent = {
version: 1,
pages: [{
id: 'pg_type',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}]
};
assert.throws(() => s.addNote('e1', {
noteType: 'unknown',
text: '正文'
}), /笔记类型无效/);
assert.throws(() => s.addNote('e1', {
noteType: 'reading',
richContent: { version: 2, ops: [{ insert: '正文\n' }] },
canvasContent
}), /读书笔记不能包含画布内容/);
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
richContent: { version: 2, ops: [{ insert: '正文\n' }] },
canvasContent
}), /画布笔记不能包含富文本内容/);
const reading = s.addNote('e1', { noteType: 'reading', text: '正文' });
assert.throws(() => s.updateNote('e1', reading.id, { noteType: 'canvas' }), /不能更改/);
const canvas = s.addNote('e1', { noteType: 'canvas', canvasContent });
assert.throws(() => s.updateNote('e1', canvas.id, {
richContent: { version: 2, ops: [{ insert: '正文\n' }] }
}), /不能包含富文本内容/);
});
test('v4 混合笔记迁移为画布笔记并保留两类旧内容', () => {
const root = tmp();
fs.writeFileSync(path.join(root, 'reader.json'), JSON.stringify({
version: 4,
collections: [],
entries: {
e1: {
notes: [{
id: 'nt_legacy_mixed',
title: '旧混合笔记',
richContent: { version: 2, ops: [{ insert: '旧正文\n' }] },
canvasContent: {
version: 1,
pages: [{
id: 'pg_legacy',
width: 794,
height: 1123,
background: { type: 'template', template: 'grid' },
objects: []
}]
},
source: 'manual',
tags: [],
createdAt: 1,
updatedAt: 1
}, {
id: 'nt_legacy_blank_canvas',
richContent: { version: 2, ops: [{ insert: '仅正文\n' }] },
canvasContent: {
version: 1,
pages: [{
id: 'pg_legacy_blank',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}]
},
source: 'manual',
tags: [],
createdAt: 2,
updatedAt: 2
}]
}
}
}));
const s = storeAt(root);
const notes = s.getState('e1').notes;
const note = notes.find((item) => item.id === 'nt_legacy_mixed');
assert.strictEqual(note.noteType, 'canvas');
assert.strictEqual(note.richContent.ops[0].insert, '旧正文\n');
assert.strictEqual(note.canvasContent.version, 2);
assert.strictEqual(note.canvasContent.pages[0].background.template, 'grid');
const updated = s.updateNote('e1', note.id, {
noteType: 'canvas',
canvasContent: note.canvasContent
});
assert.strictEqual(updated.richContent.ops[0].insert, '旧正文\n');
const blankCanvas = notes.find((item) => item.id === 'nt_legacy_blank_canvas');
assert.strictEqual(blankCanvas.noteType, 'reading');
assert.strictEqual(blankCanvas.richContent.ops[0].insert, '仅正文\n');
assert.strictEqual(blankCanvas.canvasContent.pages[0].background.template, 'blank');
});
test('画布笔记拒绝未知对象、主动属性、远程图片和非法 PDF 引用', () => {
const s = freshStore();
const page = (object, background = { type: 'template', template: 'blank' }) => ({
version: 1,
pages: [{
id: 'pg_safe',
width: 794,
height: 1123,
background,
objects: object ? [object] : []
}]
});
assert.throws(() => s.addNote('e1', {
canvasContent: page({ type: 'Circle', canvasKind: 'circle' })
}), /对象类型无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0], ['L', 1, 1]],
clipPath: {}
})
}), /不支持的属性/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0]],
arbitraryPayload: { type: 'Image' }
})
}), /不支持的属性/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0]],
scaleX: 1000
})
}), /缩放无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Image',
canvasKind: 'image',
src: 'https://example.com/image.png'
})
}), /图片格式无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page(null, { type: 'pdf', assetId: '../outside', page: 1 })
}), /PDF 底版资源无效/);
const flowPage = {
version: 2,
pages: [{
id: 'pg_flow',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}, {
id: 'pg_flow_two',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: [],
flowAuto: true
}]
};
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: { version: 1, ops: [{ insert: { canvasPageBreak: '../outside' } }] }
}
}), /分页符无效/);
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: { version: 1, ops: [{ insert: { image: 'https://example.com/a.png' } }] }
}
}), /嵌入内容无效/);
const flowNote = s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: {
version: 1,
ops: [
{ insert: '跨页正文\n', attributes: { header: 1 } },
{ insert: { canvasPageBreak: 'pg_flow_two' } },
{ insert: '第二页正文\n' }
]
}
}
});
assert.strictEqual(flowNote.text, '跨页正文\n第二页正文');
assert.strictEqual(flowNote.canvasContent.pages[1].flowAuto, true);
});
test('笔记可更新且必需保留正文或引用', () => {
const s = freshStore();
const note = s.addNote('e1', { text: '原文', source: 'manual' });
const updated = s.updateNote('e1', note.id, {
text: '',
quote: '新引用',
source: 'ai',
aiTask: '总结',
tags: ['AI'],
pinned: true
});
assert.strictEqual(updated.quote, '新引用');
assert.strictEqual(updated.source, 'ai');
assert.strictEqual(updated.kind, 'ai');
assert.strictEqual(updated.aiTask, '总结');
assert.strictEqual(updated.at, updated.updatedAt);
assert.throws(
() => s.updateNote('e1', note.id, { quote: '', text: '' }),
/内容为空/
);
assert.strictEqual(s.getState('e1').notes[0].quote, '新引用', '失败更新必须回滚');
assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
});
test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
const s = freshStore();
const collection = s.addCollection({ name: 'Research' });
assert.throws(() => s.addCollection({ name: ' research ' }), /已存在/);
const note = s.addNote('e1', { text: '归档笔记', collectionId: collection.id });
assert.strictEqual(s.listCollections()[0].name, 'Research');
assert.strictEqual(s.updateCollection(collection.id, { name: 'Inbox' }).name, 'Inbox');
assert.strictEqual(s.removeCollection(collection.id), true);
assert.strictEqual(s.listCollections().length, 0);
assert.strictEqual(s.getState('e1').notes[0].id, note.id);
assert.strictEqual(s.getState('e1').notes[0].collectionId, null);
assert.strictEqual(s.listNotes({ collectionId: null }).length, 1);
});
test('聚合笔记按置顶与更新时间排序并支持全部筛选', () => {
const s = freshStore();
const work = s.addCollection('Work');
s.setBookSnapshot('book-a', { title: 'Alpha Handbook', authors: ['A. One'] });
s.setBookSnapshot('book-b', { title: 'Beta Notes', authors: ['B. Two'] });
const first = s.addNote('book-a', {
title: 'Ordinary',
text: 'needle in text',
source: 'manual',
tags: ['Blue'],
collectionId: work.id
});
const pinned = s.addNote('book-b', {
quote: 'selected passage',
source: 'selection',
tags: ['Green'],
pinned: true
});
s.updateNote('book-a', first.id, { context: 'changed' });
const all = s.listNotes();
assert.deepStrictEqual(all.map((note) => note.id), [pinned.id, first.id]);
assert.strictEqual(all[0].entryId, 'book-b');
assert.strictEqual(all[0].bookSnapshot.title, 'Beta Notes');
assert.deepStrictEqual(s.listNotes({ entryId: 'book-a' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ collectionId: work.id }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ source: 'selection' }).map((n) => n.id), [pinned.id]);
assert.deepStrictEqual(s.listNotes({ tag: 'blue' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ query: 'alpha hand' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ query: 'NEEDLE' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.getNoteCounts(), { 'book-a': 1, 'book-b': 1 });
all[0].bookSnapshot.title = '污染';
all[0].tags.push('污染');
assert.strictEqual(s.listNotes()[0].bookSnapshot.title, 'Beta Notes');
assert.deepStrictEqual(s.listNotes()[0].tags, ['Green']);
});
test('无关联笔记独立持久化且不计入书库卡片笔记数', () => {
const s = freshStore();
const note = s.addStandaloneNote({
title: '独立想法',
text: '不关联任何书籍',
source: 'manual',
tags: ['随想']
});
const listed = s.listNotes().find((item) => item.id === note.id);
assert.strictEqual(listed.entryId, s.STANDALONE_ENTRY_ID);
assert.strictEqual(listed.associated, false);
assert.strictEqual(listed.bookSnapshot, null);
assert.deepStrictEqual(s.getNoteCounts(), {});
assert.strictEqual(
s.updateNote(listed.entryId, note.id, { text: '已编辑' }).text,
'已编辑'
);
assert.strictEqual(s.removeNote(listed.entryId, note.id), true);
});
test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
const d = tmp();
const file = path.join(d, 'reader.json');
const legacy = {
entries: {
e1: {
progress: { locator: { page: 8 }, percent: 0.4, at: 10 },
bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
notes: [{
id: 'nt_old',
text: '旧笔记',
quote: '旧引用',
kind: 'ai',
at: 123,
locator: { page: 8 }
}]
}
}
};
fs.writeFileSync(file, JSON.stringify(legacy), 'utf8');
let s = storeAt(d);
const state = s.getState('e1');
assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
assert.strictEqual(state.notes[0].id, 'nt_old');
assert.strictEqual(state.notes[0].source, 'ai');
assert.strictEqual(state.notes[0].createdAt, 123);
assert.strictEqual(state.notes[0].updatedAt, 123);
assert.strictEqual(state.notes[0].collectionId, null);
const migrated = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.strictEqual(migrated.version, 6);
assert.deepStrictEqual(migrated.collections, []);
const bytes = fs.readFileSync(file, 'utf8');
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes.length, 1);
assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes, 'v6 再加载不应重复迁移');
});
test('字段限制、来源校验和安全 ID 校验生效', () => {
const s = freshStore();
assert.throws(() => s.getState('../reader'), /ID无效/);
assert.throws(
() => s.setProgress('e1', 'toString', { kind: 'pdf', page: 1 }, 0.1),
/文档标识无效/
);
assert.throws(() => s.addNote('e1', { text: 'x', source: 'robot' }), /来源无效/);
assert.throws(
() => s.addNote('e1', { text: 'x', collectionId: 'col_missing' }),
/不存在/
);
const note = s.addNote('e1', {
text: 'x'.repeat(25000),
tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
});
assert.strictEqual(note.text.length, 20000);
assert.strictEqual(note.tags.length, 30);
});
test('写盘失败时内存和磁盘状态都回滚', () => {
const d = tmp();
let s = storeAt(d);
s.addNote('e1', { text: '已保存' });
const file = path.join(d, 'reader.json');
const before = fs.readFileSync(file, 'utf8');
const originalRename = fs.renameSync;
fs.renameSync = (from, to) => {
if (from === `${file}.tmp` && to === file) throw new Error('模拟写盘失败');
return originalRename(from, to);
};
try {
assert.throws(() => s.addNote('e1', { text: '不应保存' }), /模拟写盘失败/);
} finally {
fs.renameSync = originalRename;
}
assert.strictEqual(s.getState('e1').notes.length, 1);
assert.strictEqual(fs.readFileSync(file, 'utf8'), before);
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes.length, 1);
});
// --- reader/ai-config ---
function freshCfg(storage = fakeStorage()) {
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(tmp(), storage);
return c;
}
test('AI Key 加密落盘,磁盘无明文', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
c.save({ baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', apiKey: 'sk-SECRET-123' });
for (const f of fs.readdirSync(d)) {
const content = fs.readFileSync(path.join(d, f)).toString();
assert.ok(!content.includes('sk-SECRET-123'), `${f} 出现明文 Key`);
}
assert.strictEqual(c.get().apiKey, 'sk-SECRET-123');
assert.strictEqual(c.status().hasKey, true);
});
test('只改模型时不传 apiKey,不会清掉已存的 Key', () => {
const c = freshCfg();
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiKey: 'sk-keep',
vision: true
});
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' });
assert.strictEqual(c.get().apiKey, 'sk-keep');
assert.strictEqual(c.get().model, 'gpt-4o');
assert.strictEqual(c.status().protocol, 'anthropic');
assert.strictEqual(c.status().vision, true);
});
test('AI 接口类型显式持久化,旧配置默认使用 Chat Completions', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
baseUrl: 'https://api.openai.com/v1',
model: 'legacy'
}));
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
assert.strictEqual(c.status().protocol, 'chat-completions');
c.save({
protocol: 'openai-responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1'
});
assert.strictEqual(c.status().protocol, 'openai-responses');
assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).protocol, 'openai-responses');
assert.throws(
() => c.save({ protocol: 'unknown', baseUrl: 'https://api.openai.com/v1', model: 'm' }),
/接口类型/
);
});
test('AI 状态区分模型已配置、缺少 Key 与 Key 无法读取', () => {
const d = tmp();
delete require.cache[cfgPath];
let c = require(cfgPath);
c.init(d, fakeStorage());
let status = c.status();
assert.strictEqual(status.modelConfigured, false);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'missing');
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-sonnet',
vision: true
});
status = c.status();
assert.strictEqual(status.modelConfigured, true);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'missing');
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-sonnet',
apiKey: 'sk-anthropic'
});
assert.strictEqual(c.status().ready, true);
delete require.cache[cfgPath];
c = require(cfgPath);
c.init(d, {
isEncryptionAvailable: () => true,
decryptString: () => { throw new Error('cannot decrypt'); }
});
status = c.status();
assert.strictEqual(status.modelConfigured, true);
assert.strictEqual(status.hasKey, false);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'unreadable');
});
test('图像输入能力必须显式配置并持久化', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm' });
assert.strictEqual(c.status().vision, false);
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', vision: true });
assert.strictEqual(c.status().vision, true);
assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).vision, true);
});
test('图像输入能力拒绝配置文件中的非布尔真值', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
baseUrl: 'https://api.openai.com/v1',
model: 'm',
vision: 'true'
}));
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
assert.strictEqual(c.status().vision, false);
assert.strictEqual(c.get().vision, false);
});
test('显式传空字符串才清除 Key', () => {
const c = freshCfg();
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: 'sk-x' });
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: '' });
assert.strictEqual(c.status().hasKey, false);
});
test('切换 AI 接口类型或服务来源时不会复用旧 API Key', () => {
const c = freshCfg();
c.save({
protocol: 'chat-completions',
baseUrl: 'https://api.openai.com/v1',
model: 'm',
apiKey: 'sk-openai'
});
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude'
});
assert.strictEqual(c.status().hasKey, false);
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude',
apiKey: 'sk-anthropic'
});
c.save({
protocol: 'anthropic',
baseUrl: 'https://proxy.example.com/v1',
model: 'claude'
});
assert.strictEqual(c.status().hasKey, false);
});
test('非法接口地址被拒绝', () => {
const c = freshCfg();
assert.throws(() => c.save({ baseUrl: 'ftp://x/v1', model: 'm' }), /http/);
assert.throws(() => c.save({ baseUrl: '', model: 'm' }), /不能为空/);
assert.throws(() => c.save({ baseUrl: 'https://a/v1', model: '' }), /模型/);
assert.throws(() => c.save({ baseUrl: 'https://a/v1#fragment', model: 'm' }), /片段标识/);
});
test('本地端点识别为无需 Key', () => {
const c = freshCfg();
c.save({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen' });
assert.strictEqual(c.status().isLocal, true);
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt' });
assert.strictEqual(c.status().isLocal, false);
});
test('加密不可用时不落盘 Key', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage(false));
c.save({ baseUrl: 'https://a.com/v1', model: 'm', apiKey: 'sk-plain' });
for (const f of fs.readdirSync(d)) {
assert.ok(!fs.readFileSync(path.join(d, f)).toString().includes('sk-plain'), `${f} 落了明文`);
}
assert.strictEqual(c.get().apiKey, 'sk-plain');
assert.strictEqual(c.status().persistent, false);
});
test('baseUrl 末尾斜杠被规范化', () => {
const c = freshCfg();
c.save({ baseUrl: 'https://api.openai.com/v1///', model: 'm' });
assert.strictEqual(c.status().baseUrl, 'https://api.openai.com/v1');
});
+470
View File
@@ -0,0 +1,470 @@
const test = require('node:test');
const assert = require('node:assert');
const h = require('./helpers');
h.installFetchStub();
const sources = require('../sources');
test('注册表:每个源都实现完整接口', () => {
const list = sources.listSources();
assert.ok(list.length >= 12);
for (const s of list) {
const m = sources.getSource(s.id);
for (const fn of ['list', 'search', 'detail', 'download']) {
assert.strictEqual(typeof m[fn], 'function', `${s.id}.${fn} 缺失`);
}
assert.ok(s.name, `${s.id} 缺 name`);
}
});
test('注册表:未知 id 抛错', () => {
assert.throws(() => sources.getSource('nope'), /未知数据源/);
});
// --- PMC ---
test('pmc: esearch 响应异常时给出可读错误而不是 TypeError', async () => {
h.setHandler(h.routes([['esearch.fcgi', { body: { error: 'down' } }]]));
await assert.rejects(sources.getSource('pmc').search('x', 1), /无法识别的检索结果/);
});
test('pmc: postId 不重复拼 PMC 前缀', async () => {
const seen = [];
h.setHandler(h.routes([
['esummary.fcgi', (u) => { seen.push(u); return h.makeResponse({ body: { result: { 123: { uid: '123', title: 'T', authors: [] } } } }); }]
]));
const d = await sources.getSource('pmc').detail('PMC123');
assert.strictEqual(d.postId, '123');
assert.strictEqual(d.url, 'https://www.ncbi.nlm.nih.gov/pmc/articles/PMC123/');
assert.ok(!d.url.includes('PMCPMC'), 'URL 里出现了 PMCPMC');
assert.ok(seen[0].includes('id=123'), 'esummary 用了带前缀的 id');
});
test('pmc: 畸形 id 不会把正则搞崩', async () => {
h.setHandler(() => h.makeResponse({ body: '' }));
await assert.rejects(sources.getSource('pmc').download('12(3'), /无效的 PMC ID/);
await assert.rejects(sources.getSource('pmc').download('.*'), /无效的 PMC ID/);
});
test('pmc: 列表按 uid 归一化 postId', async () => {
h.setHandler(h.routes([
['esearch.fcgi', { body: { esearchresult: { count: '40', idlist: ['777'] } } }],
['esummary.fcgi', { body: { result: { 777: { uid: '777', title: 'A', authors: [{ name: 'X' }], pubdate: '2020 Jan' } } } }]
]));
const r = await sources.getSource('pmc').search('kw', 1);
assert.strictEqual(r.items[0].postId, '777');
assert.strictEqual(r.maxPage, 2);
});
// --- DOAJ ---
test('doaj: postId 不被二次编码', async () => {
const urls = [];
h.setHandler(h.routes([
['search/articles', { body: { total: 1, results: [{ id: '10.1234/abc', bibjson: { title: 'T', author: [], link: [] } }] } }],
['api/v2/articles/', (u) => { urls.push(u); return h.makeResponse({ body: { bibjson: { title: 'T', author: [], link: [] } } }); }]
]));
const doaj = sources.getSource('doaj');
const r = await doaj.search('kw', 1);
assert.strictEqual(r.items[0].postId, '10.1234/abc', 'postId 不该预先编码');
await doaj.detail(r.items[0].postId);
assert.ok(urls[0].includes('10.1234%2Fabc'), '详情 URL 编码错误: ' + urls[0]);
assert.ok(!urls[0].includes('%252F'), '出现二次编码: ' + urls[0]);
});
test('doaj: DOAJ 页链接正确编码', async () => {
h.setHandler(h.routes([['api/v2/articles/', { body: { bibjson: { link: [] } } }]]));
const d = await sources.getSource('doaj').download('10.1234/abc');
const page = d.links.find((l) => l.name === 'DOAJ 页');
assert.strictEqual(page.url, 'https://doaj.org/article/10.1234%2Fabc');
});
// --- Sci-Hub ---
test('scihub: 跳过广告 iframe 找到真正的 PDF', async () => {
h.setHandler(() => h.makeResponse({
body: '<iframe src="https://ads.example/b.html"></iframe><iframe src="/downloads/2020/x.pdf"></iframe>'
}));
const d = await sources.getSource('scihub').download('10.1038/nature12373');
assert.strictEqual(d.files[0].link, 'https://sci-hub.se/downloads/2020/x.pdf');
});
test('scihub: DOI 不存在时只请求一个镜像', async () => {
const hits = [];
h.setHandler((u) => { hits.push(u); return h.makeResponse({ body: 'article not found' }); });
await assert.rejects(sources.getSource('scihub').detail('10.1/x'), /不存在/);
assert.strictEqual(hits.length, 1, `不该轮询全部镜像,实际请求 ${hits.length}`);
});
test('scihub: 非 DOI 关键词返回空而不抛错', async () => {
const r = await sources.getSource('scihub').search('随便搜点什么', 1);
assert.deepStrictEqual(r.items, []);
assert.ok(r.note);
});
// --- LibGen ---
test('libgen: maxPage 只看分页控件,忽略页脚干扰链接', async () => {
const card = '<div class="resItemBox" data-book_id="1"><h3 itemprop="name"><a>Book One</a></h3></div>';
const footer = '<div class="footer"><a href="/x?page=999">junk</a></div>';
const pager = '<div class="paginator"><a href="?page=2">2</a><a href="?page=3">3</a></div>';
h.setHandler(() => h.makeResponse({ body: card + footer + pager }));
const r = await sources.getSource('libgen').search('godel escher', 1);
assert.strictEqual(r.items.length, 1);
assert.strictEqual(r.maxPage, 3, '页脚的 page=999 被误算进来了');
});
test('libgen: 无分页控件时不虚报页数', async () => {
const card = '<div class="resItemBox" data-book_id="9"><h3 itemprop="name"><a>Solo</a></h3></div>';
h.setHandler(() => h.makeResponse({ body: card + '<a href="/y?page=42">junk</a>' }));
const r = await sources.getSource('libgen').search('solo book', 1);
assert.strictEqual(r.maxPage, 1);
});
test('libgen: JSON-LD image 为对象时详情不崩溃', async () => {
const ld = JSON.stringify({ '@type': 'Book', name: 'B', image: { '@type': 'ImageObject', url: '/c.jpg' } });
h.setHandler(() => h.makeResponse({
body: `<script type="application/ld+json">${ld}</script><h1 itemprop="name">B</h1>`
}));
const d = await sources.getSource('libgen').detail('web:5');
assert.strictEqual(d.title, 'B');
assert.ok(/\/c\.jpg$/.test(d.cover), 'cover 解析失败: ' + d.cover);
});
test('libgen: 关键词过短直接返回提示', async () => {
const r = await sources.getSource('libgen').search('ab', 1);
assert.deepStrictEqual(r.items, []);
assert.ok(r.note);
});
// --- Standard Ebooks ---
test('standardebooks: author 为字符串时不丢作者', async () => {
h.setHandler(h.routes([['feeds/opds/all', {
body: {
publications: [{
metadata: { identifier: 'https://standardebooks.org/ebooks/jane-austen/emma', title: 'Emma', author: 'Jane Austen' },
images: []
}]
}
}]]));
const r = await sources.getSource('standardebooks').search('emma', 1);
assert.strictEqual(r.items[0].subtitle, 'Jane Austen');
});
test('standardebooks: author 混排对象与字符串', async () => {
h.setHandler(h.routes([['feeds/opds/all', {
body: {
publications: [{
metadata: { identifier: 'https://standardebooks.org/ebooks/a/b', title: 'T', author: [{ name: 'A' }, 'B'] },
images: []
}]
}
}]]));
const r = await sources.getSource('standardebooks').search('t', 1);
assert.strictEqual(r.items[0].subtitle, 'A, B');
});
test('standardebooks: 非法 slug 被拒绝', async () => {
await assert.rejects(sources.getSource('standardebooks').detail('../../etc/passwd'), /无效的/);
});
// --- Open Library ---
test('openlibrary: 详情解析作者姓名', async () => {
h.setHandler(h.routes([
[/works\/OL1W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/OL1A' } }], subjects: [] } }],
[/authors\/OL1A\.json/, { body: { name: 'Ursula Le Guin' } }]
]));
const d = await sources.getSource('openlibrary').detail('OL1W');
assert.deepStrictEqual(d.authors, ['Ursula Le Guin']);
});
test('openlibrary: 单个作者取不到不影响整体', async () => {
h.setHandler(h.routes([
[/works\/OL2W\.json/, { body: { title: 'W', authors: [{ author: { key: '/authors/BAD' } }, { author: { key: '/authors/OK' } }], subjects: [] } }],
// 404 不触发重试,避免这条用例白等两轮退避
[/authors\/BAD\.json/, { status: 404 }],
[/authors\/OK\.json/, { body: { name: 'Good' } }]
]));
const d = await sources.getSource('openlibrary').detail('OL2W');
assert.deepStrictEqual(d.authors, ['Good']);
});
test('openlibrary: 下载只给 archive.org 真实存在的文件', async () => {
h.setHandler(h.routes([
['editions.json', { body: { entries: [{ ocaid: 'someitem' }] } }],
['archive.org/metadata/', {
body: { files: [{ name: 'someitem.pdf', format: 'Text PDF' }, { name: 'thumb.jpg', format: 'JPEG' }] }
}]
]));
const d = await sources.getSource('openlibrary').download('OL3W');
assert.strictEqual(d.files.length, 1, '推了不存在的格式: ' + JSON.stringify(d.files));
assert.strictEqual(d.files[0].format, 'PDF');
assert.ok(d.files[0].link.includes('someitem.pdf'));
});
test('openlibrary: 借阅制条目被跳过', async () => {
h.setHandler(h.routes([
['editions.json', { body: { entries: [{ ocaid: 'lend', access_restricted: 'borrow' }] } }]
]));
const d = await sources.getSource('openlibrary').download('OL4W');
assert.deepStrictEqual(d.files, []);
});
// --- bioRxiv ---
test('biorxiv: 瞬时故障会重试而不是直接失败', async () => {
let n = 0;
h.setHandler(() => {
n++;
// 502 与超时走的是同一条 isRetryable 分支,用 502 避免真的等满超时
if (n <= 2) return h.makeResponse({ status: 502 });
return h.makeResponse({ body: { messages: [{ total: 100 }], collection: [] } });
});
const r = await sources.getSource('biorxiv').list(1);
assert.ok(n >= 3, `没有重试,只请求了 ${n}`);
assert.ok(r.maxPage >= 1);
});
test('biorxiv: 超时被判定为可重试(回归 504|502|503 正则漏判)', () => {
const { isRetryable } = require('../sources/http');
assert.strictEqual(isRetryable(new Error('请求超时,站点无响应')), true);
assert.strictEqual(isRetryable(new Error('网络连接失败,请检查网络或代理设置')), true);
const src = require('fs').readFileSync(require.resolve('../sources/biorxiv.js'), 'utf8');
assert.ok(!/504\|502\|503/.test(src), '旧的字符串匹配门仍在');
});
test('biorxiv: 不支持搜索时明确报错', async () => {
await assert.rejects(sources.getSource('biorxiv').search('x', 1), /不支持搜索/);
});
// --- MOTW ---
test('motw: 分页用 offset/limit 且随页码递增', async () => {
const urls = [];
h.setHandler((u) => {
urls.push(u);
return h.makeResponse({ body: { _items: [], _meta: { total: 1000, max_results: 48 } } });
});
const motw = sources.getSource('motw');
await motw.list(1);
await motw.list(3);
assert.ok(urls[0].includes('offset=0&limit=48'), urls[0]);
assert.ok(urls[1].includes('offset=96&limit=48'), urls[1]);
});
test('motw: 未缓存的详情给出可操作提示', async () => {
await assert.rejects(sources.getSource('motw').detail('unknown-id'), /重新进入/);
});
// --- arXiv ---
test('arxiv: 解析 atom feed 并取 pdf 链接', async () => {
const xml = `<feed><opensearch:totalResults>40</opensearch:totalResults>
<entry><id>http://arxiv.org/abs/2201.00978v1</id><title>Paper T</title>
<summary>S</summary><published>2022-01-03T00:00:00Z</published>
<author><name>A One</name></author>
<link title="pdf" href="https://arxiv.org/pdf/2201.00978v1"/>
<category term="cs.CV"/></entry></feed>`;
h.setHandler(() => h.makeResponse({ body: xml }));
const r = await sources.getSource('arxiv').search('transformer', 1);
assert.strictEqual(r.items[0].postId, '2201.00978v1');
assert.strictEqual(r.maxPage, 2);
const d = await sources.getSource('arxiv').download('2201.00978v1');
assert.strictEqual(d.files[0].link, 'https://arxiv.org/pdf/2201.00978v1');
});
// --- Z-Library ---
test('zlib: postId 缺 hash 时详情仍可用', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const origSession = auth.getSession;
const origRead = auth.read;
auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
auth.read = () => ({ email: 'e', password: 'p', userId: '1', userKey: 'k', mirror: 'https://z-lib.fm' });
try {
const urls = [];
h.setHandler((u) => {
urls.push(u);
return h.makeResponse({ body: { success: 1, book: { title: 'B', author: 'X' } } });
});
const d = await zlib.detail('123/');
assert.strictEqual(d.title, 'B');
assert.ok(urls[0].includes('/eapi/book/123'), urls[0]);
assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]);
} finally {
auth.getSession = origSession;
auth.read = origRead;
}
});
test('zlib: 完全无效的 id 仍然拒绝', async () => {
const zlib = h.freshRequire('sources/zlib.js');
await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/);
});
// 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。
// 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。
test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = { read: auth.read, getSession: auth.getSession, setSession: auth.setSession, clearSession: auth.clearSession };
let session = { userId: 'old', userKey: 'stale', mirror: 'https://z-lib.fm' };
auth.read = () => ({ email: 'e@x.com', password: 'p', ...session });
auth.getSession = () => (session.userKey ? session : null);
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
auth.clearSession = () => { session = { userId: '', userKey: '', mirror: '' }; };
try {
let loggedIn = false;
h.setHandler((url) => {
if (url.includes('/rpc.php')) {
loggedIn = true;
return h.makeResponse({
headers: {
'set-cookie': [
'remix_userid=42; Path=/; Secure; HttpOnly',
'remix_userkey=fresh; Path=/; Secure; HttpOnly'
]
},
body: { errors: [], response: { redirect: '/' } }
});
}
if (url.includes('userKey=fresh')) {
return h.makeResponse({ body: { success: 1, file: { downloadLink: 'https://cdn/x.pdf', extension: 'pdf' } } });
}
return h.makeResponse({ status: 400, body: { success: 0, error: 'Please login' } });
});
const d = await zlib.download('123/abc');
assert.ok(loggedIn, '过期会话没有触发重新登录');
assert.strictEqual(d.files[0].link, 'https://cdn/x.pdf');
} finally {
Object.assign(auth, orig);
}
});
test('zlib: 凭据错误时报出服务端原因而不是 HTTP 状态码', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = { read: auth.read, getSession: auth.getSession, write: auth.write, clear: auth.clear };
// login() 先写盘,doLogin() 再读回来,所以 stub 要如实模拟这个往返
let stored = null;
auth.read = () => stored;
auth.getSession = () => null;
auth.write = (c) => { stored = { ...c }; };
auth.clear = () => { stored = null; };
try {
let request = null;
h.setHandler((url, options) => {
request = { url, body: options.body };
return h.makeResponse({
body: {
errors: [],
response: {
validationError: true,
fields: ['email', 'password'],
message: 'Incorrect email or password'
}
}
});
});
const r = await zlib.login('e@x.com', 'wrong');
assert.strictEqual(r.ok, false);
assert.match(r.error, /Incorrect email or password/, '真实原因被 HTTP 状态码盖掉了');
assert.ok(request.url.endsWith('/rpc.php'));
assert.match(request.body, /action=login/);
assert.match(request.body, /gg_json_mode=1/);
} finally {
Object.assign(auth, orig);
}
});
test('zlib: RPC 登录从安全 Cookie 建立会话', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
read: auth.read,
getSession: auth.getSession,
write: auth.write,
setSession: auth.setSession,
clear: auth.clear
};
let stored = null;
let session = null;
auth.read = () => stored;
auth.getSession = () => session;
auth.write = (c) => { stored = { ...c }; };
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
auth.clear = () => { stored = null; session = null; };
try {
h.setHandler(() => h.makeResponse({
headers: {
'set-cookie': [
'remix_userid=42; Path=/; Secure; HttpOnly',
'remix_userkey=key%2Bvalue; Path=/; Secure; HttpOnly'
]
},
body: { errors: [], response: { redirect: '/' } }
}));
const r = await zlib.login('e@x.com', 'correct');
assert.strictEqual(r.ok, true);
assert.strictEqual(session.userId, '42');
assert.strictEqual(session.userKey, 'key+value');
assert.match(session.mirror, /^https:\/\//);
} finally {
Object.assign(auth, orig);
}
});
test('zlib: 可注入同源浏览器登录传输并持久化会话', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
read: auth.read,
getSession: auth.getSession,
write: auth.write,
setSession: auth.setSession,
clear: auth.clear
};
let stored = null;
let session = null;
auth.read = () => stored;
auth.getSession = () => session;
auth.write = (c) => { stored = { ...c }; };
auth.setSession = (userId, userKey, mirror) => { session = { userId, userKey, mirror }; };
auth.clear = () => { stored = null; session = null; };
zlib.setLoginTransport(async (mirror, email, password) => {
assert.match(mirror, /^https:\/\//);
assert.strictEqual(email, 'e@x.com');
assert.strictEqual(password, 'correct');
return { userId: 'browser-user', userKey: 'browser-key' };
});
try {
const result = await zlib.login('e@x.com', 'correct');
assert.strictEqual(result.ok, true);
assert.strictEqual(session.userId, 'browser-user');
assert.strictEqual(session.userKey, 'browser-key');
} finally {
zlib.setLoginTransport(null);
Object.assign(auth, orig);
}
});
// --- Gutenberg ---
test('gutenberg: 解析格式与封面', async () => {
h.setHandler(h.routes([['gutendex.com/books', {
body: {
count: 64,
results: [{
id: 11, title: 'Alice', authors: [{ name: 'Carroll' }],
formats: { 'application/epub+zip': 'https://x/a.epub', 'image/jpeg': 'https://x/c.jpg' }
}]
}
}]]));
const r = await sources.getSource('gutenberg').search('alice', 1);
assert.strictEqual(r.items[0].postId, '11');
assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
assert.strictEqual(r.maxPage, 2);
});
+707
View File
@@ -0,0 +1,707 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
h.setHandler(() => h.makeResponse({ status: 404 }));
const store = require('../library/store');
function tmpDir(tag) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-${tag}-`));
return d;
}
const created = [];
function freshRoot(tag) {
const d = tmpDir(tag);
created.push(d);
store.init(d);
return d;
}
test.after(() => {
for (const d of created) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('init 建出目录结构', () => {
const root = freshRoot('init');
assert.ok(fs.existsSync(path.join(root, 'files')));
assert.ok(fs.existsSync(path.join(root, 'covers')));
assert.strictEqual(store.getRoot(), path.resolve(root));
});
test('add / get / list 往返', () => {
freshRoot('crud');
const it = store.add({ title: '测试书', authors: ['作者'], sourceId: 's', sourcePostId: 1 });
assert.ok(it.id);
const got = store.get(it.id);
assert.strictEqual(got.title, '测试书');
assert.strictEqual(got.sourcePostId, '1', 'sourcePostId 应统一为字符串');
assert.strictEqual(store.list().length, 1);
assert.ok(store.findBySource('s', 1), '数字 postId 应能匹配');
assert.ok(store.findBySource('s', '1'));
});
test('批量导入本地文件可按上一级目录创建并复用书架', () => {
const root = freshRoot('local-import-shelves');
const source = path.join(root, 'source');
for (const folder of ['文学', '技术']) fs.mkdirSync(path.join(source, folder), { recursive: true });
const files = [
path.join(source, '文学', '小说.epub'),
path.join(source, '文学', '诗集.pdf'),
path.join(source, '技术', '手册.txt')
];
files.forEach((file, index) => fs.writeFileSync(file, `fixture-${index}`));
const existingLiterature = store.addShelf('文学');
const records = files.map((file) => ({
path: file,
name: path.basename(file),
format: path.extname(file).slice(1).toUpperCase(),
parentName: path.basename(path.dirname(file))
}));
const imported = store.importLocal(records, 'shelf');
assert.strictEqual(imported.added, 3);
assert.strictEqual(imported.skipped, 0);
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name).sort(), ['技术', '文学']);
const literature = store.listShelves().find((shelf) => shelf.name === '文学');
assert.strictEqual(literature.id, existingLiterature.id);
assert.strictEqual(
store.list().filter((item) => item.shelfId === literature.id).length,
2
);
const repeated = store.importLocal(records, 'shelf');
assert.deepStrictEqual(
{
added: repeated.added,
skipped: repeated.skipped,
skippedDuplicates: repeated.skippedDuplicates
},
{ added: 0, skipped: 3, skippedDuplicates: 3 }
);
assert.strictEqual(store.list().length, 3);
});
test('本地导入按规范路径跳过书库中已有的同一文件', () => {
const root = freshRoot('local-import-same-path');
const source = path.join(root, 'source', 'same.pdf');
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.writeFileSync(source, 'same-path-content');
const record = { path: source, name: 'same.pdf', parentName: 'source' };
assert.strictEqual(store.importLocal([record]).added, 1);
const repeated = store.importLocal([record]);
assert.deepStrictEqual(
{
added: repeated.added,
skipped: repeated.skipped,
skippedDuplicates: repeated.skippedDuplicates
},
{ added: 0, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 1);
});
test('本地导入按文件字节跳过不同路径下的副本', () => {
const root = freshRoot('local-import-copy');
const original = path.join(root, 'original', 'first.pdf');
const copy = path.join(root, 'copy', 'renamed.pdf');
fs.mkdirSync(path.dirname(original), { recursive: true });
fs.mkdirSync(path.dirname(copy), { recursive: true });
fs.writeFileSync(original, 'identical-file-bytes');
fs.copyFileSync(original, copy);
assert.strictEqual(store.importLocal([{ path: original }]).added, 1);
const copied = store.importLocal([{
path: copy,
name: 'Completely Different Title.pdf',
title: 'Completely Different Title'
}]);
assert.deepStrictEqual(
{
added: copied.added,
skipped: copied.skipped,
skippedDuplicates: copied.skippedDuplicates
},
{ added: 0, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 1);
});
test('本地导入保留同名但字节不同的版本', () => {
const root = freshRoot('local-import-editions');
const first = path.join(root, 'edition-one', 'Shared Title.pdf');
const second = path.join(root, 'edition-two', 'Shared Title.pdf');
fs.mkdirSync(path.dirname(first), { recursive: true });
fs.mkdirSync(path.dirname(second), { recursive: true });
fs.writeFileSync(first, 'edition-A');
fs.writeFileSync(second, 'edition-B');
const imported = store.importLocal([
{ path: first, title: 'Shared Title' },
{ path: second, title: 'Shared Title' }
]);
assert.deepStrictEqual(
{
added: imported.added,
skipped: imported.skipped,
skippedDuplicates: imported.skippedDuplicates
},
{ added: 2, skipped: 0, skippedDuplicates: 0 }
);
assert.deepStrictEqual(store.list().map((item) => item.title), ['Shared Title', 'Shared Title']);
});
test('混合批量导入同时跳过已有路径、已有副本和批内副本', () => {
const root = freshRoot('local-import-mixed');
const existing = path.join(root, 'existing', 'book.epub');
const existingCopy = path.join(root, 'incoming', 'existing-copy.epub');
const fresh = path.join(root, 'incoming', 'fresh.epub');
const freshCopy = path.join(root, 'incoming-copy', 'fresh-copy.epub');
for (const file of [existing, existingCopy, fresh, freshCopy]) {
fs.mkdirSync(path.dirname(file), { recursive: true });
}
fs.writeFileSync(existing, 'existing-content');
fs.copyFileSync(existing, existingCopy);
fs.writeFileSync(fresh, 'brand-new-content');
fs.copyFileSync(fresh, freshCopy);
store.importLocal([{ path: existing }]);
const imported = store.importLocal([
{ path: existing },
{ path: existingCopy },
{ path: fresh },
{ path: freshCopy }
]);
assert.deepStrictEqual(
{
added: imported.added,
skipped: imported.skipped,
skippedDuplicates: imported.skippedDuplicates
},
{ added: 1, skipped: 3, skippedDuplicates: 3 }
);
assert.strictEqual(imported.items[0].files[0].path, fs.realpathSync(fresh));
assert.strictEqual(store.list().length, 2);
});
test('本地批量导入写入失败时回滚条目、分类和去重状态', () => {
const root = freshRoot('local-import-rollback');
const source = path.join(root, '回滚分类');
const first = path.join(source, 'first.pdf');
const duplicate = path.join(source, 'first-copy.pdf');
const second = path.join(source, 'second.pdf');
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(first, 'duplicate-content');
fs.copyFileSync(first, duplicate);
fs.writeFileSync(second, 'distinct-content');
const records = [first, duplicate, second].map((file) => ({
path: file,
parentName: '回滚分类'
}));
const file = path.join(root, 'library.json');
const originalRename = fs.renameSync;
let failed = false;
fs.renameSync = function renameWithFailure(sourcePath, destination) {
if (!failed && sourcePath === `${file}.tmp` && destination === file) {
failed = true;
throw new Error('simulated replace failure');
}
return originalRename.apply(this, arguments);
};
try {
assert.throws(
() => store.importLocal(records, 'shelf'),
/书库索引写入失败/
);
} finally {
fs.renameSync = originalRename;
}
assert.ok(failed);
assert.deepStrictEqual(store.list(), []);
assert.deepStrictEqual(store.listShelves(), []);
assert.ok(!fs.existsSync(file));
assert.ok(!fs.existsSync(`${file}.tmp`));
const retried = store.importLocal(records, 'shelf');
assert.deepStrictEqual(
{
added: retried.added,
skipped: retried.skipped,
skippedDuplicates: retried.skippedDuplicates
},
{ added: 2, skipped: 1, skippedDuplicates: 1 }
);
assert.strictEqual(store.list().length, 2);
assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name), ['回滚分类']);
});
test('批量导入本地文件可按上一级目录创建标签或保持不分类', () => {
const root = freshRoot('local-import-tags');
const folder = path.join(root, '旧分类');
fs.mkdirSync(folder, { recursive: true });
const taggedFile = path.join(folder, '标签书.pdf');
const plainFile = path.join(folder, '普通书.epub');
fs.writeFileSync(taggedFile, 'tagged');
fs.writeFileSync(plainFile, 'plain');
const existingTag = store.addTag('旧分类');
const tagged = store.importLocal([{
path: taggedFile,
name: '标签书.pdf',
parentName: '旧分类'
}], 'tag');
assert.strictEqual(tagged.added, 1);
assert.deepStrictEqual(store.get(tagged.items[0].id).tags, ['旧分类']);
const taggedCatalog = store.listTags().find((tag) => tag.name === '旧分类');
assert.strictEqual(taggedCatalog.id, existingTag.id);
assert.strictEqual(taggedCatalog.count, 1);
const plain = store.importLocal([{
path: plainFile,
name: '普通书.epub',
parentName: '旧分类'
}], 'none');
assert.strictEqual(plain.added, 1);
assert.deepStrictEqual(store.get(plain.items[0].id).tags, []);
assert.strictEqual(store.get(plain.items[0].id).shelfId, null);
assert.throws(() => store.importLocal([], 'invalid'), /分类方式无效/);
});
test('书库内文件存相对路径,外部文件存绝对路径', () => {
const root = freshRoot('paths');
const inside = path.join(root, 'files', 'a.pdf');
fs.writeFileSync(inside, 'x');
const outsideDir = tmpDir('outside');
created.push(outsideDir);
const outside = path.join(outsideDir, 'b.pdf');
fs.writeFileSync(outside, 'y');
const it = store.add({ title: 'T', files: [{ path: inside }, { path: outside }] });
const raw = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
const stored = raw.items[0].files.map((f) => f.path);
assert.ok(stored.includes('files/a.pdf'), '库内文件未转相对路径: ' + stored);
assert.ok(stored.some((p) => path.isAbsolute(p)), '库外文件不应转相对路径');
// 对外一律给绝对路径
for (const f of it.files) assert.ok(path.isAbsolute(f.path), f.path);
assert.ok(it.files.every((f) => f.exists));
});
test('expand 如实反映磁盘状态', () => {
const root = freshRoot('missing');
const p = path.join(root, 'files', 'gone.pdf');
fs.writeFileSync(p, 'x');
const it = store.add({ title: 'T', files: [{ path: p }] });
assert.strictEqual(store.get(it.id).missing, false);
fs.unlinkSync(p);
const after = store.get(it.id);
assert.strictEqual(after.files[0].exists, false);
assert.strictEqual(after.missing, true);
});
test('allocFilePath 避免覆盖同名文件', () => {
const root = freshRoot('alloc');
const first = store.allocFilePath('book.pdf');
fs.writeFileSync(first, 'a');
const second = store.allocFilePath('book.pdf');
assert.notStrictEqual(first, second);
assert.ok(second.includes('(1)'), second);
});
test('sanitize 去掉非法字符', () => {
assert.strictEqual(store.sanitize('a/b:c*d?.pdf'), 'a_b_c_d_.pdf');
assert.strictEqual(store.sanitize(''), 'download');
assert.strictEqual(store.sanitize(' '), 'download');
});
test('remove 默认保留文件,deleteFiles 才删', () => {
const root = freshRoot('remove');
const p = path.join(root, 'files', 'keep.pdf');
fs.writeFileSync(p, 'x');
const a = store.add({ title: 'A', files: [{ path: p }] });
store.remove(a.id, false);
assert.ok(fs.existsSync(p), '未勾选删除时不该删文件');
const b = store.add({ title: 'B', files: [{ path: p }] });
store.remove(b.id, true);
assert.ok(!fs.existsSync(p), '勾选删除后文件应被删除');
});
test('remove 不删书库目录外的用户文件', () => {
freshRoot('remove-outside');
const outDir = tmpDir('user');
created.push(outDir);
const p = path.join(outDir, 'mine.pdf');
fs.writeFileSync(p, 'x');
const it = store.add({ title: 'T', files: [{ path: p }] });
store.remove(it.id, true);
assert.ok(fs.existsSync(p), '原地引用的外部文件被误删了');
});
test('scan 导入孤立文件并跳过非书籍扩展名', () => {
const root = freshRoot('scan');
fs.writeFileSync(path.join(root, 'files', 'novel.epub'), 'x');
fs.writeFileSync(path.join(root, 'files', 'notes.exe'), 'x');
const r = store.scan();
assert.strictEqual(r.added, 1, '应只导入 epub');
assert.strictEqual(store.list()[0].title, 'novel');
const again = store.scan();
assert.strictEqual(again.added, 0, '重复扫描不应重复导入');
});
test('attachFile 幂等,不产生重复条目文件', () => {
const root = freshRoot('attach');
const it = store.add({ title: 'T' });
const p = path.join(root, 'files', 'x.pdf');
fs.writeFileSync(p, 'x');
store.attachFile(it.id, p);
const after = store.attachFile(it.id, p);
assert.strictEqual(after.files.length, 1, '重复挂载产生了重复记录');
});
test('生成封面写入 covers 并随条目删除', () => {
const root = freshRoot('generated-cover');
const it = store.add({ title: 'T' });
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
const cover = store.setGeneratedCover(it.id, jpeg);
assert.ok(cover.startsWith(path.join(root, 'covers')), cover);
assert.ok(fs.existsSync(cover));
assert.strictEqual(store.get(it.id).cover, cover);
store.remove(it.id, false);
assert.ok(!fs.existsSync(cover), '移除条目后遗留了生成封面');
});
test('生成封面不覆盖更新后的来源封面', () => {
const root = freshRoot('generated-priority');
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
store.update(it.id, { cover: 'https://new.example/cover.jpg' });
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]);
assert.strictEqual(store.setGeneratedCover(it.id, jpeg, it.cover), '');
assert.strictEqual(store.get(it.id).cover, 'https://new.example/cover.jpg');
const blank = store.add({ title: 'Blank' });
const manual = path.join(root, 'manual.jpg');
fs.writeFileSync(manual, jpeg);
store.update(blank.id, { cover: manual });
assert.strictEqual(store.setGeneratedCover(blank.id, jpeg, ''), '');
assert.strictEqual(store.get(blank.id).cover, manual);
});
test('生成封面拒绝非 JPEG 和过大数据', () => {
freshRoot('generated-validation');
const it = store.add({ title: 'T' });
assert.throws(() => store.setGeneratedCover(it.id, Buffer.from('not an image')), /JPEG/);
const large = Buffer.alloc(2 * 1024 * 1024 + 1);
large[0] = 0xff; large[1] = 0xd8; large[2] = 0xff;
assert.throws(() => store.setGeneratedCover(it.id, large), /JPEG/);
});
test('远程封面下载完成后不覆盖期间更新的封面', async () => {
const root = freshRoot('remote-cover-race');
let release;
let startedResolve;
const started = new Promise((resolve) => { startedResolve = resolve; });
h.setHandler(() => {
startedResolve();
return new Promise((resolve) => {
release = () => resolve({
ok: true,
arrayBuffer: async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]).buffer
});
});
});
const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' });
const job = store.ensureCoverCached(it.id);
await started;
const manual = path.join(root, 'manual.jpg');
fs.writeFileSync(manual, Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
store.update(it.id, { cover: manual });
release();
assert.strictEqual(await job, '');
assert.strictEqual(store.get(it.id).cover, manual);
h.setHandler(() => h.makeResponse({ status: 404 }));
});
test('远程封面缓存拒绝网页响应', async () => {
const root = freshRoot('remote-cover-html');
h.setHandler(() => ({
ok: true,
arrayBuffer: async () => Uint8Array.from(Buffer.from('<html>not an image</html>')).buffer
}));
const it = store.add({ title: 'T', cover: 'https://example.com/cover.jpg' });
assert.strictEqual(await store.ensureCoverCached(it.id), '');
assert.strictEqual(store.get(it.id).cover, 'https://example.com/cover.jpg');
assert.deepStrictEqual(fs.readdirSync(path.join(root, 'covers')), []);
h.setHandler(() => h.makeResponse({ status: 404 }));
});
test('索引损坏时报错而不是静默清空书库', () => {
const root = freshRoot('corrupt');
store.add({ title: '重要的书' });
fs.writeFileSync(path.join(root, 'library.json'), '{ 坏掉的 json');
store.init(root);
assert.throws(() => store.list(), /书库索引读取失败/);
});
test('写入后可从 .bak 恢复', () => {
const root = freshRoot('bak');
store.add({ title: '书' });
const idx = path.join(root, 'library.json');
fs.copyFileSync(idx, idx + '.bak');
fs.unlinkSync(idx);
store.init(root);
assert.strictEqual(store.list().length, 1, '未从 .bak 恢复');
});
test('migrateTo 搬运文件并保持条目可用', () => {
const src = freshRoot('mig-src');
const shelf = store.addShelf({ name: '迁移书架' });
const p = path.join(src, 'files', 'm.pdf');
fs.writeFileSync(p, 'data');
const added = store.add({ title: 'M', shelfId: shelf.id, tags: ['迁移'], files: [{ path: p }] });
const oldCover = store.setGeneratedCover(
added.id,
Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9])
);
const dest = tmpDir('mig-dest');
created.push(dest);
store.migrateTo(dest);
store.finalizeMigration();
assert.strictEqual(store.getRoot(), path.resolve(dest));
const items = store.list();
assert.strictEqual(items.length, 1);
assert.ok(items[0].files[0].exists, '迁移后文件丢失');
assert.ok(items[0].files[0].path.startsWith(path.resolve(dest)), items[0].files[0].path);
assert.ok(items[0].cover.startsWith(path.resolve(dest)), items[0].cover);
assert.ok(fs.existsSync(items[0].cover), '迁移后生成封面丢失');
assert.strictEqual(items[0].shelfId, shelf.id);
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['迁移书架']);
assert.ok(!fs.existsSync(p), '旧文件未清理');
assert.ok(!fs.existsSync(oldCover), '旧生成封面未清理');
});
test('migrateTo 拒绝互相包含的目录', () => {
const src = freshRoot('mig-nest');
assert.throws(() => store.migrateTo(path.join(src, 'sub')), /不能互相包含/);
});
test('migrateTo 拒绝已有书库的目标目录', () => {
freshRoot('mig-occupied');
store.add({ title: 'A' });
const dest = tmpDir('mig-taken');
created.push(dest);
fs.writeFileSync(path.join(dest, 'library.json'), '{}');
assert.throws(() => store.migrateTo(dest), /已包含书库索引/);
});
test('rollbackMigration 回到原目录且不留残File', () => {
const src = freshRoot('mig-rb');
const shelf = store.addShelf('回滚书架');
const p = path.join(src, 'files', 'r.pdf');
fs.writeFileSync(p, 'data');
store.add({ title: 'R', shelfId: shelf.id, files: [{ path: p }] });
const dest = tmpDir('mig-rb-dest');
created.push(dest);
store.migrateTo(dest);
store.rollbackMigration();
assert.strictEqual(store.getRoot(), path.resolve(src));
assert.ok(fs.existsSync(p), '回滚后源文件应还在');
assert.ok(!fs.existsSync(path.join(dest, 'library.json')), '目标目录索引未清理');
assert.strictEqual(store.list().length, 1);
assert.strictEqual(store.list()[0].shelfId, shelf.id);
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['回滚书架']);
});
test('importLegacy 正确复制相对路径封面', () => {
const legacy = tmpDir('legacy-relative-cover');
created.push(legacy);
fs.mkdirSync(path.join(legacy, 'covers'), { recursive: true });
fs.writeFileSync(path.join(legacy, 'covers', 'old.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify([{
id: 'legacy-book',
title: 'Legacy',
cover: 'covers/old.jpg',
files: []
}]));
const root = freshRoot('legacy-relative-dest');
assert.strictEqual(store.importLegacy(legacy).imported, 1);
const imported = store.get('legacy-book');
assert.ok(imported.cover.startsWith(path.join(root, 'covers')), imported.cover);
assert.ok(fs.existsSync(imported.cover));
});
test('update 修改字段并刷新 updatedAt', () => {
freshRoot('update');
const it = store.add({ title: '旧' });
const out = store.update(it.id, { title: '新', tags: ['t'] });
assert.strictEqual(out.title, '新');
assert.deepStrictEqual(out.tags, ['t']);
assert.throws(() => store.update('nope', {}), /条目不存在/);
});
test('v1 和 v2 索引透明迁移到 v4 并保留条目与标签目录', () => {
for (const fixture of [
{
tag: 'schema-v1',
data: [{ id: 'v1', title: '旧数组', custom: { kept: true }, tags: [' A ', 'a', ''] }]
},
{
tag: 'schema-v2',
data: {
version: 2,
items: [{ id: 'v2', title: '旧对象', custom: { kept: true }, tags: ['B'], shelfId: 'missing' }]
}
}
]) {
const root = freshRoot(fixture.tag);
fs.writeFileSync(path.join(root, 'library.json'), JSON.stringify(fixture.data));
store.init(root);
const item = store.list()[0];
assert.deepStrictEqual(item.custom, { kept: true });
assert.strictEqual(item.shelfId, null);
assert.strictEqual(item.tags.length, 1);
store.update(item.id, { title: item.title });
const persisted = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8'));
assert.strictEqual(persisted.version, 4);
assert.deepStrictEqual(persisted.shelves, []);
assert.strictEqual(persisted.tags.length, 1);
assert.strictEqual(persisted.tags[0].name, item.tags[0]);
assert.deepStrictEqual(persisted.items[0].custom, { kept: true });
}
});
test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
freshRoot('shelf-crud');
const shelf = store.addShelf({ name: ' 技术 ' });
assert.match(shelf.id, /^shelf_[a-f0-9]{24}$/);
assert.strictEqual(shelf.name, '技术');
assert.ok(Number.isFinite(shelf.createdAt));
assert.ok(Number.isFinite(shelf.updatedAt));
assert.throws(() => store.addShelf(' '), /不能为空/);
assert.throws(() => store.addShelf('技术'), /已存在/);
const listed = store.listShelves();
listed[0].name = '被外部修改';
listed.push({ id: 'fake', name: '假的' });
assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['技术']);
const updated = store.updateShelf(shelf.id, { name: ' 文学 ' });
assert.strictEqual(updated.name, '文学');
assert.strictEqual(updated.createdAt, shelf.createdAt);
assert.ok(updated.updatedAt >= shelf.updatedAt);
updated.name = '再次外部修改';
assert.strictEqual(store.listShelves()[0].name, '文学');
assert.throws(() => store.updateShelf('missing', { name: 'X' }), /不存在/);
const other = store.addShelf('Research');
assert.throws(() => store.addShelf(' research '), /已存在/);
assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
});
test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
freshRoot('shelf-remove');
let changes = 0;
store.setChangeListener(() => { changes++; });
try {
const shelf = store.addShelf('待整理');
const book = store.add({ title: '保留我' });
store.update(book.id, { shelfId: shelf.id, tags: [' A ', 'a', 'B'] });
assert.strictEqual(changes, 2, '书架添加和组织更新均应通知');
assert.strictEqual(store.get(book.id).shelfId, shelf.id);
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: true });
assert.strictEqual(changes, 3);
assert.strictEqual(store.list().length, 1, '删除书架不应删除书籍');
assert.strictEqual(store.get(book.id).shelfId, null);
assert.deepStrictEqual(store.get(book.id).tags, ['A', 'B']);
assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: false });
assert.strictEqual(changes, 3, '重复删除不存在的书架不应通知');
} finally {
store.setChangeListener(null);
}
});
test('条目组织字段归一化并限制标签数量和长度', () => {
freshRoot('organization-normalize');
const shelf = store.addShelf('有效书架');
const manyTags = Array.from({ length: 60 }, (_, i) => ` tag-${i} `);
const book = store.add({
title: '组织',
shelfId: shelf.id,
tags: [' Foo ', 'foo', null, '', 'x'.repeat(80), ...manyTags]
});
assert.strictEqual(book.shelfId, shelf.id);
assert.strictEqual(book.tags[0], 'Foo');
assert.strictEqual(book.tags[1].length, 64);
assert.strictEqual(book.tags.length, 50);
const invalid = store.update(book.id, { shelfId: 'not-a-shelf', tags: 'not-an-array' });
assert.strictEqual(invalid.shelfId, null);
assert.deepStrictEqual(invalid.tags, []);
});
test('listTags 合并大小写、保留稳定 ID 并按数量和中文名称排序', () => {
freshRoot('tag-catalog');
store.add({ title: '一', tags: [' 科学 ', 'SCIENCE', '历史'] });
store.add({ title: '二', tags: ['科学', 'science', '文学'] });
store.add({ title: '三', tags: ['Science'] });
const actual = store.listTags();
assert.ok(actual.every((tag) => /^tag_[a-f0-9]{24}$/.test(tag.id)));
assert.deepStrictEqual(actual.slice(0, 2).map(({ name, count }) => ({ name, count })), [
{ name: 'SCIENCE', count: 3 },
{ name: '科学', count: 2 }
]);
const tied = actual.slice(2).map(({ name, count }) => ({ name, count }));
assert.deepStrictEqual(
tied,
[{ name: '历史', count: 1 }, { name: '文学', count: 1 }]
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' }))
);
});
test('importLegacy 保留书架、标签并将同名书架映射到现有书架', () => {
const legacy = tmpDir('legacy-shelves');
created.push(legacy);
fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify({
version: 3,
shelves: [
{ id: 'old-shared', name: ' 已有 ', createdAt: 10, updatedAt: 20 },
{ id: 'old-new', name: '新书架', createdAt: 30, updatedAt: 40 }
],
items: [
{ id: 'legacy-shared-book', title: '共享', shelfId: 'old-shared', tags: [' A ', 'a'] },
{ id: 'legacy-new-book', title: '新增', shelfId: 'old-new', tags: [' B '] }
]
}));
freshRoot('legacy-shelves-dest');
const existing = store.addShelf('已有');
assert.strictEqual(store.importLegacy(legacy).imported, 2);
const importedShelves = store.listShelves();
assert.deepStrictEqual(importedShelves.map((entry) => entry.name), ['已有', '新书架']);
assert.strictEqual(importedShelves[1].createdAt, 30);
assert.strictEqual(store.get('legacy-shared-book').shelfId, existing.id);
assert.strictEqual(store.get('legacy-new-book').shelfId, importedShelves[1].id);
assert.deepStrictEqual(store.get('legacy-shared-book').tags, ['A']);
const raw = JSON.parse(fs.readFileSync(path.join(store.getRoot(), 'library.json'), 'utf8'));
assert.strictEqual(raw.version, 4);
assert.strictEqual(raw.shelves.length, 2);
assert.deepStrictEqual(raw.tags.map((tag) => tag.name).sort(), ['A', 'B']);
});
+521
View File
@@ -0,0 +1,521 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const utilFile = path.join(__dirname, '..', 'ui', 'util.js');
const libFile = path.join(__dirname, '..', 'ui', 'views', 'library.js');
const utilSrc = fs.readFileSync(utilFile, 'utf8');
// util.js 只做 window.X = ... 赋值,没有加载期副作用,
// 因此可以整体求值拿到真实实现(DOM 依赖都在调用时才触发)。
function loadUtil() {
const win = {};
const store = new Map();
win.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v))
};
new Function('window', 'localStorage', 'document', utilSrc)(win, win.localStorage, undefined);
return win;
}
// style="..." 里的值会先被 HTML 解码,再交给 CSS 解析
function htmlDecode(s) {
return s.replace(/&#39;/g, "'").replace(/&quot;/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
test('escapeHtml 覆盖全部危险字符', () => {
const { escapeHtml } = loadUtil();
assert.strictEqual(escapeHtml(`<a href="x">&'`), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;');
assert.strictEqual(escapeHtml(null), '');
assert.strictEqual(escapeHtml(undefined), '');
assert.strictEqual(escapeHtml(0), '0');
});
test('coverStyle 阻断 style 属性逃逸', () => {
const { coverStyle } = loadUtil();
const out = coverStyle('https://evil/a.jpg") onerror="alert(1)');
assert.ok(!out.includes('"'), '裸引号泄漏: ' + out);
assert.ok(out.includes('&quot;'), '未做 HTML 转义: ' + out);
});
test('coverStyle 阻断 CSS 串逃逸', () => {
const { coverStyle } = loadUtil();
// 浏览器会先 HTML 解码属性值,再按 CSS 解析,这里模拟同样的两步
const css = htmlDecode(coverStyle("https://evil/a.jpg'); background:url('x"));
const inner = css.replace(/^background-image:url\('/, '').replace(/'\)$/, '');
assert.ok(!/(^|[^\\])'/.test(inner), 'CSS 单引号未转义,可提前闭合 url(): ' + css);
assert.ok(!/(^|[^\\])\)/.test(inner), 'CSS 右括号未转义: ' + css);
});
test('coverStyle 拒绝换行注入', () => {
const { coverStyle } = loadUtil();
assert.strictEqual(coverStyle('https://x/a.jpg\n background:red'), '');
});
test('coverStyle 正常输入仍可用', () => {
const { coverStyle } = loadUtil();
assert.strictEqual(coverStyle(''), '');
// 转义后浏览器实际解析到的地址才是关注点
const remote = htmlDecode(coverStyle('https://x/a.jpg')).replace(/\\(.)/g, '$1');
assert.strictEqual(remote, "background-image:url('https://x/a.jpg')");
const local = htmlDecode(coverStyle('C:\\books\\c.jpg')).replace(/\\([('")])/g, '$1');
assert.ok(local.includes('file:///C:/books/c.jpg'), local);
});
test('formatDate 补零', () => {
const { formatDate } = loadUtil();
assert.strictEqual(formatDate(0), '');
assert.strictEqual(formatDate(new Date(2024, 0, 5).getTime()), '2024-01-05');
});
test('enabledSources 存取;损坏数据回退为 null', () => {
const win = loadUtil();
assert.strictEqual(win.getEnabledSources(), null);
win.setEnabledSources(['arxiv', 'pmc']);
assert.deepStrictEqual(win.getEnabledSources(), ['arxiv', 'pmc']);
win.localStorage.setItem('enabledSources', '{坏json');
assert.strictEqual(win.getEnabledSources(), null, '损坏数据应回退而不是抛错');
});
test('添加本地内容支持文件、文件夹和上级目录分类选项', () => {
const src = fs.readFileSync(libFile, 'utf8');
assert.match(src, /name="localImportSource" value="files"/);
assert.match(src, /name="localImportSource" value="folder"/);
assert.match(src, /window\.api\.library\.pickLocal\(source\)/);
assert.match(src, /value="shelf"[\s\S]+上一级目录作为书架/);
assert.match(src, /value="tag"[\s\S]+上一级目录作为标签/);
assert.match(src, /window\.api\.library\.importLocal\(selection\.selectionId,\s*options\)/);
});
test('写进 HTML 的字段插值都过 escapeHtml', () => {
for (const f of ['views/browse.js', 'views/library.js']) {
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', f), 'utf8');
// 只看真正拼 HTML 的行(含标签),DOM 选择器之类的插值不在此列
const bad = [];
src.split('\n').forEach((line, i) => {
if (!/<[a-z]/i.test(line)) return;
for (const m of line.match(/\$\{(?!escapeHtml|coverStyle)[^}]*\}/g) || []) {
if (/^\$\{(it|d|f|l|s|e|b)\.[a-zA-Z_]+\}$/.test(m)) bad.push(`${f}:${i + 1} ${m}`);
}
});
assert.deepStrictEqual(bad, [], `存在未转义的 HTML 插值:\n${bad.join('\n')}`);
}
});
test('index.html 保留 CSP 且未开启 nodeIntegration', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
assert.ok(/Content-Security-Policy/.test(html), '缺少 CSP');
assert.ok(/default-src 'self'/.test(html));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
assert.ok(/contextIsolation:\s*true/.test(main));
assert.ok(/nodeIntegration:\s*false/.test(main));
});
test('我的笔记页可新建关联或无关联笔记', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
assert.match(html, /data-tab="notes">我的笔记</);
assert.match(html, /id="addGlobalNoteBtn"/);
assert.match(notes, /window\.api\.library\.list\(\)/);
assert.match(notes, /source:\s*'manual'/);
assert.match(notes, /<option value="">不关联书籍<\/option>/);
assert.match(notes, /window\.api\.reader\.addNote\(entryId,\s*note\)/);
assert.match(notes, /window\.api\.reader\.addStandaloneNote\(note\)/);
assert.match(notes, /note\.associated === false\s*\?\s*'未关联书籍'/);
});
test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位卡', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/);
assert.match(html, /id="librarySearchBtn"/);
assert.match(html, /id="libraryClearSearchBtn"/);
assert.match(html, /id="sortSelect"[\s\S]*value="recent">最近阅读/);
assert.match(library, /recent:\s*\(a,\s*b\)[\s\S]*lastReadAt/);
assert.match(library, /function matchesSearch\(item, query\)/);
assert.match(library, /item\.authors/);
assert.match(library, /isSubsequence/);
assert.match(library, /function reconcileCards/);
assert.doesNotMatch(library, /grid\.innerHTML\s*=\s*items\.map/);
const sidebarRule = css.match(/\.library-sidebar\s*\{([^}]*)\}/);
assert.ok(sidebarRule);
assert.match(sidebarRule[1], /max-height:\s*calc\(100vh - 84px\)/);
assert.match(sidebarRule[1], /overflow-y:\s*auto/);
assert.match(css, /\.card:hover \.card-cover:not\(\[data-cover-state="pending"\]\)/);
assert.match(library, /data-cover-state="\$\{it\.cover \? 'ready' : 'pending'\}"/);
});
test('书库页提供可管理标签目录和整理多选下拉', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
assert.match(html, /id="libraryShelfList"/);
assert.match(html, /id="libraryTagList"/);
assert.match(html, /id="addTagBtn"/);
assert.match(library, /window\.api\.library\.listShelves\(\)/);
assert.match(library, /window\.api\.library\.addTag\(\{ name \}\)/);
assert.match(library, /window\.api\.library\.updateTag\(tag\.id/);
assert.match(library, /window\.api\.library\.removeTag\(tag\.id\)/);
assert.match(library, /cardAction\('organize'/);
assert.match(library, /<details id="libraryBookTags"/);
assert.match(library, /#libraryBookTags input\[type="checkbox"\]:checked/);
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
});
test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(browse, /createDownloadProgress/);
assert.match(browse, /updateDownloadProgress/);
assert.match(browse, /classList\.add\('downloaded'\)/);
const rule = css.match(/\.dl-btn\.downloaded\s*\{([^}]*)\}/);
assert.ok(rule, '缺少下载完成按钮样式');
assert.match(rule[1], /background:\s*var\(--green\)/);
assert.doesNotMatch(rule[1], /background:\s*var\(--accent\)/);
assert.match(rule[1], /color:\s*#07130b/);
});
test('主窗口在设置旁提供持久化明暗主题切换', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const themeAt = html.indexOf('id="uiThemeBtn"');
const settingsAt = html.indexOf('data-tab="settings"');
assert.ok(themeAt >= 0 && themeAt < settingsAt, '主题按钮不在设置按钮旁边');
assert.match(app, /window\.api\.ui\.getTheme\(\)/);
assert.match(app, /window\.api\.ui\.setTheme\(next\)/);
assert.match(css, /:root\[data-ui-theme="light"\]/);
assert.match(css, /--bg:\s*#f4f7fb/);
});
test('人民阅读器品牌与主题图标显示在界面左上角', () => {
for (const file of ['index.html', 'reader.html']) {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', file), 'utf8');
assert.match(html, /<title>PeopleLib<\/title>/);
assert.match(html, /人民阅读器/);
assert.match(html, /brand-logo-dark[^>]+icons\/dist\/dark\/icon-32\.png/);
assert.match(html, /brand-logo-light[^>]+icons\/dist\/light\/icon-32\.png/);
}
});
test('阅读器控件隔离正文选择并提供 PDF 适宽、拖拽和文本选择工具', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const pdfWorker = fs.readFileSync(path.join(__dirname, '..', 'ui', 'vendor', 'pdf.worker.range.mjs'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
assert.match(html, /id="fitWidthBtn"[\s\S]*aria-label="适应内容宽度"/);
assert.match(html, /data-annotation-tool="pan"[\s\S]*data-annotation-tool="text-select"/);
assert.match(css, /button,[\s\S]*\.statusbar,[\s\S]*user-select:\s*none/);
assert.match(shell, /isReaderControlTarget/);
assert.match(shell, /fitPdfWidth/);
assert.match(pdf, /function fitWidthScale/);
assert.match(pdf, /className = 'endOfContent'/);
assert.match(pdf, /const endPage = pageOfNode\(range\.endContainer\)/);
assert.match(pdf, /pdfx-tool-pan/);
assert.match(pdf, /extends pdfjs\.PDFDataRangeTransport/);
assert.match(pdf, /pdf\.worker\.range\.mjs/);
assert.match(pdf, /disableAutoFetch\s*=\s*true/);
assert.match(pdfWorker, /super\(new Uint8Array\(0\), 0, length, null\)/);
assert.doesNotMatch(pdfWorker, /super\(new Uint8Array\(length\), 0, length, null\)/);
assert.match(pdfWorker, /MAX_SPARSE_PDF_CACHE_BYTES = 256 \* 1024 \* 1024/);
assert.match(pdfWorker, /MAX_GROUPED_RANGE_CHUNKS = 4/);
assert.match(pdfWorker, /offset = offset \* 256 \+ offsetByte/);
assert.match(pdfWorker, /_loadedChunks\.delete\(chunk\)/);
// 稀疏基础缓冲区是空的,字体哈希不能再直接按 stream.bytes.buffer 建视图,
// 否则字体会静默变成不可见的 ErrorFont
assert.match(pdfWorker, /stream\.getByteRange\(stream\.start, stream\.end\)/);
assert.doesNotMatch(pdfWorker, /new Uint8Array\(stream\.bytes\.buffer, stream\.start, stream\.end - stream\.start\)/);
// 256 MB 以内仍用官方 worker,只有超出才启用稀疏 worker
assert.match(pdf, /STANDARD_WORKER_MAX_BYTES/);
assert.match(pdf, /SPARSE_WORKER_URL/);
assert.match(pdf, /this\.active < 8/);
assert.match(pdf, /Promise\.race\(\[task\.promise, rangeFailurePromise\]\)/);
assert.match(shell, /openPdfRangeSource/);
assert.match(shell, /api\.reader\.rangeRead/);
assert.match(preload, /rangeOpen:[\s\S]*reader:rangeOpen/);
assert.match(preload, /rangeRead:[\s\S]*reader:rangeRead/);
assert.match(preload, /rangeClose:[\s\S]*reader:rangeClose/);
});
test('AI 助手提供受限图像上下文和可扩展 OCR 契约', () => {
const index = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
const ocr = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'ocr-provider.mjs'), 'utf8');
const contract = fs.readFileSync(path.join(__dirname, '..', 'reader', 'visual-context.js'), 'utf8');
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
assert.match(index, /id="aiProtocol"[\s\S]*value="anthropic"[\s\S]*value="openai-responses"[\s\S]*value="chat-completions"/);
assert.match(index, /id="aiVision"[^>]*type="checkbox"/);
assert.match(app, /protocol:\s*\$\('aiProtocol'\)\.value/);
assert.match(reader, /value="page-image"[\s\S]*value="region-image"/);
assert.match(reader, /id="aiVisualCard"[\s\S]*id="aiOcrBtn"[\s\S]*disabled/);
assert.deepStrictEqual(
[...reader.matchAll(/data-ai-task="([^"]+)"/g)].map((match) => match[1]),
['summarize']
);
assert.match(shell, /function beginVisualSelection/);
assert.match(shell, /function confirmVisualSelection/);
assert.match(shell, /toAiVisualContext/);
assert.match(pdf, /async function captureVisual/);
assert.match(pdf, /function visualPageAtPoint/);
assert.match(epub, /function visualViewportRect/);
assert.match(ocr, /function registerOcrProvider/);
assert.match(ocr, /function recognizeOcr/);
assert.match(ocr, /signal:\s*options\.signal/);
assert.match(contract, /MAX_IMAGE_BYTES\s*=\s*3\s*\*\s*1024\s*\*\s*1024/);
assert.match(contract, /MAX_VISUAL_CONTEXTS\s*=\s*1/);
assert.match(contract, /图像内容与声明尺寸不匹配/);
assert.match(client, /type:\s*'image_url'/);
assert.match(client, /type:\s*'image'[\s\S]*type:\s*'base64'[\s\S]*media_type:/);
assert.match(client, /type:\s*'input_image'/);
assert.match(client, /当前模型配置未启用图像输入/);
assert.match(app, /模型已配置[\s\S]*尚缺 API Key/);
assert.match(app, /已保存的 API Key 无法读取,请重新输入/);
assert.match(shell, /模型已配置,但尚缺 API Key/);
assert.match(shell, /模型已配置,但已保存的 API Key 无法读取/);
assert.match(preload, /onChanged:\s*\(cb\)[\s\S]*ipcRenderer\.on\('ai:changed'/);
assert.match(shell, /api\.ai\.onChanged\(\(\)\s*=>\s*refreshAiStatus\(\)\)/);
assert.match(preload, /function captureReaderRect\(rect\)/);
assert.match(preload, /document\.getElementById\('docArea'\)/);
assert.match(preload, /captureRect:\s*\(rect\)\s*=>\s*captureReaderRect\(rect\)/);
assert.match(main, /ipcMain\.handle\('reader:captureRect'/);
assert.match(main, /截图区域无效或超出阅读器窗口/);
});
test('AI 上下文提供无需选中的当前页与全文范围', () => {
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
assert.match(reader, /<option value="document">全文<\/option>/);
assert.doesNotMatch(reader, /value="chapter"/);
// 只有 selection 需要选区,page/document 直接走 textOf
assert.match(shell, /if \(scope === 'selection'\)[\s\S]{0,400}请先在正文中选中文本/);
assert.match(shell, /scope === 'page' \? 'page' : 'document'/);
// 全文必须提示可能超限,并且始终弹确认框
assert.match(shell, /可能超过模型限制/);
assert.match(shell, /全文可能超过模型的上下文限制/);
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
// 旧设置迁移,避免升级后回落成 selection
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
assert.match(shell, /api\.settings\.set\('reader\.aiScope', 'document'\)/);
// 适配器真的取整本,而不是当前页 ±1
assert.match(pdf, /if \(span !== 'document'\) return pageText\(page\)/);
assert.match(pdf, /for \(let i = 1; i <= pageCount; i\+\+\)/);
assert.match(epub, /if \(span === 'document'\)[\s\S]{0,400}chapter < spine\.length/);
});
test('AI 图像上下文按更小的目标体积压缩且只用 JPEG', () => {
const visual = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'visual-context.mjs'), 'utf8');
assert.match(visual, /MAX_CAPTURE_DIMENSION = 1600/);
assert.match(visual, /TARGET_CAPTURE_BYTES = 400 \* 1024/);
assert.match(visual, /const qualities = \[0\.82, 0\.74, 0\.66, 0\.58\]/);
assert.match(visual, /bytes <= TARGET_CAPTURE_BYTES/);
// 缩到 800px 就停手,避免文字页被压糊
assert.match(visual, /<= 800\) break/);
// 只保留一条 JPEG 编码路径,不做格式回退
assert.deepStrictEqual([...visual.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]), ['image/jpeg']);
});
test('AI 回答使用固定版本 Markdown-it 和 DOMPurify 安全渲染', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const renderer = fs.readFileSync(path.join(__dirname, '..', 'ui', 'ai-markdown.js'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
assert.strictEqual(pkg.devDependencies['markdown-it'], '15.0.0');
assert.strictEqual(pkg.devDependencies.dompurify, '3.4.12');
assert.match(html, /vendor\/purify\.min\.js[\s\S]*vendor\/markdown-it\.min\.js[\s\S]*ai-markdown\.js/);
assert.match(renderer, /html:\s*false/);
assert.match(renderer, /purifier\.sanitize/);
assert.match(renderer, /renderer\.rules\.image/);
assert.match(renderer, /data-external-url/);
assert.match(renderer, /MAX_MARKDOWN_LENGTH\s*=\s*256\s*\*\s*1024/);
assert.match(shell, /scheduleAiOutput/);
assert.match(shell, /window\.AiMarkdown\.externalUrl/);
assert.match(shell, /addEventListener\('auxclick'/);
assert.match(css, /\.ai-output pre[\s\S]*overflow:\s*auto/);
for (const file of [
'vendor/markdown-it.min.js',
'vendor/markdown-it.LICENSE.txt',
'vendor/purify.min.js',
'vendor/DOMPurify.LICENSE.txt'
]) {
assert.ok(fs.existsSync(path.join(__dirname, '..', 'ui', file)), `${file} 未随应用提供`);
}
});
test('安装包和可执行文件保留 PeopleLib 产品名', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
assert.strictEqual(pkg.build.productName, 'PeopleLib');
assert.strictEqual(pkg.build.portable.artifactName, 'PeopleLib-${version}.exe');
assert.match(main, /app\.setName\('PeopleLib'\)/);
assert.match(build, /const PRODUCT = pkg\.productName \|\| 'PeopleLib'/);
});
test('设置关于页与 README 列出书库和内置阅读格式', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
assert.match(html, /关于 PeopleLib/);
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
assert.match(readme, /## 支持格式/);
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
});
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
assert.match(adapter, /该 MOBI\/AZW 图书有 DRM 保护/);
assert.match(shell, /使用系统应用打开/);
assert.match(build, /node_modules', 'foliate-js'/);
});
test('书库卡片操作使用带悬浮提示的纯图标按钮', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /const CARD_ICONS =/);
assert.match(library, /class="\$\{primary \? 'open-btn ' : ''\}icon-action"/);
assert.match(library, /title="\$\{label\}" aria-label="\$\{label\}"/);
for (const action of ['read', 'open', 'reveal', 'page', 'organize', 'remove']) {
assert.match(library, new RegExp(`cardAction\\('${action}'`));
}
});
test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版', () => {
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
const rich = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.js'), 'utf8');
const mixed = fs.readFileSync(path.join(__dirname, '..', 'ui', 'mixed-note.js'), 'utf8');
const canvas = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-note.mjs'), 'utf8');
const canvasFlow = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-flow.mjs'), 'utf8');
const richCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.css'), 'utf8');
const appCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const readerCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
const readerHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const indexHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const store = fs.readFileSync(path.join(__dirname, '..', 'reader', 'store.js'), 'utf8');
const assets = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-assets.js'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
assert.match(notes, /id="newNoteRich"/);
assert.match(notes, /id="noteEditRich"/);
assert.match(notes, /window\.MixedNote\.mount/);
assert.match(notes, /选择笔记类型/);
assert.match(notes, /noteType/);
assert.match(readerHtml, /id="noteRichEditor"/);
assert.match(indexHtml, /vendor\/quill\/quill\.js/);
assert.match(indexHtml, /vendor\/quill\/quill\.snow\.css/);
assert.match(readerHtml, /vendor\/jspdf\.umd\.min\.js/);
assert.match(readerHtml, /<script src="rich-note\.js"><\/script>/);
assert.match(rich, /new window\.Quill/);
assert.match(rich, /version:\s*2,\s*ops/);
assert.doesNotMatch(rich, /document\.execCommand/);
assert.ok(
rich.indexOf("header.className = 'ql-header'") < rich.indexOf("['bold', '加粗']"),
'段落类型必须位于 B/I 等格式按钮之前'
);
assert.match(richCss, /\.ql-toolbar \.ql-picker-options/);
assert.match(richCss, /background:\s*var\(--bg-card\)/);
assert.match(richCss, /color:\s*var\(--text\)/);
assert.match(rich, /image\/jpeg,image\/png,image\/gif,image\/webp/);
assert.match(rich, /单张图片不能超过 2 MB/);
assert.match(mixed, /function mountTyped/);
assert.match(mixed, /options\.noteType/);
assert.match(indexHtml, /id="notesTypeTabs"/);
assert.match(indexHtml, />全部</);
assert.match(indexHtml, />画布笔记</);
assert.match(indexHtml, />读书笔记</);
assert.match(appCss, /\.notes-list[\s\S]*grid-template-columns/);
assert.match(canvas, /Import PDF|导入 PDF/);
assert.match(canvas, /Export PDF|导出 PDF/);
assert.match(canvas, /canvasKind/);
assert.match(canvas, /MAX_PAGES = 50/);
assert.match(canvas, /const BUTTON_ICONS =/);
assert.match(canvas, /canvas-note-icon/);
assert.match(canvas, /canvas-note-tool-group/);
assert.match(canvas, /\['flow-text', '全局文本'\]/);
assert.match(canvas, /mountFlowText/);
assert.match(canvasFlow, /canvasPageBreak/);
assert.match(canvasFlow, /columnWidth/);
assert.match(canvasFlow, /onPageCount/);
assert.match(canvasFlow, /renderPage/);
assert.match(canvasFlow, /suppressUserFollowSelection/);
assert.match(canvas, /await flowEditor\.flush\(\)/);
assert.match(canvas, /insertedPageId/);
assert.match(richCss, /\.canvas-flow-toolbar/);
assert.match(richCss, /\.canvas-flow-layer/);
const toolbarRule = richCss.match(/\.canvas-note-toolbar\s*\{([^}]*)\}/);
const viewportRule = richCss.match(/\.canvas-note-viewport\s*\{([^}]*)\}/);
const mainCanvasBodyRule = appCss.match(/\.canvas-note-modal \.modal-body\s*\{([^}]*)\}/);
const readerCanvasFieldsRule = readerCss.match(
/\.canvas-note-modal \.note-editor-fields\s*\{([^}]*)\}/
);
assert.ok(toolbarRule && viewportRule && mainCanvasBodyRule && readerCanvasFieldsRule);
assert.match(toolbarRule[1], /flex-wrap:\s*wrap/);
assert.match(toolbarRule[1], /overflow:\s*visible/);
assert.match(viewportRule[1], /overflow:\s*auto/);
assert.match(mainCanvasBodyRule[1], /overflow:\s*hidden/);
assert.match(readerCanvasFieldsRule[1], /overflow:\s*hidden/);
assert.match(store, /richImageTotalBytes:\s*8 \* 1024 \* 1024/);
assert.match(store, /normalizeCanvasContent/);
assert.match(store, /const VERSION = 6/);
assert.match(store, /normalizeCanvasFlow/);
assert.match(store, /NOTE_TYPES/);
assert.match(assets, /reader-note-assets/);
assert.match(assets, /senderId/);
assert.doesNotMatch(notes, /id="newNoteText"|id="noteEditText"/);
const functionAt = browse.indexOf('async function downloadFile');
const awaitAt = browse.indexOf('await window.api.library.findBySource', functionAt);
const snapshotAt = browse.indexOf('const meta = entryMeta()', functionAt);
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
});
test('书架操作对键盘焦点可见', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
});
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const titleRule = css.match(/\.card-title\s*\{([^}]*)\}/);
assert.ok(titleRule, '缺少书库标题样式');
assert.match(titleRule[1], /white-space:\s*nowrap/);
assert.match(titleRule[1], /overflow:\s*hidden/);
assert.match(titleRule[1], /text-overflow:\s*ellipsis/);
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
});
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /data-act="read" role="button" tabindex="0"/);
assert.match(library, /cover\.onclick[\s\S]*onAction\(id, 'read'\)/);
assert.match(library, /event\.key !== 'Enter' && event\.key !== ' '/);
assert.match(library, /window\.api\.reader\.open\(id, idx >= 0 \? idx : undefined\)/);
});