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', ` `); zip.file('OEBPS/content.opf', ` reader-features-fixture Reader Features EPUB Fixture en `); const paragraphs = Array.from({ length: 90 }, (_, index) => ( `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 ? ' Jump to middle section' : ''}

` )).join('\n'); zip.file('OEBPS/chapter.xhtml', ` Touch Chapter

Touch Chapter

${paragraphs} `); zip.file('OEBPS/nav.xhtml', ` Contents `); fs.writeFileSync(file, await zip.generateAsync({ type: 'nodebuffer', mimeType: 'application/epub+zip', compression: 'DEFLATE', compressionOptions: { level: 6 } })); } function makeMobi(file) { const text = Buffer.from( 'MOBI Chapter' + '

MOBI Chapter

MOBI selectable text validates the Foliate parser, ' + 'built-in rendering, bookmarks, excerpts, notes, and stable reading progress.

' + '' + '' + 'unsafe link' + '', '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 夹具', '', '正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。', '', '## 危险内容小节', '', '', '', '', '', '[不安全链接](javascript:window.__mdLinkExecuted=true)', '', '', '', '## 结构小节', '', '- 列表项一', '- 列表项二', '', '```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, integration,touch'; 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); });