Files
peoplelib/src/_test/electron/reader-features.integration.js
T
lofyer 6841cfe38f
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
test: 焦点锚定容差按实际每行字数算,不再写死字符数
2026-08-05 20:19:08 +08:00

1747 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, dialog, safeStorage, nativeImage } = require('electron');
const fs = require('fs');
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-reader-features-'));
const PDF_FILE = path.join(TMP, 'reader-features.pdf');
const EPUB_FILE = path.join(TMP, 'reader-features.epub');
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
const TXT_FILE = path.join(TMP, 'reader-features.txt');
const MD_FILE = path.join(TMP, 'reader-features.md');
app.setPath('userData', TMP);
app.setPath('appData', TMP);
const results = [];
function check(name, condition, detail = '') {
results.push([condition ? 'OK' : 'FAIL', name, detail]);
}
function imageHasInk(dataUrl) {
const image = nativeImage.createFromDataURL(String(dataUrl || ''));
if (image.isEmpty()) return false;
const bitmap = image.toBitmap();
let dark = 0;
for (let offset = 0; offset + 3 < bitmap.length; offset += 200) {
if (bitmap[offset] < 238 || bitmap[offset + 1] < 238 || bitmap[offset + 2] < 238) dark++;
}
return dark >= 8;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitUntil(predicate, timeout = 10000, interval = 100) {
const deadline = Date.now() + timeout;
let lastError = null;
while (Date.now() < deadline) {
try {
const value = await predicate();
if (value) return value;
} catch (error) {
lastError = error;
}
await wait(interval);
}
if (lastError) throw lastError;
throw new Error(`等待条件超时(${timeout}ms`);
}
function makePdf(file) {
const pageText = [
'Page One reader integration fixture.',
'Page Two selectable text validates excerpt and note storage.',
'Page Three reader integration fixture.'
];
const objects = new Array(10);
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
objects[2] = '<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>';
for (let i = 0; i < 3; i++) {
objects[3 + i] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents ${7 + i} 0 R >>`;
}
objects[6] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
for (let i = 0; i < 3; i++) {
const stream = [
'BT',
'/F1 18 Tf',
'72 720 Td',
`(${pageText[i]}) Tj`,
'0 -34 Td',
'(Touch gestures must preserve the focal document location.) Tj',
'ET',
''
].join('\n');
objects[7 + i] = `<< /Length ${Buffer.byteLength(stream, 'ascii')} >>\nstream\n${stream}endstream`;
}
let pdf = '%PDF-1.4\n';
const offsets = new Array(objects.length).fill(0);
for (let i = 1; i < objects.length; i++) {
offsets[i] = Buffer.byteLength(pdf, 'ascii');
pdf += `${i} 0 obj\n${objects[i]}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf, 'ascii');
pdf += `xref\n0 ${objects.length}\n`;
pdf += '0000000000 65535 f \n';
for (let i = 1; i < objects.length; i++) {
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
fs.writeFileSync(file, pdf, 'ascii');
}
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" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`);
zip.file('OEBPS/content.opf', `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">reader-features-fixture</dc:identifier>
<dc:title>Reader Features EPUB Fixture</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
</manifest>
<spine><itemref idref="chapter"/></spine>
</package>`);
const paragraphs = Array.from({ length: 90 }, (_, index) => (
`<p${index === 44 ? ' id="section-mid"' : ''}>Paragraph ${index + 1}. EPUB focal anchor sentence ${index + 1} keeps a nearby character offset after a synthetic pinch gesture. Selection excerpt text remains available to reader notes.${index === 0 ? ' <a href="#section-mid">Jump to middle section</a>' : ''}</p>`
)).join('\n');
zip.file('OEBPS/chapter.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Touch Chapter</title></head>
<body><h1>Touch Chapter</h1>${paragraphs}</body>
</html>`);
zip.file('OEBPS/nav.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Contents</title></head>
<body><nav epub:type="toc"><ol>
<li><a href="chapter.xhtml">Start</a></li>
<li><a href="chapter.xhtml#section-mid">Middle Section</a></li>
</ol></nav></body>
</html>`);
fs.writeFileSync(file, await zip.generateAsync({
type: 'nodebuffer',
mimeType: 'application/epub+zip',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
}));
}
function makeMobi(file) {
const text = Buffer.from(
'<html><head><title>MOBI Chapter</title></head><body>'
+ '<h1>MOBI Chapter</h1><p>MOBI selectable text validates the Foliate parser, '
+ 'built-in rendering, bookmarks, excerpts, notes, and stable reading progress.</p>'
+ '<script>window.__mobiScriptExecuted = true</script>'
+ '<img src="https://untrusted.example/tracker.png" onerror="window.__mobiHandlerExecuted=true">'
+ '<a href="javascript:window.__mobiLinkExecuted=true">unsafe link</a>'
+ '</body></html>',
'utf8'
);
const compressed = Buffer.concat(Array.from(
{ length: Math.ceil(text.length / 8) },
(_, index) => {
const chunk = text.subarray(index * 8, index * 8 + 8);
return Buffer.concat([Buffer.from([chunk.length]), chunk]);
}
));
const record0Length = 320;
const record0Offset = 96;
const record1Offset = record0Offset + record0Length;
const output = Buffer.alloc(record1Offset + compressed.length);
output.write('Reader Features MOBI', 0, 'ascii');
output.write('BOOK', 60, 'ascii');
output.write('MOBI', 64, 'ascii');
output.writeUInt16BE(2, 76);
output.writeUInt32BE(record0Offset, 78);
output.writeUInt32BE(record1Offset, 86);
output.writeUInt16BE(2, record0Offset);
output.writeUInt16BE(1, record0Offset + 8);
output.writeUInt16BE(4096, record0Offset + 10);
output.writeUInt16BE(0, record0Offset + 12);
output.write('MOBI', record0Offset + 16, 'ascii');
output.writeUInt32BE(248, record0Offset + 20);
output.writeUInt32BE(2, record0Offset + 24);
output.writeUInt32BE(65001, record0Offset + 28);
output.writeUInt32BE(12345, record0Offset + 32);
output.writeUInt32BE(6, record0Offset + 36);
const title = Buffer.from('Reader Features MOBI Fixture', 'utf8');
output.writeUInt32BE(270, record0Offset + 84);
output.writeUInt32BE(title.length, record0Offset + 88);
output[record0Offset + 94] = 0;
output[record0Offset + 95] = 9;
output.writeUInt32BE(2, record0Offset + 108);
output.writeUInt32BE(0xffffffff, record0Offset + 112);
output.writeUInt32BE(0, record0Offset + 116);
output.writeUInt32BE(0, record0Offset + 128);
output.writeUInt32BE(0, record0Offset + 240);
output.writeUInt32BE(0xffffffff, record0Offset + 244);
title.copy(output, record0Offset + 270);
compressed.copy(output, record1Offset);
fs.writeFileSync(file, output);
}
function makeTxt(file) {
const lines = ['纯文本夹具标题', ''];
for (let i = 1; i <= 3; i++) {
lines.push(`第${i}章 编码与分章`, '');
for (let k = 0; k < 12; k++) {
lines.push(`第${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
}
lines.push('');
}
const text = lines.join('\r\n');
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
const body = Buffer.from(text, 'utf16le');
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
return text;
}
function makeMd(file) {
const source = [
'# Markdown 夹具',
'',
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
'',
'## 危险内容小节',
'',
'<script>window.__mdScriptExecuted = true</script>',
'',
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
'',
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
'',
'<iframe src="https://untrusted.example/frame"></iframe>',
'',
'## 结构小节',
'',
'- 列表项一',
'- 列表项二',
'',
'```js',
'const fenced = "code block";',
'```',
'',
'| 列一 | 列二 |',
'|---|---|',
'| 单元格 | MD-TABLE-CELL |',
'',
'> 引用块 MD-QUOTE-MARK',
''
].join('\n');
fs.writeFileSync(file, source, 'utf8');
return source;
}
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 waitForJs(win, source, timeout = 15000) {
return waitUntil(async () => {
if (win.isDestroyed()) throw new Error('阅读器窗口已关闭');
return js(win, source);
}, timeout, 120);
}
async function openReader(entryId, fileIndex) {
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, fileIndex: String(fileIndex) }
});
return { win, errors };
}
async function selectPdfText(win, page) {
return js(win, `(() => {
const root = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-text');
const span = root && Array.from(root.querySelectorAll('span')).find((item) => {
return item.firstChild && item.firstChild.nodeType === Node.TEXT_NODE
&& item.firstChild.data.trim().length >= 12;
});
if (!span) return null;
const node = span.firstChild;
const start = Math.min(5, node.data.length - 2);
const end = Math.min(node.data.length, start + 24);
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const rect = range.getBoundingClientRect();
document.dispatchEvent(new MouseEvent('mouseup', {
bubbles: true,
clientX: rect.left + Math.max(1, rect.width / 2),
clientY: rect.top + Math.max(1, rect.height / 2)
}));
return { text: selection.toString(), offset: start };
})()`);
}
async function drawPdfTouchStroke(win, page) {
return js(win, `(async () => {
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
const view = canvas.ownerDocument.defaultView;
const rect = canvas.getBoundingClientRect();
const point = (id, x, y) => {
const init = {
identifier: id, target: canvas,
clientX: rect.left + x, clientY: rect.top + y,
pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
screenX: rect.left + x, screenY: rect.top + y,
radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
};
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
};
const fire = (type, touches, changed) => {
let event;
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
event = new view.TouchEvent(type, {
bubbles: true, cancelable: true, composed: true,
touches, targetTouches: touches, changedTouches: changed, view
});
} else {
// Some headless Chromium builds omit Touch; preserve the real touch event path with explicit lists.
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
Object.defineProperties(event, {
touches: { value: touches },
targetTouches: { value: touches },
changedTouches: { value: changed }
});
}
canvas.dispatchEvent(event);
};
let touch = point(31, 90, 150);
fire('touchstart', [touch], [touch]);
for (const [x, y] of [[125, 165], [170, 180], [220, 205]]) {
touch = point(31, x, y);
fire('touchmove', [touch], [touch]);
await new Promise((resolve) => setTimeout(resolve, 25));
}
fire('touchend', [], [touch]);
return typeof view.Touch === 'function' ? 'native' : 'fallback';
})()`);
}
async function pinchPdf(win, page) {
return js(win, `(async () => {
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
const view = canvas.ownerDocument.defaultView;
const rect = canvas.getBoundingClientRect();
const cx = rect.left + rect.width * 0.5;
const cy = rect.top + rect.height * 0.35;
const point = (id, x, y) => {
const init = {
identifier: id, target: canvas,
clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
};
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
};
const fire = (type, touches, changed) => {
let event;
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
event = new view.TouchEvent(type, {
bubbles: true, cancelable: true, composed: true,
touches, targetTouches: touches, changedTouches: changed, view
});
} else {
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
Object.defineProperties(event, {
touches: { value: touches },
targetTouches: { value: touches },
changedTouches: { value: changed }
});
}
canvas.dispatchEvent(event);
};
let a = point(41, cx - 60, cy);
let b = point(42, cx + 60, cy);
fire('touchstart', [a, b], [a, b]);
await new Promise((resolve) => setTimeout(resolve, 60));
a = point(41, cx - 105, cy);
b = point(42, cx + 105, cy);
fire('touchmove', [a, b], [a, b]);
await new Promise((resolve) => setTimeout(resolve, 80));
fire('touchend', [], [a, b]);
return typeof view.Touch === 'function' ? 'native' : 'fallback';
})()`);
}
async function rollbackPdfStroke(win, page) {
return js(win, `(async () => {
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
const view = canvas.ownerDocument.defaultView;
const rect = canvas.getBoundingClientRect();
const point = (id, x, y) => {
const init = {
identifier: id, target: canvas,
clientX: rect.left + x, clientY: rect.top + y,
pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
screenX: rect.left + x, screenY: rect.top + y,
radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
};
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
};
const fire = (type, touches, changed) => {
let event;
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
event = new view.TouchEvent(type, {
bubbles: true, cancelable: true, composed: true,
touches, targetTouches: touches, changedTouches: changed, view
});
} else {
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
Object.defineProperties(event, {
touches: { value: touches },
targetTouches: { value: touches },
changedTouches: { value: changed }
});
}
canvas.dispatchEvent(event);
};
let first = point(51, 120, 260);
fire('touchstart', [first], [first]);
first = point(51, 180, 285);
fire('touchmove', [first], [first]);
await new Promise((resolve) => setTimeout(resolve, 40));
const second = point(52, 300, 285);
fire('touchstart', [first, second], [second]);
await new Promise((resolve) => setTimeout(resolve, 300));
fire('touchend', [], [first, second]);
})()`);
}
async function pinchEpub(win) {
return js(win, `(async () => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const view = frame.contentWindow;
const outer = document.querySelector('.epub-scroll');
const outerRect = outer.getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
const focalX = 260 + outerRect.left - frameRect.left;
const focalY = 190 + outerRect.top - frameRect.top;
const target = doc.elementFromPoint(focalX, focalY) || doc.body;
const textOffsetAtPoint = () => {
let point = null;
if (typeof doc.caretPositionFromPoint === 'function') {
const caret = doc.caretPositionFromPoint(focalX, focalY);
if (caret) point = { node: caret.offsetNode, index: caret.offset };
} else if (typeof doc.caretRangeFromPoint === 'function') {
const range = doc.caretRangeFromPoint(focalX, focalY);
if (range) point = { node: range.startContainer, index: range.startOffset };
}
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
let offset = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (point && node === point.node) return offset + Math.max(0, point.index || 0);
if (point && point.node && point.node.nodeType === Node.ELEMENT_NODE && point.node.contains(node)) {
return offset;
}
offset += node.data.length;
}
return 0;
};
const point = (id, x, y) => {
const init = {
identifier: id, target,
clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
};
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
};
const fire = (type, touches, changed) => {
let event;
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
event = new view.TouchEvent(type, {
bubbles: true, cancelable: true, composed: true,
touches, targetTouches: touches, changedTouches: changed, view
});
} else {
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
Object.defineProperties(event, {
touches: { value: touches },
targetTouches: { value: touches },
changedTouches: { value: changed }
});
}
target.dispatchEvent(event);
};
const anchorOffset = textOffsetAtPoint();
const before = parseFloat(view.getComputedStyle(doc.body).fontSize);
let a = point(61, focalX - 60, focalY);
let b = point(62, focalX + 60, focalY);
fire('touchstart', [a, b], [a, b]);
await new Promise((resolve) => setTimeout(resolve, 60));
a = point(61, focalX - 90, focalY);
b = point(62, focalX + 90, focalY);
fire('touchmove', [a, b], [a, b]);
await new Promise((resolve) => setTimeout(resolve, 80));
fire('touchend', [], [a, b]);
return {
anchorOffset,
before,
mode: typeof view.Touch === 'function' ? 'native' : 'fallback'
};
})()`);
}
async function selectEpubText(win, value = 'EPUB focal anchor sentence') {
const needle = JSON.stringify(value);
return js(win, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node && !node.data.includes(${needle})) node = walker.nextNode();
if (!node) return null;
const start = node.data.indexOf(${needle});
const end = start + ${needle}.length;
const range = doc.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const selection = doc.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const rect = range.getBoundingClientRect();
doc.dispatchEvent(new frame.contentWindow.MouseEvent('mouseup', {
bubbles: true,
clientX: rect.left + Math.max(1, rect.width / 2),
clientY: rect.top + Math.max(1, rect.height / 2)
}));
return { text: selection.toString(), offset: start };
})()`);
}
function printSummary() {
console.log('\n========== 阅读功能 Electron 集成验证 ==========');
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}`);
return failed;
}
app.whenReady().then(async () => {
makePdf(PDF_FILE);
await makeEpub(EPUB_FILE);
makeMobi(MOBI_FILE);
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
makeTxt(TXT_FILE);
makeMd(MD_FILE);
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
drmFixture.writeUInt16BE(1, 96 + 12);
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
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 rangeSessions = require(path.join(ROOT, 'src', 'reader', 'range-sessions'));
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
const managedReader = require(path.join(ROOT, 'src', 'reader', 'window'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
settings.init(TMP);
readerStore.init(TMP);
annotations.init(TMP);
aiConfig.init(TMP, safeStorage);
aiConfig.save({
protocol: 'chat-completions',
baseUrl: 'http://127.0.0.1:65534/v1',
model: 'vision-fixture',
apiKey: '',
vision: true
});
library.init(path.join(TMP, 'library'));
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
const entry = library.add({
title: 'Reader Feature Fixtures',
authors: ['Integration Test'],
files: [
{ path: PDF_FILE, name: 'reader-features.pdf', format: 'PDF' },
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
]
});
await wait(1200);
for (const window of BrowserWindow.getAllWindows()) window.hide();
check('本地 PDF、EPUB 和 MOBI 固定夹具已创建',
fs.existsSync(PDF_FILE)
&& fs.existsSync(EPUB_FILE)
&& fs.existsSync(MOBI_FILE)
&& fs.existsSync(DRM_MOBI_FILE)
&& fs.statSync(LARGE_EPUB_FILE).size === 256 * 1024 * 1024 + 1);
const pdfReader = await openReader(entry.id, 0);
const pdfWin = pdfReader.win;
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas')?.width > 0`);
check('PDF 通过真实主进程 IPC 渲染', await js(pdfWin,
`document.querySelectorAll('.pdfx-page').length === 3`));
const rangeStatus = rangeSessions.status();
check('PDF 通过发送者隔离的分段会话读取',
rangeStatus.sessions === 1 && rangeStatus.bytesRead > 0,
`${rangeStatus.bytesRead} bytes`);
const failedRange = await js(pdfWin, `(async () => {
const module = await import('./reader/pdf-adapter.mjs');
const adapter = module.createPdfAdapter();
let closed = false;
try {
await Promise.race([
adapter.load({
kind: 'range',
size: 4096,
chunkSize: 1024,
read: async () => { throw new Error('range fixture failure'); },
close: async () => { closed = true; }
}),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);
return { rejected: false, closed };
} catch (error) {
return { rejected: true, message: error.message, closed };
} finally {
adapter.destroy();
}
})()`);
check('PDF 分段读取失败会立即拒绝加载而不是永久等待',
failedRange.rejected && failedRange.closed
&& failedRange.message.includes('range fixture failure'),
JSON.stringify(failedRange));
check('右侧面板包含书签、标注、笔记和 AI 标签', await js(pdfWin, `(
Array.from(document.querySelectorAll('.pane-tab')).map((button) => button.dataset.pane).join(',')
=== 'bookmarks,annotations,notes,ai'
)`));
await waitForJs(pdfWin, `(() => {
const controls = document.getElementById('pdfViewControls');
return !controls.classList.contains('hidden')
&& document.getElementById('posLabel').textContent === '第 1 页'
&& document.getElementById('pdfViewMode').value === 'continuous'
&& document.getElementById('pdfPageLayout').value === 'single'
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-view-continuous')
&& document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-single');
})()`);
check('PDF 底栏默认显示连续和单页两个独立选项', true);
pdfWin.setSize(760, 700);
await wait(150);
check('窄窗口仍保留 PDF 阅读和版式控件', await js(pdfWin, `(() => {
const bar = document.querySelector('.statusbar');
const controls = document.getElementById('pdfViewControls');
return !controls.classList.contains('hidden') && bar.scrollWidth <= bar.clientWidth;
})()`));
pdfWin.setSize(2200, 1000);
await js(pdfWin, `(() => {
const select = document.getElementById('pdfPageLayout');
select.value = 'auto';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const pages = document.querySelectorAll('.pdfx-page');
return document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-auto')
&& pages[0].offsetTop === pages[1].offsetTop;
})()`);
check('自动版式在宽窗口并排显示多页', await js(pdfWin, `(() => {
const pages = document.querySelectorAll('.pdfx-page');
return pages[0].offsetTop === pages[1].offsetTop
&& pages[0].offsetLeft !== pages[1].offsetLeft;
})()`));
await js(pdfWin, `(() => {
const select = document.getElementById('pdfViewMode');
select.value = 'paged';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `getComputedStyle(document.querySelector('.pdfx-scroller'))
.scrollSnapType.includes('mandatory')`);
check('分页模式启用按行滚动吸附', await js(pdfWin, `(() => {
const scroller = document.querySelector('.pdfx-scroller');
const pages = document.querySelectorAll('.pdfx-page');
return scroller.classList.contains('pdfx-view-paged')
&& pages[0].offsetTop === pages[1].offsetTop
&& pages[2].offsetTop > pages[0].offsetTop;
})()`));
await waitUntil(() => Promise.resolve(
settings.get('reader.pdfViewMode', '') === 'paged'
&& settings.get('reader.pdfPageLayout', '') === 'auto'
));
check('PDF 阅读和版式偏好已持久化', true);
await js(pdfWin, `document.getElementById('nextBtn').click()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
check('自动多页的分页导航按整行前进', true);
await js(pdfWin, `(() => {
const mode = document.getElementById('pdfViewMode');
mode.value = 'continuous';
mode.dispatchEvent(new Event('change'));
const layout = document.getElementById('pdfPageLayout');
layout.value = 'single';
layout.dispatchEvent(new Event('change'));
})()`);
pdfWin.setSize(1280, 900);
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
.classList.contains('pdfx-layout-single')`);
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '0';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
const canvasProbe = `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
const css = canvas.getBoundingClientRect().width;
return {
dpr: window.devicePixelRatio,
backing: canvas.width,
css: Math.round(css),
ratio: canvas.width / css
};
})()`;
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
return canvas && canvas.width > 0;
})()`);
check('标准画质下第 1 页有真实墨迹',
imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
const qualityBase = await js(pdfWin, canvasProbe);
// 生效倍率是 max(dpr, quality)HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
check('默认画质下 backing 比例等于设备像素比',
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '${targetQuality}';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
})()`);
const qualityHigh = await js(pdfWin, canvasProbe);
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
&& qualityHigh.backing > qualityBase.backing
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
.filter((canvas) => canvas.width > 0)
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
check('画质切换后同屏各页倍率一致',
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
JSON.stringify(qualityRatios));
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
let highInk = false;
try {
await waitUntil(async () => {
highInk = imageHasInk(await js(pdfWin,
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
return highInk;
}, 15000, 250);
} catch (error) { /* 交给下面的断言报告 */ }
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
await waitUntil(() => Promise.resolve(
settings.get('reader.pdfRenderQuality', 0) === targetQuality
));
check('PDF 画质偏好已持久化', true);
await js(pdfWin, `(() => {
const select = document.getElementById('pdfRenderQuality');
select.value = '1';
select.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `(() => {
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
if (!canvas || !canvas.width) return false;
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
})()`);
check('画质调回标准档后恢复设备像素比', true);
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '1000';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
try {
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
.some((node) => node.firstChild && node.firstChild.data.trim())`);
} catch (error) {
const state = await js(pdfWin, `(() => ({
position: document.getElementById('posLabel').textContent,
scrollTop: document.querySelector('.pdfx-scroller').scrollTop,
pages: Array.from(document.querySelectorAll('.pdfx-page')).map((page) => ({
page: page.dataset.page,
top: page.offsetTop,
canvas: page.querySelector('.pdfx-canvas')?.width || 0,
spans: page.querySelectorAll('.pdfx-text span').length
}))
}))()`);
throw new Error(`${error.message}; ${JSON.stringify(state)}`);
}
const selectionClearedByZoom = await js(pdfWin, `(() => {
const span = Array.from(document.querySelectorAll('.pdfx-text span'))
.find((node) => node.firstChild && node.firstChild.data.trim());
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(span);
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
const button = document.getElementById('zoomOutBtn');
button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
button.click();
return !selection.toString()
&& document.getElementById('selBar').classList.contains('hidden');
})()`);
check('点击缩放等阅读器控件会清除正文选区且不重新触发划选工具条',
selectionClearedByZoom);
await waitForJs(pdfWin,
`document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`);
const fitPage = await js(pdfWin, `document.getElementById('posLabel').textContent`);
pdfWin.setSize(900, 700);
await wait(180);
await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
await waitForJs(pdfWin, `(() => {
const page = document.querySelector('.pdfx-page[data-page="3"]');
const scroller = document.querySelector('.pdfx-scroller');
return page && page.querySelector('.pdfx-canvas').width > 0
&& Math.abs(page.getBoundingClientRect().width - (scroller.clientWidth - 32)) <= 2;
})()`, 20000);
const narrowFit = await js(pdfWin, `(() => {
const page = document.querySelector('.pdfx-page[data-page="3"]');
const scroller = document.querySelector('.pdfx-scroller');
return {
zoom: Number.parseFloat(document.getElementById('zoomLabel').textContent),
pageWidth: page.getBoundingClientRect().width,
available: scroller.clientWidth - 32,
position: document.getElementById('posLabel').textContent
};
})()`);
check('PDF 适应内容宽度在窄窗口贴合可用宽度并保持当前页',
Math.abs(narrowFit.pageWidth - narrowFit.available) <= 2
&& narrowFit.position === fitPage,
JSON.stringify(narrowFit));
await js(pdfWin, `document.getElementById('zoomInBtn').click()`);
await waitForJs(pdfWin,
`Number.parseFloat(document.getElementById('zoomLabel').textContent) > ${narrowFit.zoom}`);
const manualZoom = await js(pdfWin,
`Number.parseFloat(document.getElementById('zoomLabel').textContent)`);
await js(pdfWin, `document.getElementById('zoomOutBtn').click()`);
await waitForJs(pdfWin,
`Number.parseFloat(document.getElementById('zoomLabel').textContent) < ${manualZoom}`);
check('适宽产生的非预设比例之后仍可正常手动放大和缩小', true);
await js(pdfWin, `(() => {
const layout = document.getElementById('pdfPageLayout');
layout.value = 'auto';
layout.dispatchEvent(new Event('change'));
})()`);
pdfWin.setSize(1900, 900);
await wait(220);
await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`,
20000);
check('自动多页版式适宽时整行内容保持在可用宽度内', await js(pdfWin, `(() => {
const scroller = document.querySelector('.pdfx-scroller');
const rows = new Map();
for (const page of document.querySelectorAll('.pdfx-page')) {
const top = page.offsetTop;
const row = rows.get(top) || [];
row.push(page);
rows.set(top, row);
}
return [...rows.values()].every((row) => {
const first = row[0].getBoundingClientRect();
const last = row[row.length - 1].getBoundingClientRect();
return last.right - first.left <= scroller.clientWidth - 30;
}) && document.getElementById('posLabel').textContent === ${JSON.stringify(fitPage)};
})()`));
await js(pdfWin, `(() => {
const layout = document.getElementById('pdfPageLayout');
layout.value = 'single';
layout.dispatchEvent(new Event('change'));
})()`);
pdfWin.setSize(1280, 900);
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
.classList.contains('pdfx-layout-single')`);
await js(pdfWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '500';
range.dispatchEvent(new Event('change'));
})()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="2"] .pdfx-text span')?.textContent.length > 0`);
await js(pdfWin, `document.querySelector('[data-pane="ai"]').click();
var s=document.getElementById('aiScope');
s.value='page-image';
s.dispatchEvent(new Event('change'));`);
await waitForJs(pdfWin, `(() => {
const card = document.getElementById('aiVisualCard');
const image = document.getElementById('aiVisualPreview');
return !card.classList.contains('hidden') && image.complete && image.naturalWidth > 0;
})()`, 20000);
const fullPageVisual = await js(pdfWin, `(() => {
const image = document.getElementById('aiVisualPreview');
return {
width: image.naturalWidth,
height: image.naturalHeight,
label: document.getElementById('aiVisualMeta').textContent
};
})()`);
const fullPageDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
check('PDF 当前页按独立高分辨率生成图像上下文',
fullPageVisual.width > 1000
&& Math.max(fullPageVisual.width, fullPageVisual.height) <= 1600
&& fullPageVisual.label.includes('第 2 页')
&& imageHasInk(fullPageDataUrl),
JSON.stringify(fullPageVisual));
await js(pdfWin, `var s=document.getElementById('aiScope');
s.value='region-image';
s.dispatchEvent(new Event('change'));`);
await waitForJs(pdfWin, `!!document.querySelector('.visual-select-overlay')`);
const pdfRegionSelected = await js(pdfWin, `(() => {
const overlay = document.querySelector('.visual-select-overlay');
const page = document.querySelector('.pdfx-page[data-page="2"]').getBoundingClientRect();
const x1 = page.left + 60;
const y1 = page.top + 70;
const x2 = Math.min(page.right - 40, x1 + 260);
const y2 = Math.min(page.bottom - 40, y1 + 190);
overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 52, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 52, buttons: 1, clientX: x2, clientY: y2 }));
overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 52, button: 0, clientX: x2, clientY: y2 }));
return !overlay.querySelector('.visual-select-actions').classList.contains('hidden');
})()`);
check('PDF 框选被限制在单个页面并进入确认状态', pdfRegionSelected);
await js(pdfWin, `document.querySelector('.visual-select-actions .tb-btn').click()`);
await waitForJs(pdfWin, `(() => {
const image = document.getElementById('aiVisualPreview');
return !document.querySelector('.visual-select-overlay')
&& document.getElementById('aiVisualLabel').textContent === '框选区域'
&& image.complete && image.naturalWidth > 0;
})()`, 20000);
const regionSize = await js(pdfWin, `(() => {
const image = document.getElementById('aiVisualPreview');
return { width: image.naturalWidth, height: image.naturalHeight };
})()`);
const regionDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
// 局部裁剪会放大到发送上限,所以比的是长宽比而不是绝对像素:
// 区域比整页更“扁”,说明裁的确实是页面的一小块
check('PDF 框选区域按页面坐标高质量裁剪',
regionSize.width > 100
&& regionSize.height > 100
&& Math.max(regionSize.width, regionSize.height) <= 1600
&& regionSize.width / regionSize.height > fullPageVisual.width / fullPageVisual.height
&& imageHasInk(regionDataUrl),
JSON.stringify({ region: regionSize, page: fullPageVisual }));
await js(pdfWin, `document.getElementById('aiVisualRemoveBtn').click()`);
await js(pdfWin, `document.querySelector('[data-pane="notes"]').click();
document.getElementById('addNoteBtn').click()`);
check('阅读器新建笔记先选择读书笔记或画布笔记', await js(pdfWin,
`!document.getElementById('noteTypeChooser').classList.contains('hidden')
&& Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
.map((button) => button.textContent.trim()).join('|').includes('读书笔记')
&& Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
.map((button) => button.textContent.trim()).join('|').includes('画布笔记')`));
await js(pdfWin, `document.querySelector('#noteTypeChooser [data-note-type="reading"]').click()`);
check('人工笔记编辑框可由笔记面板打开', await js(pdfWin,
`!document.getElementById('noteEditorModal').classList.contains('hidden')
&& document.getElementById('noteAssociation').textContent
=== '关联当前书籍:Reader Feature Fixtures'`));
await js(pdfWin, `(() => {
document.getElementById('noteTitleInput').value = 'Manual reader note';
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
.setText('Manual note body saved through IPC.');
document.getElementById('noteTagsInput').value = 'touch, integrationtouch';
document.getElementById('noteEditorSaveBtn').click();
})()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 1);
const manual = readerStore.getState(entry.id).notes[0];
check('人工笔记标题、正文、标签和来源写入 readerStore',
manual.title === 'Manual reader note'
&& manual.noteType === 'reading'
&& manual.text === 'Manual note body saved through IPC.'
&& manual.source === 'manual'
&& JSON.stringify(manual.tags) === JSON.stringify(['touch', 'integration']));
check('人工笔记保存 PDF 文件来源和第 2 页位置',
manual.fileIndex === 0
&& manual.documentKey === annotations.documentKey(PDF_FILE)
&& manual.locator && manual.locator.kind === 'pdf' && manual.locator.page === 2);
await waitForJs(pdfWin,
`document.getElementById('noteList').textContent.includes('Manual reader note')
&& document.getElementById('noteList').textContent.includes('#touch')`);
check('人工笔记立即显示在笔记面板', await js(pdfWin,
`document.getElementById('noteList').textContent.includes('Manual reader note')
&& document.getElementById('noteList').textContent.includes('#touch')`));
await js(pdfWin, `document.getElementById('addNoteBtn').click();
document.querySelector('#noteTypeChooser [data-note-type="canvas"]').click()`);
await waitForJs(pdfWin, `!!document.querySelector('#noteRichEditor .canvas-note-root')`, 15000);
check('阅读器画布仅工作区滚动且工具栏使用一致的图标按钮', await js(pdfWin, `(() => {
const viewport = document.querySelector('#noteRichEditor .canvas-note-viewport');
const toolbar = document.querySelector('#noteRichEditor .canvas-note-toolbar');
const buttons = [...toolbar.querySelectorAll('.canvas-note-button')];
const scrollables = [];
for (let node = viewport; node && node.id !== 'noteEditorModal'; node = node.parentElement) {
const style = getComputedStyle(node);
if (/auto|scroll/.test(style.overflowX) || /auto|scroll/.test(style.overflowY)) {
scrollables.push(node);
}
}
return scrollables.length === 1
&& scrollables[0] === viewport
&& getComputedStyle(document.getElementById('noteEditorFields')).overflowY === 'hidden'
&& getComputedStyle(toolbar).flexWrap === 'wrap'
&& getComputedStyle(toolbar).overflowX === 'visible'
&& buttons.length >= 10
&& buttons.every((button) => !!button.querySelector('.canvas-note-icon'))
&& viewport.clientHeight > 0;
})()`));
await js(pdfWin, `(() => {
document.querySelector('#noteRichEditor [data-tool="flow-text"]').click();
const quill = Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'));
quill.setText('阅读器全局正文\\n第二段会和手写批注一起保存。\\n', 'user');
})()`);
check('阅读器全局文本工具显示富文本格式栏', await js(pdfWin,
`!document.querySelector('#noteRichEditor .canvas-flow-toolbar-host')
.classList.contains('hidden')
&& document.querySelector('#noteRichEditor .canvas-flow-layer')
.classList.contains('canvas-flow-active')`));
const originalPdfPicker = dialog.showOpenDialog;
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [PDF_FILE] });
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-import-pdf').click()`);
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
&& document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
dialog.showOpenDialog = originalPdfPicker;
const readerFlowTextBeforeDelete = await js(pdfWin,
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()`);
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
check('含 PDF 底版的页面需要二次确认才删除', await js(pdfWin,
`document.querySelector('#noteRichEditor .canvas-note-delete-page')
.classList.contains('canvas-note-delete-confirm')
&& document.querySelector('#noteRichEditor .canvas-note-page-counter')
.textContent.endsWith('/ 3')`));
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-page-counter')
.textContent.endsWith('/ 2')`);
check('确认删除 PDF 页面后全局正文保持不变', await js(pdfWin,
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()
=== ${JSON.stringify(readerFlowTextBeforeDelete)}`));
await js(pdfWin, `(() => {
document.getElementById('noteTitleInput').value = 'Reader canvas note';
document.querySelector('#noteRichEditor [data-tool="pen"]').click();
const canvas = document.querySelector('#noteRichEditor .upper-canvas');
const rect = canvas.getBoundingClientRect();
const event = (type, x, y, buttons) => new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: rect.left + x,
clientY: rect.top + y,
button: 0,
buttons
});
canvas.dispatchEvent(event('mousedown', 80, 100, 1));
document.dispatchEvent(event('mousemove', 140, 130, 1));
document.dispatchEvent(event('mouseup', 190, 150, 0));
document.getElementById('noteEditorSaveBtn').click();
})()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 2);
const canvasNote = readerStore.getState(entry.id).notes.find((note) => (
note.title === 'Reader canvas note'
));
check('阅读器画布笔记保存 PDF 底版和自由画笔并提交受管资源',
canvasNote?.noteType === 'canvas'
&& canvasNote.canvasContent?.pages.length === 2
&& canvasNote.canvasContent.flow?.ops.some((op) => (
typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
))
&& canvasNote.canvasContent.pages.every((page) => /^pdf_[a-f0-9]{64}$/.test(
page.background.assetId || ''
))
&& canvasNote.canvasContent.pages[0].objects.some((object) => (
object.type === 'Path' && object.canvasKind === 'pen'
)));
check('阅读器笔记列表区分读书笔记和画布笔记', await js(pdfWin,
`document.getElementById('noteList').textContent.includes('读书笔记')
&& document.getElementById('noteList').textContent.includes('画布笔记')`));
await js(pdfWin, `(() => {
const item = Array.from(document.querySelectorAll('#noteList .list-item'))
.find((entry) => entry.textContent.includes('Reader canvas note'));
item.querySelector('.list-item-edit').click();
})()`);
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
&& document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
check('重开画布笔记恢复全局正文', await js(pdfWin,
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'))
.getText().includes('阅读器全局正文')`));
await js(pdfWin, `document.getElementById('noteEditorSaveBtn').click()`);
await waitForJs(pdfWin, `document.getElementById('noteEditorModal').classList.contains('hidden')`);
const reopenedCanvas = readerStore.getState(entry.id).notes.find((note) => (
note.title === 'Reader canvas note'
));
check('重开并保存画布笔记后保留 PDF 底版和 Fabric 画笔',
reopenedCanvas?.canvasContent?.pages.length === 2
&& reopenedCanvas.canvasContent.flow?.ops.some((op) => (
typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
))
&& reopenedCanvas.canvasContent.pages[0].objects.some((object) => (
object.type === 'Path' && object.canvasKind === 'pen'
)));
const crossLineSelection = await js(pdfWin, `new Promise((resolve) => {
const layer = document.querySelector('.pdfx-page[data-page="2"] .pdfx-text');
const spans = Array.from(layer.querySelectorAll('span')).filter((span) => (
span.firstChild && span.firstChild.nodeType === Node.TEXT_NODE
&& span.firstChild.data.trim().length >= 12
));
const range = document.createRange();
range.setStart(spans[0].firstChild, 5);
range.setEnd(spans[1].firstChild, Math.min(24, spans[1].firstChild.data.length));
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
requestAnimationFrame(() => {
const pageRect = layer.getBoundingClientRect();
const rects = Array.from(range.getClientRects()).filter((rect) => rect.width && rect.height);
const result = {
sentinelCount: layer.querySelectorAll('.endOfContent').length,
selecting: layer.classList.contains('selecting'),
text: selection.toString(),
bounded: rects.length >= 2 && rects.every((rect) => (
rect.left >= pageRect.left - 1
&& rect.right <= pageRect.right + 1
&& rect.top >= pageRect.top - 1
&& rect.bottom <= pageRect.bottom + 1
&& rect.height < pageRect.height / 4
))
};
selection.removeAllRanges();
resolve(result);
});
})`);
check('PDF 跨行选择只覆盖实际文字区域',
crossLineSelection.sentinelCount === 1
&& crossLineSelection.selecting
&& crossLineSelection.text.includes('selectable text validates')
&& crossLineSelection.text.includes('Touch gestures')
&& crossLineSelection.bounded,
JSON.stringify(crossLineSelection));
let excerptSelection = null;
for (let attempt = 0; attempt < 3; attempt++) {
excerptSelection = await selectPdfText(pdfWin, 2);
await wait(150);
if (await js(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`)) break;
}
await waitForJs(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`);
check('PDF 文本选择显示摘录和记笔记操作',
!!excerptSelection && await js(pdfWin, `(
!!document.querySelector('[data-sel="excerpt"]')
&& !!document.querySelector('[data-sel="note"]')
)`), excerptSelection && excerptSelection.text);
await js(pdfWin, `document.querySelector('[data-sel="excerpt"]').click()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 3);
const excerpt = readerStore.getState(entry.id).notes.find((note) => (
note.source === 'selection' && !note.text
));
check('摘录操作保存引文、上下文和 PDF 位置',
!!excerpt
&& excerpt.quote === excerptSelection.text
&& excerpt.context.includes(excerptSelection.text.trim())
&& excerpt.locator.kind === 'pdf'
&& excerpt.locator.page === 2
&& Number.isInteger(excerpt.locator.offset));
check('摘录通过真实 readerStore 保存文件来源',
!!excerpt
&& excerpt.fileIndex === 0
&& excerpt.documentKey === annotations.documentKey(PDF_FILE));
check('摘录立即显示在笔记面板', await js(pdfWin,
`document.getElementById('noteList').textContent.includes(${JSON.stringify(excerptSelection.text)})`));
const noteSelection = await selectPdfText(pdfWin, 2);
await js(pdfWin, `document.querySelector('[data-sel="note"]').click()`);
await waitForJs(pdfWin, `!document.getElementById('noteEditorModal').classList.contains('hidden')`);
check('记笔记操作保留选择引文预览', await js(pdfWin,
`document.getElementById('noteQuotePreview').textContent === ${JSON.stringify(noteSelection.text)}
&& !document.getElementById('noteQuotePreview').classList.contains('hidden')`));
await js(pdfWin, `(() => {
document.getElementById('noteTitleInput').value = 'Selection reader note';
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
.setText('Comment attached to the selected PDF quote.');
document.getElementById('noteTagsInput').value = 'selection, pdf';
document.getElementById('noteEditorSaveBtn').click();
})()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 4);
const selectionNote = readerStore.getState(entry.id).notes.find((note) => (
note.title === 'Selection reader note'
));
check('记笔记操作保存正文、引文、标签和选择来源',
!!selectionNote
&& selectionNote.text === 'Comment attached to the selected PDF quote.'
&& selectionNote.quote === noteSelection.text
&& selectionNote.source === 'selection'
&& JSON.stringify(selectionNote.tags) === JSON.stringify(['selection', 'pdf']));
check('选择笔记保存第 2 页定位', !!selectionNote
&& selectionNote.locator.kind === 'pdf' && selectionNote.locator.page === 2);
await js(pdfWin, `document.getElementById('annotationToggleBtn').click();
document.querySelector('[data-annotation-tool="pen"]').click()`);
await waitForJs(pdfWin, `getComputedStyle(
document.querySelector('.pdfx-page[data-page="2"] .pdfx-annotation')
).pointerEvents === 'auto'`);
const pdfTouchMode = await drawPdfTouchStroke(pdfWin, 2);
await waitForJs(pdfWin, `document.getElementById('annotationStatus').textContent.includes('1 项')`);
check('单指真实触摸事件可用画笔创建笔划', true, pdfTouchMode);
await waitUntil(() => {
const stored = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
return stored.pages['2'] && stored.pages['2'].objects.length === 1;
});
let storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
check('触摸画笔通过真实批注 IPC 持久化',
storedAnnotations.pages['2'].objects[0].annotationKind === 'pen');
await js(pdfWin, `document.querySelector('[data-pane="annotations"]').click()`);
await waitForJs(pdfWin, `document.getElementById('annotationList').textContent.includes('第 2 页')`);
check('标注页列表显示页码、数量和画笔类型', await js(pdfWin,
`document.getElementById('annotationList').textContent.includes('第 2 页')
&& document.getElementById('annotationList').textContent.includes('1 项标注')
&& document.getElementById('annotationList').textContent.includes('画笔')`));
await js(pdfWin, `document.getElementById('nextBtn').click()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
await js(pdfWin, `document.querySelector('#annotationList .list-item-label').click()`);
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
check('标注页列表可跳回被标注页', true);
const zoomBefore = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
const pinchMode = await pinchPdf(pdfWin, 2);
await waitForJs(pdfWin, `!document.querySelector('.host-pdf').classList.contains('pinch-preview')
&& document.getElementById('zoomLabel').textContent !== ${JSON.stringify(zoomBefore)}`, 20000);
const zoomAfter = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
check('PDF 双指触摸缩放改变比例', zoomAfter !== zoomBefore,
`${zoomBefore} -> ${zoomAfter}; ${pinchMode}`);
check('PDF 双指缩放保持焦点页', await js(pdfWin,
`document.getElementById('posLabel').textContent === '第 2 页'`));
await waitForJs(pdfWin, `!!document.querySelector(
'.pdfx-page[data-page="2"] .pdfx-annotation .upper-canvas'
)`, 20000);
await rollbackPdfStroke(pdfWin, 2);
await wait(1800);
storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
check('画笔中途加入第二指会回滚未完成笔划',
storedAnnotations.pages['2'].objects.length === 1,
`对象=${storedAnnotations.pages['2'].objects.length}`);
check('回滚后界面对象计数没有增加', await js(pdfWin,
`document.getElementById('annotationStatus').textContent.includes('1 项')`));
check('PDF 阅读器没有控制台错误', pdfReader.errors.length === 0,
pdfReader.errors.slice(0, 3).join(' | '));
pdfWin.close();
await waitUntil(() => rangeSessions.status().sessions === 0, 5000);
check('关闭 PDF 阅读器会释放分段文件句柄', rangeSessions.status().sessions === 0);
const largeEpubReader = await openReader(entry.id, 4);
const largeEpubWin = largeEpubReader.win;
await waitForJs(largeEpubWin, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
?.textContent.includes('超过 256 MB')`);
check('超大 EPUB 在分配整文件内存前停止并提供外部应用后备', await js(largeEpubWin, `(
document.querySelector('.doc-overlay.err .doc-overlay-msg').textContent.includes('请使用外部应用')
&& Array.from(document.querySelectorAll('.doc-overlay.err button'))
.some((button) => button.textContent === '使用系统应用打开')
)`));
largeEpubWin.close();
await wait(200);
const epubReader = await openReader(entry.id, 1);
const epubWin = epubReader.win;
await waitForJs(epubWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('EPUB focal anchor sentence')`, 20000);
check('EPUB 本地夹具通过真实主进程 IPC 渲染', await js(epubWin,
`document.getElementById('zoomLabel').textContent === '18px'
&& document.getElementById('posLabel').textContent === 'Start'`));
check('非 PDF 阅读时隐藏 PDF 阅读和版式选项', await js(epubWin,
`document.getElementById('pdfViewControls').classList.contains('hidden')`));
const epubKey = annotations.documentKey(EPUB_FILE);
await js(epubWin, `Array.from(document.querySelectorAll('.toc-item'))
.find((item) => item.textContent.includes('Middle Section')).click()`);
await waitUntil(() => {
const progress = readerStore.getState(entry.id, epubKey).progress;
return progress && progress.locator && progress.locator.offset > 100;
}, 5000);
const tocOffset = readerStore.getState(entry.id, epubKey).progress.locator.offset;
check('EPUB 目录片段跳到章节内字符位置', tocOffset > 100, String(tocOffset));
await js(epubWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '0';
range.dispatchEvent(new Event('change'));
})()`);
await wait(700);
await js(epubWin, `document.querySelector('.host-epub iframe').contentDocument
.querySelector('a[data-epub-href="#section-mid"]').click()`);
await waitUntil(() => {
const progress = readerStore.getState(entry.id, epubKey).progress;
return progress && progress.locator && Math.abs(progress.locator.offset - tocOffset) <= 4;
}, 5000);
check('EPUB 正文保留的内部链接可导航',
Math.abs(readerStore.getState(entry.id, epubKey).progress.locator.offset - tocOffset) <= 4);
await js(epubWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '420';
range.dispatchEvent(new Event('change'));
})()`);
await wait(1400);
const pinchResult = await pinchEpub(epubWin);
await waitForJs(epubWin, `!document.querySelector('.host-epub').classList.contains('pinch-preview')
&& document.getElementById('zoomLabel').textContent !== '18px'`, 20000);
const epubFont = await js(epubWin, `parseFloat(document.querySelector(
'.host-epub iframe'
).contentWindow.getComputedStyle(document.querySelector(
'.host-epub iframe'
).contentDocument.body).fontSize)`);
check('EPUB iframe 内双指触摸改变字号',
epubFont > pinchResult.before,
`${pinchResult.before}px -> ${epubFont}px; ${pinchResult.mode}`);
check('EPUB 双指缩放保持当前章节', await js(epubWin,
`document.getElementById('posLabel').textContent === 'Start'`));
await wait(1200);
await waitUntil(() => {
const progress = readerStore.getState(entry.id).progress;
return progress && progress.locator && progress.locator.kind === 'epub';
}, 5000);
const epubProgress = readerStore.getState(entry.id).progress;
const focalAfter = await js(epubWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const outerRect = document.querySelector('.epub-scroll').getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
const x = 260 + outerRect.left - frameRect.left;
const y = 190 + outerRect.top - frameRect.top;
let caret = null;
if (typeof doc.caretPositionFromPoint === 'function') {
caret = doc.caretPositionFromPoint(x, y);
} else if (typeof doc.caretRangeFromPoint === 'function') {
const range = doc.caretRangeFromPoint(x, y);
if (range) caret = { offsetNode: range.startContainer, offset: range.startOffset };
}
if (!caret) return { offset: -1, charsPerLine: 0 };
// 容差要按"一行有多少字"算:字体不同,同样的一行在不同平台字数不同,
// 写死字符数的话换个字体集就会假失败
let charsPerLine = 0;
const host = caret.offsetNode.parentElement;
if (host && host.textContent) {
const range = doc.createRange();
range.selectNodeContents(host);
const lines = range.getClientRects().length;
if (lines > 0) charsPerLine = host.textContent.length / lines;
}
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
let offset = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node === caret.offsetNode) return { offset: offset + caret.offset, charsPerLine };
offset += node.data.length;
}
return { offset: -1, charsPerLine };
})()`);
const focalOffsetAfter = focalAfter.offset;
// 焦点锚定的语义是"停在原来那行附近",超过一行就说明锚点真的漂了
const focalTolerance = Math.max(12, Math.ceil(focalAfter.charsPerLine));
check('EPUB 双指缩放保持焦点附近字符偏移',
Math.abs(focalOffsetAfter - pinchResult.anchorOffset) <= focalTolerance,
`${pinchResult.anchorOffset} -> ${focalOffsetAfter}; 容差=${focalTolerance}; 顶部=${epubProgress.locator.offset}`);
const epubSelection = await selectEpubText(epubWin);
await waitForJs(epubWin, `!document.getElementById('selBar').classList.contains('hidden')`);
check('EPUB iframe 单指兼容选择流程显示选择工具条',
!!epubSelection && epubSelection.text === 'EPUB focal anchor sentence');
await js(epubWin, `document.querySelector('[data-sel="excerpt"]').click()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 5);
const epubExcerpt = readerStore.getState(entry.id).notes.find((note) => (
note.fileIndex === 1 && note.quote === 'EPUB focal anchor sentence'
));
check('EPUB 选择摘录通过真实 readerStore 保存',
!!epubExcerpt
&& epubExcerpt.source === 'selection'
&& epubExcerpt.documentKey === annotations.documentKey(EPUB_FILE)
&& epubExcerpt.locator.kind === 'epub'
&& epubExcerpt.locator.chapter === 0
&& Number.isInteger(epubExcerpt.locator.offset));
check('EPUB 摘录立即显示在笔记面板', await js(epubWin,
`document.getElementById('noteList').textContent.includes('EPUB focal anchor sentence')`));
check('EPUB 阅读器没有控制台错误', epubReader.errors.length === 0,
epubReader.errors.slice(0, 3).join(' | '));
const mobiReader = await openReader(entry.id, 2);
const mobiWin = mobiReader.win;
await waitForJs(mobiWin, `(() => {
const error = document.querySelector('.doc-overlay.err .doc-overlay-msg')?.textContent;
if (error) throw new Error(error);
return document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('MOBI selectable text');
})()`, 30000);
check('MOBI 通过 Foliate 解析器在内置阅读器渲染', await js(mobiWin,
`document.querySelector('.doctab-fmt')?.textContent === 'mobi'
&& document.getElementById('zoomLabel').textContent === '18px'
&& document.getElementById('posLabel').textContent.includes('第 1 章')`));
check('MOBI 脚本、事件属性和外部资源在渲染前被移除', await js(mobiWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
return !doc.querySelector('script, iframe, object, embed, [onerror], img[src^="http"]')
&& !frame.contentWindow.__mobiScriptExecuted
&& !frame.contentWindow.__mobiHandlerExecuted
&& !frame.contentWindow.__mobiLinkExecuted;
})()`));
const mobiKey = annotations.documentKey(MOBI_FILE);
await js(mobiWin, `document.getElementById('addBookmarkBtn').click()`);
await waitUntil(() => readerStore.getState(entry.id, mobiKey).bookmarks.length === 1);
check('MOBI 书签保存稳定章节字符位置', (() => {
const bookmark = readerStore.getState(entry.id, mobiKey).bookmarks[0];
return bookmark
&& bookmark.documentKey === mobiKey
&& bookmark.locator.kind === 'mobi'
&& bookmark.locator.chapter === 0
&& Number.isInteger(bookmark.locator.offset);
})());
const mobiSelection = await selectEpubText(mobiWin, 'MOBI selectable text');
await waitForJs(mobiWin, `!document.getElementById('selBar').classList.contains('hidden')`);
check('MOBI 正文支持选择和摘录操作',
!!mobiSelection && mobiSelection.text === 'MOBI selectable text');
await js(mobiWin, `document.querySelector('[data-sel="excerpt"]').click()`);
await waitUntil(() => readerStore.getState(entry.id).notes.length === 6);
const mobiExcerpt = readerStore.getState(entry.id).notes.find((note) => (
note.fileIndex === 2 && note.quote === 'MOBI selectable text'
));
check('MOBI 摘录关联原文件、文档指纹和定位',
!!mobiExcerpt
&& mobiExcerpt.documentKey === mobiKey
&& mobiExcerpt.locator.kind === 'mobi'
&& mobiExcerpt.locator.chapter === 0
&& Number.isInteger(mobiExcerpt.locator.offset));
check('MOBI 阅读器没有控制台错误', mobiReader.errors.length === 0,
mobiReader.errors.slice(0, 3).join(' | '));
const encryptedMobi = fs.readFileSync(MOBI_FILE);
encryptedMobi.writeUInt16BE(1, 96 + 12);
const drmError = await js(mobiWin, `import('./reader/mobi-adapter.mjs').then(async (module) => {
const adapter = module.createMobiAdapter('azw');
try {
await adapter.load(Uint8Array.from(${JSON.stringify([...encryptedMobi])}));
return '';
} catch (error) {
return error && error.message;
} finally {
adapter.destroy();
}
})`);
check('MOBI 适配器明确拒绝 DRM 文件且不尝试绕过', drmError.includes('DRM 保护'));
mobiWin.close();
const reopenedMobi = await openReader(entry.id, 2);
await waitForJs(reopenedMobi.win, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('MOBI selectable text')`, 30000);
check('重开 MOBI 后恢复对应文档的书签和阅读状态',
readerStore.getState(entry.id, mobiKey).bookmarks.length === 1
&& await js(reopenedMobi.win, `document.getElementById('bookmarkList').textContent.includes('第 1 章')`));
check('重开的 MOBI 阅读器没有控制台错误', reopenedMobi.errors.length === 0,
reopenedMobi.errors.slice(0, 3).join(' | '));
reopenedMobi.win.close();
const drmReader = await openReader(entry.id, 3);
await waitForJs(drmReader.win, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
?.textContent.includes('DRM 保护')`, 15000);
check('DRM 或不兼容 AZW 打开失败时提供系统应用后备入口',
await js(drmReader.win, `Array.from(document.querySelectorAll('.doc-overlay.err button'))
.some((button) => button.textContent === '使用系统应用打开')`));
check('DRM 后备界面没有控制台错误', drmReader.errors.length === 0,
drmReader.errors.slice(0, 3).join(' | '));
drmReader.win.close();
const txtReader = await openReader(entry.id, 5);
const txtWin = txtReader.win;
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
check('TXT 通过内置阅读器打开而不是回退到外部程序',
await js(txtWin, `!document.querySelector('.doc-overlay.err')
&& !!document.querySelector('.host-epub iframe')`));
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('TXT 按章节标题切分并生成可跳转目录',
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
JSON.stringify(txtToc.slice(0, 5)));
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /第1章/.test(button.textContent)).click()`);
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
.textContent.includes('TXT-MARK-1-0')`, 20000);
const txtDecoded = await js(txtWin, `(() => {
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
return {
chinese: text.includes('第1章第1段'),
replacement: text.includes('\\ufffd'),
nul: text.includes('\\u0000')
};
})()`);
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
JSON.stringify(txtDecoded));
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
const txtFullText = await js(txtWin, `(async () => {
const module = await import('./reader/text-adapter.mjs');
const adapter = module.createTextAdapter('txt');
try {
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
await adapter.load(bytes.data, {});
const host = document.createElement('div');
document.body.appendChild(host);
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
const full = await adapter.textOf(null, 'document');
const page = await adapter.textOf(null, 'page');
host.remove();
return {
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
length: full.length
};
} finally {
adapter.destroy();
}
})()`);
check('TXT 全文范围覆盖所有章节而不是只有当前章',
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
JSON.stringify(txtFullText));
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
txtReader.errors.slice(0, 3).join(' | '));
txtWin.close();
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
{
const mainWin = BrowserWindow.getAllWindows()
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
check('存在主窗口用于校验书库入口', !!mainWin);
if (mainWin) {
mainWin.show();
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
await waitForJs(mainWin,
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
const cardEntry = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
&& cardEntry.coverActs, JSON.stringify(cardEntry));
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
const txtOnly = library.add({
title: 'TXT Only Fixture',
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
});
await js(mainWin, `document.getElementById('rescanBtn').click()`);
await waitForJs(mainWin,
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
const txtCard = await js(mainWin, `(() => {
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
if (!card) return { missing: true };
return {
coverReadable: !!card.querySelector('.card-cover.readable'),
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
.some((b) => /阅读/.test(b.title || ''))
};
})()`);
check('纯 TXT 条目在书库里也有阅读入口',
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
library.remove(txtOnly.id);
mainWin.hide();
}
}
const mdReader = await openReader(entry.id, 6);
const mdWin = mdReader.win;
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
?.textContent.includes('Markdown 夹具')`, 30000);
const mdHeadStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h1: doc.querySelectorAll('h1').length,
strong: doc.querySelectorAll('strong').length,
code: doc.querySelectorAll('code').length
};
})()`);
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
JSON.stringify(mdHeadStructure));
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.map((button) => button.textContent)`);
check('Markdown 按标题层级生成目录',
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
JSON.stringify(mdToc));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /结构小节/.test(button.textContent)).click()`);
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
const mdStructure = await js(mdWin, `(() => {
const doc = document.querySelector('.host-epub iframe').contentDocument;
return {
h2: doc.querySelectorAll('h2').length,
li: doc.querySelectorAll('li').length,
pre: doc.querySelectorAll('pre').length,
table: doc.querySelectorAll('table td, table th').length,
quote: doc.querySelectorAll('blockquote').length
};
})()`);
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
JSON.stringify(mdStructure));
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
await wait(1200);
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
const mdSafety = await js(mdWin, `(() => {
const frame = document.querySelector('.host-epub iframe');
const doc = frame.contentDocument;
const nodes = Array.from(doc.querySelectorAll('*'));
return {
script: doc.querySelectorAll('script').length,
iframe: doc.querySelectorAll('iframe').length,
img: doc.querySelectorAll('img').length,
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
.some((attr) => /^on/i.test(attr.name))).length,
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
};
})()`);
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
JSON.stringify(mdSafety));
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
mdReader.errors.slice(0, 3).join(' | '));
mdWin.close();
const readerFile = path.join(TMP, 'reader.json');
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
check('readerStore 使用隔离目录中的 v6 存储',
readerJson.version === 6
&& fs.existsSync(readerFile)
&& readerStore.getState(entry.id).notes.length === 6,
readerFile);
check('PDF 批注文件写入隔离 reader-annotations 目录',
fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
epubWin.close();
await wait(300);
const managedWin = managedReader.open(entry.id, ROOT, 0, null, 'dark');
managedWin.hide();
await waitForJs(managedWin, `document.readyState === 'complete'`, 10000);
await wait(500);
check('受管阅读器就绪握手可排空队列', managedReader.markReady(managedWin.webContents));
await js(managedWin, `(() => {
const range = document.getElementById('progressRange');
range.value = '700';
range.dispatchEvent(new Event('change'));
})()`);
const removed = await js(managedWin, `window.api.library.remove(
${JSON.stringify(entry.id)},
{ deleteFiles: false, deleteReadingData: true }
)`);
const lateWrite = await js(managedWin, `window.api.reader.setProgress(
${JSON.stringify(entry.id)},
${JSON.stringify(annotations.documentKey(PDF_FILE))},
{ kind: 'pdf', page: 1 },
0.1
)`);
await wait(1200);
check('显式删除阅读资料会等待阅读器排空并移除条目', removed && removed.ok && !library.get(entry.id));
check('显式删除后迟到的进度和批注写入不会重建资料',
lateWrite && lateWrite.ok === false
&& readerStore.getState(entry.id).notes.length === 0
&& !fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.close();
}
const failed = printSummary();
app.exit(failed ? 1 : 0);
}).catch(async (error) => {
console.error('异常:', error);
check('集成测试未发生异常', false, error && error.stack ? error.stack.split('\n')[0] : String(error));
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.close();
}
printSummary();
await wait(100);
app.exit(1);
});