@@ -452,9 +469,18 @@ export class SourcesScreen {
if (this.disposed) return;
if (field.key in this.credentialValues) await this.options.api.sourceCredential(source.id, field.key, this.credentialValues[field.key]!);
}
- this.credentialValues = {}; this.credentialsOpen = false; this.message = '个人凭据已保存,不会回显。';
- }); }}>
{this.selected.descriptor?.credentialKeys?.map((field) =>
)}
+ this.credentialValues = {}; this.credentialsOpen = false; this.message = '个人凭据已保存,不会回显。修改过的凭据需重新搜索或浏览检查访问。';
+ }); }}>
+
未编辑的字段会保留;编辑后留空并保存会删除该项。保存凭据不代表登录有效。
+ {this.credentialStatus ?
+
{({ unknown: '尚未验证访问', reachable: '最近一次访问成功', 'auth-required': '需要登录或凭据已过期', 'verification-required': '需要人工验证' })[this.credentialStatus.state]} · {date(this.credentialStatus.checkedAt)}
+ {!this.credentialStatus.available &&
来源当前不可用,请联系管理员检查插件。
}
+ {this.credentialStatus.state === 'verification-required' &&
请在站点或插件提供的管理页面完成验证,再重试。Reader 不会绕过验证码。
}
+
这是当前账号最近一次请求的结果,不保证会话仍有效;站点未提供的过期时间无法判断。
+
:
尚未取得访问状态,可重试打开凭据页。
}
+ {this.selected.descriptor?.credentialKeys?.map((field) =>
)}
}
>;
diff --git a/web/src/ui/text-anchor.ts b/web/src/ui/text-anchor.ts
new file mode 100644
index 0000000..fb92093
--- /dev/null
+++ b/web/src/ui/text-anchor.ts
@@ -0,0 +1,83 @@
+/** Text offsets plus an exact quote keep search and annotations independent of pagination. */
+export interface TextAnchor { sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string }
+
+export function textNodes(root: Node): Text[] {
+ const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const nodes: Text[] = [];
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ const parent = node.parentElement;
+ if (parent?.closest('script,style,noscript,[data-reader-mark]')) continue;
+ nodes.push(node as Text);
+ }
+ return nodes;
+}
+
+export function anchorFor(sectionId: string, text: string, start: number, end: number): TextAnchor {
+ return { sectionId, start, end, quote: text.slice(start, end), prefix: text.slice(Math.max(0, start - 32), start), suffix: text.slice(end, end + 32) };
+}
+export function encodeAnchor(anchor: TextAnchor): string { return 'rt1:' + JSON.stringify(anchor); }
+export function decodeAnchor(locator: string): TextAnchor | null {
+ if (!locator.startsWith('rt1:')) return null;
+ try {
+ const a = JSON.parse(locator.slice(4)) as TextAnchor;
+ return typeof a.sectionId === 'string' && typeof a.quote === 'string' && a.quote.length > 0 &&
+ typeof a.prefix === 'string' && typeof a.suffix === 'string' && Number.isSafeInteger(a.start) &&
+ Number.isSafeInteger(a.end) && a.start >= 0 && a.end > a.start ? a : null;
+ } catch { return null; }
+}
+
+export function anchorRange(root: Node, anchor: TextAnchor): Range | null {
+ const nodes = textNodes(root), text = nodes.map(n => n.data).join('');
+ let start = anchor.start;
+ if (text.slice(start, start + anchor.quote.length) !== anchor.quote) {
+ const context = anchor.prefix + anchor.quote + anchor.suffix;
+ const exact = text.indexOf(context);
+ if (exact >= 0 && text.indexOf(context, exact + 1) < 0) start = exact + anchor.prefix.length;
+ else {
+ start = text.indexOf(anchor.quote);
+ // Ambiguous matches must not silently move a note onto another paragraph.
+ if (start < 0 || text.indexOf(anchor.quote, start + 1) >= 0) return null;
+ }
+ }
+ const range = root.ownerDocument!.createRange();
+ const end = start + anchor.quote.length;
+ let offset = 0, started = false;
+ for (const node of nodes) {
+ const next = offset + node.length;
+ if (!started && start < next) { range.setStart(node, start - offset); started = true; }
+ if (started && end <= next) { range.setEnd(node, end - offset); return range; }
+ offset = next;
+ }
+ return null;
+}
+
+export function selectedAnchor(root: Node, sectionId: string, selection: Selection | null): TextAnchor | null {
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
+ const range = selection.getRangeAt(0);
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
+ const prefix = range.cloneRange(); prefix.selectNodeContents(root); prefix.setEnd(range.startContainer, range.startOffset);
+ const text = textNodes(root).map(n => n.data).join('');
+ // cloneContents excludes styles via the same traversal used by search.
+ const start = textNodes(prefix.cloneContents()).map(n => n.data).join('').length;
+ const quote = textNodes(range.cloneContents()).map(n => n.data).join('');
+ return quote ? anchorFor(sectionId, text, start, start + quote.length) : null;
+}
+
+export function searchableText(html: string, markup = true): string {
+ if (!markup) return html;
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return textNodes(doc.body).map(n => n.data).join('');
+}
+
+export interface SearchHit { title: string; excerpt: string; anchor: TextAnchor }
+export function findText(sectionId: string, title: string, text: string, query: string, limit = 200): SearchHit[] {
+ const result: SearchHit[] = [];
+ if (!query.trim()) return result;
+ let start = text.indexOf(query);
+ while (start >= 0 && result.length < limit) {
+ result.push({ title, excerpt: text.slice(Math.max(0, start - 35), start + query.length + 65), anchor: anchorFor(sectionId, text, start, start + query.length) });
+ start = text.indexOf(query, start + Math.max(1, query.length));
+ }
+ return result;
+}
diff --git a/web/test/api-account-switch.test.ts b/web/test/api-account-switch.test.ts
index 126aeb1..a3bb59d 100644
--- a/web/test/api-account-switch.test.ts
+++ b/web/test/api-account-switch.test.ts
@@ -34,10 +34,10 @@ async function setup() {
}
describe('requests across an account change', () => {
- it('keeps the reader signed in when an OPDS source requires separate credentials', async () => {
+ it.each(['AUTH_REQUIRED', 'AUTH_EXPIRED', 'VERIFICATION_REQUIRED'])('keeps the Reader session for source error %s', async code => {
const env = await setup();
- env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code: 'AUTH_REQUIRED', message: 'OPDS password required' } } }));
- await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code: 'AUTH_REQUIRED', isAuthFailure: false });
+ env.transport.respondWith(() => ({ status: 401, headers: {}, json: { error: { code, message: '站点需要验证' } } }));
+ await expect(env.api.sourceCatalog('private')).rejects.toMatchObject({ code, isAuthFailure: false });
expect(env.api.currentSession()?.user.id).toBe('a');
expect(env.transport.requests).toHaveLength(1);
});
diff --git a/web/test/chapter-reader.test.ts b/web/test/chapter-reader.test.ts
index 72aec02..2e14e4d 100644
--- a/web/test/chapter-reader.test.ts
+++ b/web/test/chapter-reader.test.ts
@@ -9,6 +9,7 @@ import { OfflineStore } from '../src/store/offline.ts';
import { PublicationCache, publicationScope } from '../src/store/publications.ts';
import { DEFAULT_APP_SETTINGS } from '../src/store/settings.ts';
import { ReaderScreen } from '../src/ui/reader-screen.tsx';
+import { App } from '../src/app.ts';
import type { SyncEngine } from '../src/core/sync.ts';
import { FakeTransport, MemoryBlobs, MemoryKv, makePlatform } from './helpers/env.ts';
@@ -105,6 +106,62 @@ async function click(screen: ReaderScreen, label: string): Promise
{
}
describe('chapter publication reading', () => {
+ it('does not save a temporary opening position while waiting for remote progress', async () => {
+ const env = await setup(); serve(env.transport, content(['a', 'b']));
+ const old = { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
+ chapterTitle: 'b', device: 'phone', updatedAt: 10 };
+ await env.offline.setProgress(old);
+ let finish!: (value: typeof old) => void;
+ vi.spyOn(env.api, 'getProgress').mockImplementation(() => new Promise(resolve => { finish = resolve; }));
+ const opening = env.screen.open(book);
+ await vi.waitFor(() => expect(finish).toBeDefined());
+ await new Promise(resolve => setTimeout(resolve, 1700));
+ const duringOpen = env.offline.progressFor(book.id);
+ finish({ ...old, updatedAt: 20 }); await opening;
+ expect(duringOpen).toEqual(old);
+ });
+
+ it.each([{ localTime: 10, chapter: 'b' }, { localTime: 30, chapter: 'a' }])(
+ 'opens the latest position when the local timestamp is $localTime', async ({ localTime, chapter }) => {
+ const env = await setup();
+ await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:a', percentage: 0,
+ chapterTitle: 'a', device: 'phone', updatedAt: localTime });
+ env.transport.respondWith(request => {
+ if (request.url.endsWith('/manifest')) return { status: 200, headers: {}, json: manifest(content(['a', 'b'])) };
+ if (request.url.includes('/assets?')) return { status: 200, headers: {}, bytes: new TextEncoder().encode(new URL(request.url, 'http://one.test').searchParams.get('ref')!) };
+ return { status: 200, headers: {}, json: { progress: { bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
+ chapterTitle: 'b', device: 'new phone', updatedAt: 20 } } };
+ });
+ await env.screen.open(book);
+ expect(body(env.screen)?.textContent).toContain(`resource:r1:${chapter}`);
+ });
+
+ it('flushes the visible chapter before app background sync, without waiting for the reading debounce', async () => {
+ const env = await setup();
+ serve(env.transport, content(['a', 'b']));
+ await env.screen.open(book);
+ // Exercise the actual App lifecycle flush and actual ReaderScreen/OfflineStore.
+ let savedLocator: string | undefined;
+ const sync = { flush: vi.fn(async () => {
+ const persisted = new OfflineStore(env.platform.kv);
+ await persisted.setScope(publicationScope(env.api.baseUrl, 'u1'));
+ savedLocator = persisted.current.progress[book.id]?.locator;
+ }) };
+ await App.prototype.flush.call({ reader: env.screen, offline: env.offline, sync } as unknown as App);
+ expect(sync.flush).toHaveBeenCalledOnce();
+ expect(savedLocator).toContain('chapter:a');
+ expect(env.offline.current.progress[book.id]?.locator).toContain('chapter:a');
+ });
+
+ it('keeps the local reading position when the progress service is unavailable', async () => {
+ const env = await setup(); serve(env.transport, content(['a', 'b']));
+ await env.offline.setProgress({ bookId: book.id, locator: 'r1:0.0000:chapter:b', percentage: 0.5,
+ chapterTitle: 'b', device: 'phone', updatedAt: 10 });
+ vi.spyOn(env.api, 'getProgress').mockRejectedValue(new ApiError('offline', 'offline'));
+ await env.screen.open(book);
+ expect(body(env.screen)?.textContent).toContain('resource:r1:b');
+ });
+
it('switches from the contents panel only after chapter selection and preserves the old view on failure', async () => {
const env = await setup(); let failSwitch = true;
env.transport.respondWith(request => {
@@ -124,7 +181,26 @@ describe('chapter publication reading', () => {
const submit = [...env.screen.element.querySelectorAll('button')].find(button => button.textContent === '确认换源并阅读')!;
expect(submit.disabled).toBe(true);
const select = env.screen.element.querySelector('section[aria-label="切换书源"] select')!;
- select.value = 'x'; select.dispatchEvent(new Event('change', { bubbles: true })); await click(env.screen, '确认换源并阅读');
+ select.value = 'x'; select.dispatchEvent(new Event('change', { bubbles: true }));
+ const quality = vi.spyOn(env.api, 'switchQuality');
+ quality.mockRejectedValueOnce(new ApiError('bad_request', '空章不能读取', 'EMPTY_CHAPTER', 400));
+ await click(env.screen, '检测所选章节 / 重试');
+ await vi.waitFor(() => expect(env.screen.element.textContent).toContain('空章不能读取'));
+ quality.mockResolvedValueOnce({ chapterId: 'x', title: '另一个章节', latestChapter: '末章', characters: 100, images: 0, bytes: 300, elapsedMs: 10 });
+ await click(env.screen, '检测所选章节 / 重试');
+ await vi.waitFor(() => expect(env.screen.element.textContent).toContain('100 字符'));
+ expect(body(env.screen)?.textContent).toContain('resource:r1:a');
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('100 字符');
+ let finish!: (value: {chapterId:string;title:string;latestChapter:string;characters:number;images:number;bytes:number;elapsedMs:number}) => void;
+ quality.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
+ await click(env.screen, '检测所选章节 / 重试');
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ expect(quality.mock.calls.at(-1)?.[3]?.signal?.aborted).toBe(true);
+ finish({chapterId:'x',title:'迟到结果',latestChapter:'末章',characters:999,images:0,bytes:999,elapsedMs:1});
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(env.screen.element.querySelector('dialog')?.textContent).not.toContain('999 字符');
+ await click(env.screen, '确认换源并阅读');
await vi.waitFor(() => expect(noticeText()).toContain('正文获取失败'));
expect(body(env.screen)?.textContent).toContain('resource:r1:a');
expect(env.screen.element.querySelector('section[aria-label="切换书源"]')?.textContent).toContain('最新章节');
@@ -311,3 +387,20 @@ describe('legacy offline migration', () => {
expect(reopened.current.progress).toEqual({});
});
});
+
+it('opens tools, searches the whole book, navigates and returns, and persists a bookmark', async () => {
+ const env = await setup(); serve(env.transport,content(['a','b'])); await env.screen.open(book);
+ await click(env.screen,'工具'); await vi.waitFor(()=>expect(env.screen.element.textContent).toContain('搜索全文'));
+ const input = env.screen.element.querySelector('input[maxlength="200"]')!;
+ input.value = '正文'; input.dispatchEvent(new Event('input',{bubbles:true}));
+ await vi.waitFor(()=>expect(env.screen.element.querySelector('form button')?.disabled).toBe(false));
+ await click(env.screen,'搜索全文');
+ await vi.waitFor(()=>expect(env.screen.element.querySelectorAll('.reading-tool-results li')).toHaveLength(2));
+ const results = env.screen.element.querySelectorAll('.reading-tool-results li button'); results[1]!.click();
+ await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:b'));
+ await click(env.screen,'工具'); await click(env.screen,'返回原阅读位置');
+ await vi.waitFor(()=>expect(body(env.screen)?.textContent).toContain('resource:r1:a'));
+ await click(env.screen,'工具'); await click(env.screen,'笔记'); await click(env.screen,'添加当前位置书签');
+ await vi.waitFor(()=>expect(env.offline.notesFor(book.id)).toHaveLength(1));
+ expect(env.offline.notesFor(book.id)[0]?.type).toBe('bookmark');
+});
diff --git a/web/test/reading-tools-core.test.ts b/web/test/reading-tools-core.test.ts
new file mode 100644
index 0000000..ce0f787
--- /dev/null
+++ b/web/test/reading-tools-core.test.ts
@@ -0,0 +1,101 @@
+import { applyCorrections } from '../src/ui/reading-overrides.ts';
+import { loadTxt } from '../src/formats/txt.ts';
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { Blob as NodeBlob } from 'node:buffer';
+import { parseNavigation } from '../src/formats/navigation.ts';
+import { anchorFor, anchorRange, selectedAnchor, encodeAnchor, decodeAnchor, findText, searchableText } from '../src/ui/text-anchor.ts';
+import { PublicationCache } from '../src/store/publications.ts';
+import { OfflineDownload } from '../src/core/offline-download.ts';
+import { MemoryKv, MemoryBlobs } from './helpers/env.ts';
+import type { Manifest } from '../src/api/types.ts';
+import type { ReaderApi } from '../src/api/client.ts';
+
+describe('navigation and text anchors', () => {
+ it('preserves three levels, fragments, entities, and TOC navigation selection', () => {
+ expect(parseNavigation('', 'nav')).toEqual([
+ { href: 'a#top', title: '一 & 二', depth: 0 }, { href: 'a#middle', title: '第二层', depth: 1 }, { href: 'b#end', title: '第三层', depth: 2 }, { href: 'c', title: '尾章', depth: 0 },
+ ]);
+ expect(parseNavigation('父子', 'ncx').map(n => n.depth)).toEqual([0,1]);
+ });
+ it('locates different occurrences across text nodes and rejects ambiguous moved text', () => {
+ const root = document.createElement('div'); root.innerHTML = '开头词语结尾
后面词语结束
';
+ const hits = findText('s', '章', searchableText(root.innerHTML), '词语');
+ expect(hits).toHaveLength(2);
+ const first = anchorRange(root, hits[0]!.anchor)!, second = anchorRange(root, hits[1]!.anchor)!;
+ expect(first.startContainer).not.toBe(second.startContainer);
+ expect(decodeAnchor(encodeAnchor(hits[0]!.anchor))).toEqual(hits[0]!.anchor);
+ expect(anchorRange(root, { ...hits[0]!.anchor, start: 90, prefix: '不存在', suffix: '不存在' })).toBeNull();
+ const spanning = anchorFor('s', searchableText(root.innerHTML), 2, 6);
+ expect(anchorRange(root, spanning)?.toString()).toBe('词语结尾');
+ });
+ it('captures a browser selection and relocates a unique quote after edits', () => {
+ const root = document.createElement('div'); root.innerHTML = '前文选中文字后文
'; document.body.append(root);
+ const range = document.createRange(); range.selectNodeContents(root.querySelector('b')!);
+ const selection = document.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
+ const anchor = selectedAnchor(root, 's', selection)!; expect(anchor.quote).toBe('选中文字');
+ root.prepend(document.createTextNode('新内容')); expect(anchorRange(root, anchor)?.toString()).toBe('选中文字'); root.remove();
+ });
+});
+describe('scoped quota and offline tasks', () => {
+ it('serializes quota checks, isolates accounts, and rolls back failed metadata writes', async () => {
+ const kv = new MemoryKv(), blobs = new MemoryBlobs(); const cache = new PublicationCache(kv, blobs, 'one');
+ await cache.setQuota(1024 * 1024);
+ const results = await Promise.allSettled([cache.putResource('a','x',new Uint8Array(700000)),cache.putResource('b','y',new Uint8Array(700000))]);
+ expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
+ expect(await new PublicationCache(kv,blobs,'two').resource('a','x')).toBeNull();
+ const write = vi.spyOn(kv,'set').mockRejectedValueOnce(new Error('disk full'));
+ await expect(cache.putResource('a','x',new Uint8Array([1]))).rejects.toThrow('disk full'); write.mockRestore();
+ expect((await cache.resource('a','x'))?.length).toBe(700000);
+ await cache.removeBook('a'); expect(await cache.entries()).toEqual([]);
+ });
+ it('downloads selected windows and resumes without downloading completed resources', async () => {
+ const cache = new PublicationCache(new MemoryKv(), new MemoryBlobs(),'scope');
+ const item = (seq: number) => ({ id: String(seq), seq, title: String(seq), href: 'chapter:' + seq, resourceRef: 'r:' + seq, kind: 'chapter', format: 'html', mediaType: 'text/plain' });
+ const content = { revision: 'one', kind: 'text', total: 3, groups: [{ id:'a',seq:0,offset:0,count:1,title:'a' },{ id:'b',seq:1,offset:1,count:2,title:'b' }], items:[item(0)] };
+ const manifest = { book:{ id:'b', format:'txt' }, content } as unknown as Manifest;
+ const asset = vi.fn(async (_id: string, ref: string) => new NodeBlob([ref]));
+ const api = { asset, items: vi.fn(async () => ({ ...content, group: 1, items:[item(1),item(2)] })) } as unknown as ReaderApi;
+ const task = new OfflineDownload(api,cache,manifest,()=>{}); await task.start(2,3);
+ expect(task.state.status).toBe('complete'); expect(asset).toHaveBeenCalledTimes(2);
+ expect((await cache.window('b',1))?.items).toHaveLength(2);
+ const resumed = new OfflineDownload(api,cache,manifest,()=>{}); await resumed.load(); await resumed.start(2,3);
+ expect(asset).toHaveBeenCalledTimes(2); expect(resumed.state.completed).toBe(2);
+ const changed = new OfflineDownload(api,cache,{ ...manifest,content:{ ...manifest.content!,revision:'two' } },()=>{});
+ await changed.load(); expect(changed.state.status).toBe('idle');
+ });
+ it('aborts an in-flight task without marking incomplete bytes as cached', async () => {
+ const cache = new PublicationCache(new MemoryKv(),new MemoryBlobs(),'pause');
+ const manifest = { book:{id:'b',format:'epub'},files:[] } as unknown as Manifest;
+ const bookBytes = vi.fn((_id: string,{signal}: {signal:AbortSignal}) => new Promise((_resolve,reject) => signal.addEventListener('abort',()=>reject(signal.reason))));
+ const task = new OfflineDownload({bookBytes} as unknown as ReaderApi,cache,manifest,()=>{});
+ const run = task.start(); await vi.waitFor(()=>expect(bookBytes).toHaveBeenCalled()); await task.pause(true); await run;
+ expect(task.state.status).toBe('cancelled'); expect(await cache.entries()).toEqual([]);
+ });
+});
+
+it('applies literal corrections safely, supports chained edits, and preserves original source', () => {
+ const root = document.createElement('div'); root.innerHTML = '需要纠错的原文
';
+ const first = anchorFor('xhtml:a',root.textContent!,2,4);
+ const second = anchorFor('a','需要修改的原文',2,4);
+ expect(applyCorrections(root,'a',{version:1,headingPrefix:'',corrections:[{id:'1',anchor:first,replacement:'修改'},{id:'2',anchor:second,replacement:''}]})).toBe(2);
+ expect(root.querySelector('script')).toBeNull(); expect(root.textContent).toContain('');
+});
+it('previews and applies a literal TXT heading prefix without executing regular expressions', () => {
+ const bytes = new TextEncoder().encode('前言\n@@ 一\n正文\n@@ 二\n正文');
+ const result = loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'@@'});
+ expect(result.doc.toc.map(t => t.label)).toEqual(['前言','@@ 一','@@ 二']);
+ expect(loadTxt({bytes,fileName:'a.txt',bookId:'a'},{headingPrefix:'(a+)+'}).chapterCount).toBe(0);
+});
+
+it('retries a failed chapter download and uses a complete TXT file for custom headings', async () => {
+ const cache=new PublicationCache(new MemoryKv(),new MemoryBlobs(),'retry');
+ const content={kind:'text',total:2,revision:'v',groups:[{id:'all',seq:0,offset:0,count:2,title:'全部'}],items:[0,1].map(seq=>({id:String(seq),seq,title:String(seq),href:'chapter:'+seq,resourceRef:'r:'+seq,kind:'chapter',mediaType:'text/plain'}))};
+ const manifest={book:{id:'b',format:'txt'},files:[],content} as unknown as Manifest;
+ const asset=vi.fn().mockResolvedValueOnce(new NodeBlob(['a'])).mockRejectedValueOnce(new Error('offline')).mockResolvedValue(new NodeBlob(['b']));
+ const api={asset,bookBytes:vi.fn(async()=>new Uint8Array([1,2,3]))} as unknown as ReaderApi;
+ const task=new OfflineDownload(api,cache,manifest,()=>{});await task.start(1,2);expect(task.state.status).toBe('error');expect(task.state.completed).toBe(1);
+ await task.start(1,2);expect(task.state.status).toBe('complete');expect(asset).toHaveBeenCalledTimes(3);
+ await cache.putOverrides('b',{version:1,headingPrefix:'@@',corrections:[]});await task.start(1,1);
+ expect(api.bookBytes).toHaveBeenCalledTimes(1);expect(task.state.message).toBe('整书已可离线阅读');
+});
diff --git a/web/test/sources-screen.test.ts b/web/test/sources-screen.test.ts
index ed96a2b..8086c1e 100644
--- a/web/test/sources-screen.test.ts
+++ b/web/test/sources-screen.test.ts
@@ -31,6 +31,7 @@ async function setup(admin = true) {
transport.respondWith((request) => {
let json: unknown = {};
if (request.url.endsWith('/sources/types')) json = { types: [opds] };
+ else if (request.url.endsWith('/credentials')) json = { state: 'unknown', checkedAt: null, available: true, fields: [{ key: 'password', label: 'OPDS 密码', configured: false }] };
else if (request.url.endsWith('/sources')) json = { sources: [source], source };
else if (request.url.endsWith('/plugins')) json = { plugins: [{ pluginId: 'remote', name: '远程插件', enabled: true, builtin: false }] };
else if (request.url.endsWith('/subscriptions')) json = { subscriptions: [{ bookId: 'book1', title: '追更小说', enabled: false, intervalMinutes: 60, newChapters: 2, lastSuccessAt: 1000, nextCheckAt: 0 }] };
@@ -48,24 +49,63 @@ async function setup(admin = true) {
async function* pages(...values: Array>): AsyncGenerator { for (const value of values) yield await value; }
+it('creates external reader credentials, shows the password once and revokes only the chosen client', async () => {
+ const { screen, api } = await setup(false);
+ const item = { id: 'client-1', name: '平板', createdAt: 1, expiresAt: Date.now() + 100000 };
+ const list = vi.spyOn(api, 'opdsCredentials').mockResolvedValue({ credentials: [], catalogUrl: '/opds' });
+ const create = vi.spyOn(api, 'createOpdsCredential').mockResolvedValue({ ...item, username: item.id, password: 'once-secret', catalogUrl: '/opds' });
+ const revoke = vi.spyOn(api, 'revokeOpdsCredential').mockResolvedValue();
+ await click(screen.element, '连接外部阅读器');
+ await vi.waitFor(() => expect(screen.element.querySelector('dialog input[readonly]')?.value).toContain('/opds'));
+ list.mockResolvedValue({ credentials: [item], catalogUrl: '/opds' });
+ input(screen.element, '客户端名称', '平板'); await vi.waitFor(() => expect(button(screen.element, '创建 OPDS 凭据').disabled).toBe(false)); await click(screen.element, '创建 OPDS 凭据');
+ await vi.waitFor(() => expect(create).toHaveBeenCalledWith('平板'));
+ await vi.waitFor(() => expect([...screen.element.querySelectorAll('input')].some(field => field.value === 'once-secret')).toBe(true));
+ await vi.waitFor(() => expect(button(screen.element, '关闭弹窗').disabled).toBe(false));
+ await click(screen.element, '关闭弹窗'); await vi.waitFor(() => expect(screen.element.querySelector('dialog')).toBeNull());
+ await click(screen.element, '连接外部阅读器');
+ await vi.waitFor(() => expect(screen.element.textContent).toContain('撤销 平板'));
+ expect([...screen.element.querySelectorAll('input')].some(field => field.value === 'once-secret')).toBe(false);
+ list.mockResolvedValue({ credentials: [], catalogUrl: '/opds' });
+ await click(screen.element, '撤销 平板'); await vi.waitFor(() => expect(revoke).toHaveBeenCalledWith('client-1'));
+});
+
+it('shows declared capabilities and personal access state without exposing or resubmitting stored secrets', async () => {
+ const { screen, api } = await setup(false); await chooseSource(screen.element);
+ expect(screen.element.querySelector('.source-capabilities')?.textContent).toContain('OPDS 服务端');
+ vi.spyOn(api, 'credentialStatus').mockResolvedValue({ state: 'verification-required', checkedAt: 1000, available: true,
+ fields: [{ key: 'password', label: 'OPDS 密码', configured: true }] });
+ const save = vi.spyOn(api, 'sourceCredential').mockResolvedValue();
+ await click(screen.element, '登录凭据');
+ expect(screen.element.querySelector('dialog')?.textContent).toContain('需要人工验证');
+ expect(screen.element.querySelector('dialog')?.textContent).toContain('已配置');
+ expect(screen.element.querySelector('input[type=password]')!.value).toBe('');
+ await click(screen.element, '保存个人凭据'); expect(save).not.toHaveBeenCalled();
+ await click(screen.element, '登录凭据'); await click(screen.element, '清空OPDS 密码(保存后删除)');
+ await click(screen.element, '保存个人凭据'); expect(save).toHaveBeenCalledWith('source-1', 'password', '');
+ vi.spyOn(api, 'credentialStatus').mockRejectedValue(new Error('状态暂不可用'));
+ await click(screen.element, '登录凭据'); input(screen.element, 'OPDS 密码', 'replacement');
+ await click(screen.element, '保存个人凭据'); expect(save).toHaveBeenLastCalledWith('source-1', 'password', 'replacement');
+});
+
it('separates partial failures, preserves single-source pagination and clears old results on a new search', async () => {
const { screen, api } = await setup(); await chooseSource(screen.element);
const search = vi.spyOn(api, 'searchSource');
const errors = [{ source: '测试来源', code: 'HTTP_ERROR', message: '站点返回 HTTP 503' }];
search.mockImplementationOnce(() => pages({ items: [{ ref: 'ok', title: '成功结果' }], errors }));
input(screen.element, '搜索书籍', '小说'); await click(screen.element, '搜索');
- expect(screen.element.querySelector('details')?.open).toBe(false);
+ expect(screen.element.querySelector('details:not(.source-capabilities)')?.open).toBe(false);
expect(screen.element.textContent).toContain('成功结果');
search.mockImplementationOnce(() => pages({ items: [], errors, nextCursor: 'more' }));
await click(screen.element, '搜索');
- expect(screen.element.querySelector('details')?.open).toBe(true);
+ expect(screen.element.querySelector('details:not(.source-capabilities)')?.open).toBe(true);
expect(screen.element.textContent).toContain('部分来源搜索失败');
search.mockImplementationOnce(() => pages({ items: [] })); await click(screen.element, '加载更多结果');
expect(search.mock.calls.at(-1)?.[1]).toMatchObject({ query: '小说', cursor: 'more', filters: {} });
- expect(screen.element.querySelector('details')).not.toBeNull();
+ expect(screen.element.querySelector('details:not(.source-capabilities)')).not.toBeNull();
search.mockImplementationOnce(() => pages({ items: [] }));
input(screen.element, '搜索书籍', '新关键词'); await click(screen.element, '搜索');
- expect(screen.element.querySelector('details')).toBeNull();
+ expect(screen.element.querySelector('details:not(.source-capabilities)')).toBeNull();
expect(screen.element.textContent).toContain('没有找到匹配书籍');
});
diff --git a/web/test/sync.test.ts b/web/test/sync.test.ts
index 4f94dbb..0155923 100644
--- a/web/test/sync.test.ts
+++ b/web/test/sync.test.ts
@@ -13,6 +13,19 @@ const SESSION = {
refreshTokenExpiresAt: Date.now() + 86_400_000,
};
+it('reports a failed local progress write and permits retry without losing the outbox', async () => {
+ const kv = new MemoryKv();
+ const offline = new OfflineStore(kv);
+ const progress = { bookId: 'b', locator: 'chapter:12', percentage: 0.5, chapterTitle: '12', device: 'phone', updatedAt: 20 };
+ vi.spyOn(kv, 'set').mockRejectedValueOnce(new Error('disk full'));
+ await expect(offline.setProgress(progress)).rejects.toThrow('disk full');
+ expect(offline.outbox().progress).toEqual([progress]);
+ await offline.setProgress(progress);
+ const restored = new OfflineStore(kv);
+ await restored.load();
+ expect(restored.progressFor('b')).toEqual(progress);
+});
+
function sessions() {
const kv = new MemoryKv();
return {
diff --git a/web/tools/ui-review/p1-reading.mjs b/web/tools/ui-review/p1-reading.mjs
new file mode 100644
index 0000000..f27ff81
--- /dev/null
+++ b/web/tools/ui-review/p1-reading.mjs
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve, relative, isAbsolute } from 'node:path';
+import { chromium } from 'playwright';
+import JSZip from 'jszip';
+import { openDatabase } from '../../../server/src/db/index.ts';
+import { buildApp } from '../../../server/src/http/app.ts';
+import { Scanner } from '../../../server/src/indexer/scanner.ts';
+import { UserService } from '../../../server/src/services/users.ts';
+import { ShelfService } from '../../../server/src/services/shelf.ts';
+import { SyncService } from '../../../server/src/services/sync.ts';
+import { TtsService } from '../../../server/src/services/tts.ts';
+import { BrowseService } from '../../../server/src/services/browse.ts';
+const repo=resolve(import.meta.dirname,'../../..'), root=await mkdtemp(join(tmpdir(),'reader-p1-'));
+const shots=join(repo,'docs/ui-review/p1');
+const config={booksDir:join(root,'books'),dataDir:join(root,'data'),host:'127.0.0.1',port:0,jwtSecret:'isolated-p1-test-secret',accessTokenTtl:86400,refreshTokenTtl:86400,scanInterval:0,watchInterval:0,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(repo,'web/dist')};
+let app,db,browser;
+try {
+ await Promise.all([mkdir(config.booksDir),mkdir(config.dataDir),mkdir(shots,{recursive:true})]);
+ const zip=new JSZip();zip.file('mimetype','application/epub+zip');zip.file('META-INF/container.xml','');
+ zip.file('book.opf','阅读工具测试测试 ');
+ zip.file('nav.xhtml','');
+ zip.file('chapter.xhtml','第一层
这里是需要纠错的文字,检索目标一。
'+Array.from({length:24},(_,i)=>'这是用于测试阅读定位的正文段落,第 '+i+' 段。
').join('')+'第二层
检索目标二。
第三层
结尾。
');
+ await writeFile(join(config.booksDir,'test.epub'),await zip.generateAsync({type:'nodebuffer'}));
+ await writeFile(join(config.booksDir,'test.txt'),'前言\n@@ 第一章\n正文一\n@@ 第二章\n正文二');
+ db=openDatabase(config);const ctx={config,db};app=buildApp(ctx);ctx.log=app.log;ctx.scanner=new Scanner(db,config,{info(){},warn(){}});ctx.users=new UserService(db,config);ctx.shelf=new ShelfService(db);ctx.sync=new SyncService(db,ctx.shelf);ctx.tts=new TtsService(config);ctx.browse=new BrowseService(db,config,ctx.shelf);
+ await ctx.scanner.scan();const base=await app.listen({host:'127.0.0.1',port:0});
+ const registered=await app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username:'p1-reader',password:'password123'}});assert.equal(registered.statusCode,201);
+ const books=db.all('SELECT id,format FROM books'), epub=books.find(b=>b.format==='epub'),txt=books.find(b=>b.format==='txt');
+ browser=await chromium.launch({headless:true,...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{})});
+ const context=await browser.newContext({viewport:{width:390,height:844}}), page=await context.newPage();page.setDefaultTimeout(12000);
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));const button=name=>page.getByRole('button',{name,exact:true});
+ await page.goto(base);await page.locator('input[autocomplete=username]').fill('p1-reader');await page.locator('input[type=password]').fill('password123');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
+ await page.goto(base+'/#/book/'+epub.id);await page.locator('book-content h1').waitFor();assert.ok(await button('设置').evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight));await button('工具').click();
+ for(const width of [320,390,1280]){await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'tools-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
+ await page.setViewportSize({width:390,height:844});await page.getByLabel('关键词',{exact:true}).fill('检索目标');await button('搜索全文').click();await page.locator('.reading-tool-results li').nth(1).waitFor();await page.locator('.reading-tool-results li button').nth(1).click();await page.getByRole('dialog').waitFor({state:'hidden'});
+ assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="search"]').length)>0);
+ await button('工具').click();await button('返回原阅读位置').click();
+ await button('目录').click();await page.getByRole('button',{name:'第三层',exact:false}).last().waitFor();await page.getByRole('button',{name:'第一层 折叠',exact:true}).click();assert.equal(await page.getByRole('button',{name:'第三层',exact:false}).count(),0);await page.getByRole('button',{name:'第一层 展开',exact:true}).click();await page.getByRole('button',{name:/3.*第三层/}).click();
+ await button('工具').click();await button('离线缓存').click();await button('下载 / 继续').click();await page.getByText('整书已可离线阅读',{exact:false}).waitFor();await page.screenshot({path:join(shots,'offline.png')});await button('关闭弹窗').click();
+ await context.route('**/api/**',route=>route.abort('internetdisconnected'));await page.reload();await page.locator('book-content h1').waitFor();await context.unroute('**/api/**');
+ // Select actual shadow DOM text, then create a persisted personal correction.
+ await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
+ await button('工具').click();await button('笔记').click();await page.getByLabel('批注',{exact:true}).fill('跨设备批注测试');await button('保存高亮或批注').click();await page.getByText('已保存,联网后同步').waitFor();
+ await button('关闭弹窗').click();assert.ok(await page.locator('book-content').evaluate(host=>host.shadowRoot.querySelectorAll('[data-reader-mark="note"]').length)>0);
+ await page.locator('book-content').evaluate(host=>{const node=host.shadowRoot.querySelector('p').firstChild;const range=document.createRange();range.setStart(node,3);range.setEnd(node,7);const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);});
+ await button('工具').click();await button('内容整理').click();await page.getByLabel('替换为',{exact:true}).fill('已经修正');await button('保存纠错 / 过滤').click();await page.getByText('纠错已保存,正文已更新').waitFor();await button('关闭弹窗').click();await page.reload();await page.locator('book-content p').filter({hasText:'已经修正'}).waitFor();
+ await button('工具').click();await button('内容整理').click();await button('撤销上一次整理修改').click();await page.getByText('已撤销上一次整理修改').waitFor();await button('关闭弹窗').click();assert.ok(!(await page.locator('book-content p').first().innerText()).includes('已经修正'));
+ await page.goto(base+'/#/book/'+txt.id);await page.locator('book-content p').first().waitFor();await button('工具').click();await button('内容整理').click();await page.getByLabel('标题开头').fill('@@');await button('预览目录').click();await button('应用预览规则').waitFor();await page.screenshot({path:join(shots,'txt-preview.png')});await button('应用预览规则').click();await page.getByText('目录规则已保存').waitFor();
+ assert.deepEqual(errors,[]);console.log('P1 browser flow passed: search/navigation/highlight, 320–1280px, nested TOC, offline EPUB reopen, persisted correction/undo, TXT preview/apply');
+}finally{await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));assert.ok(!cleanupRelative.startsWith('..')&&!isAbsolute(cleanupRelative)&&cleanupRelative.startsWith('reader-p1-'));await rm(root,{recursive:true,force:true});}
diff --git a/web/tools/ui-review/p2-sources.mjs b/web/tools/ui-review/p2-sources.mjs
new file mode 100644
index 0000000..227de10
--- /dev/null
+++ b/web/tools/ui-review/p2-sources.mjs
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve, relative, isAbsolute } from 'node:path';
+import { chromium } from 'playwright';
+import { openDatabase } from '../../../server/src/db/index.ts';
+import { buildApp } from '../../../server/src/http/app.ts';
+import { Scanner } from '../../../server/src/indexer/scanner.ts';
+import { UserService } from '../../../server/src/services/users.ts';
+import { ShelfService } from '../../../server/src/services/shelf.ts';
+import { SyncService } from '../../../server/src/services/sync.ts';
+import { TtsService } from '../../../server/src/services/tts.ts';
+import { BrowseService } from '../../../server/src/services/browse.ts';
+
+const repo=resolve(import.meta.dirname,'../../..'), root=await mkdtemp(join(tmpdir(),'reader-p2-'));
+const shots=join(repo,'docs/ui-review/p2');
+const config={booksDir:join(root,'books'),dataDir:join(root,'data'),host:'127.0.0.1',port:0,jwtSecret:'isolated-p2-test-secret',accessTokenTtl:86400,refreshTokenTtl:86400,scanInterval:0,watchInterval:0,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(repo,'web/dist')};
+let app,db,browser,page;
+try {
+ await Promise.all([mkdir(config.booksDir),mkdir(config.dataDir),mkdir(shots,{recursive:true})]);
+ await writeFile(join(config.booksDir,'opds-test.txt'),'OPDS 文件正文');
+ db=openDatabase(config);const ctx={config,db};app=buildApp(ctx);ctx.log=app.log;ctx.scanner=new Scanner(db,config,{info(){},warn(){}});ctx.users=new UserService(db,config);ctx.shelf=new ShelfService(db);ctx.sync=new SyncService(db,ctx.shelf);ctx.tts=new TtsService(config);ctx.browse=new BrowseService(db,config,ctx.shelf);
+ await ctx.scanner.scan();
+ const base=await app.listen({host:'127.0.0.1',port:0});
+ const registered=await app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username:'p2-reader',password:'password123'}});assert.equal(registered.statusCode,201);
+ const user=db.get('SELECT id FROM users');
+ const provider={descriptor:{id:'quality',label:'质量测试插件',version:'1',capabilities:['browse','detail','acquire.chapters','content.manifest','content.resource','content.alternatives'],credentialKeys:[{key:'password',label:'站点密码'}]},
+ async browse(){return {items:[]};},async detail(){return {ref:'original',title:'换源质量测试',authors:['测试作者']};},
+ async acquire(_ctx,request){return {kind:'chapters',publicationRef:request.entryRef};},
+ async alternatives(){return {items:[{ref:'alternative',title:'换源质量测试',authors:['测试作者'],sourceName:'备选来源',latestChapter:'空白章'}]};},
+ async getManifest(_ctx,ref){return {publicationRef:ref,items:[{id:'one',seq:0,title:'第一章',kind:'chapter',mediaType:'text/plain',ref:'body'},{id:'empty',seq:1,title:'空白章',kind:'chapter',mediaType:'text/plain',ref:'empty'}]};},
+ async readResource(_ctx,request){return {mediaType:'text/plain',text:request.ref==='empty'?' ':request.publicationRef==='original'?'原来的正文,换源失败时仍然保留。':'新来源的正文,供质量检测和阅读。'};}};
+ ctx.sources.registry.register({pluginId:'test.quality',provider});
+ await ctx.sources.create({id:'quality',pluginId:'test.quality',sourceType:'quality',name:'质量测试来源',config:{}});
+ const acquired=await ctx.sources.acquire(user.id,'quality','original');assert.equal(acquired.kind,'ready');
+ browser=await chromium.launch({headless:true,...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{})});
+ page=await browser.newPage({viewport:{width:390,height:844}});page.setDefaultTimeout(12000);
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));const button=name=>page.getByRole('button',{name,exact:true});
+ await page.goto(base);await page.locator('input[autocomplete=username]').fill('p2-reader');await page.locator('input[type=password]').fill('password123');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor();
+ await page.goto(base+'/#/sources');await button('连接外部阅读器').click();
+ await page.getByLabel('客户端名称').fill('浏览器验收客户端');await button('创建 OPDS 凭据').click();await page.getByLabel('OPDS 密码',{exact:true}).waitFor();
+ const opdsUser=await page.getByLabel('OPDS 用户名',{exact:true}).inputValue(),opdsPassword=await page.getByLabel('OPDS 密码',{exact:true}).inputValue();
+ const authorization='Basic '+Buffer.from(opdsUser+':'+opdsPassword).toString('base64');
+ const feed=await fetch(base+'/opds',{headers:{authorization}});assert.equal(feed.status,200);assert.match(await feed.text(),/opds-test/);
+ await button('关闭弹窗').click();await button('连接外部阅读器').click();await button('撤销 浏览器验收客户端').waitFor();assert.equal(await page.getByLabel('OPDS 密码',{exact:true}).count(),0);
+ for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'opds-access-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
+ await button('撤销 浏览器验收客户端').click();await button('撤销 浏览器验收客户端').waitFor({state:'hidden'});assert.equal((await fetch(base+'/opds',{headers:{authorization}})).status,401);await button('关闭弹窗').click();
+ await page.getByLabel('选择来源').selectOption('quality');
+ await page.locator('.source-capabilities summary').click();
+ for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'capabilities-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
+ await button('登录凭据').click();await page.getByText('最近一次访问成功',{exact:false}).waitFor();await page.locator('input[type=password]').fill('test-secret');await button('保存个人凭据').click();await page.getByRole('dialog').waitFor({state:'hidden'});
+ await button('登录凭据').click();await page.getByText('尚未验证访问',{exact:false}).waitFor();assert.equal(await page.locator('input[type=password]').inputValue(),'');
+ for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'credentials-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
+ await button('清空站点密码(保存后删除)').click();await button('保存个人凭据').click();await page.getByRole('dialog').waitFor({state:'hidden'});assert.equal(ctx.sources.credentialStatus('quality',user.id).fields[0].configured,false);
+ await page.goto(base+'/#/book/'+acquired.publicationId);await page.locator('book-content p').first().waitFor();await button('切换书源').click();await button('查看此源目录:备选来源').click();
+ await page.getByLabel('切换后阅读的章节').selectOption('empty');await button('检测所选章节 / 重试').click();await page.getByRole('dialog').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
+ await button('确认换源并阅读').click();await page.locator('.notyf').getByText('此章节没有可读正文',{exact:false}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'original');
+ await button('关闭提示').click();
+ await page.getByLabel('切换后阅读的章节').selectOption('one');await button('检测所选章节 / 重试').click();await page.getByText('正文检测通过',{exact:true}).waitFor();
+ for(const width of [320,390,1280]) {await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'quality-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);}
+ await button('确认换源并阅读').click();await page.getByRole('dialog').waitFor({state:'hidden'});await page.locator('book-content p').filter({hasText:'新来源的正文'}).waitFor();assert.equal(ctx.sources.chapters.binding(user.id,acquired.publicationId).publication_ref,'alternative');
+ await button('设置').click();await button('应用电纸书预设').click();
+ await page.waitForFunction(async()=>{const db=await new Promise((resolve,reject)=>{const request=indexedDB.open('reader');request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});try {const stored=await new Promise((resolve,reject)=>{const request=db.transaction('kv','readonly').objectStore('kv').get('reader.settings.v1');request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const value=JSON.parse(stored||'{}');return value.pageAnimation==='none'&&value.mode==='paged'&&value.theme==='light';}finally{db.close();}});
+ assert.deepEqual(errors,[]);console.log('PASS P2 real HTTP + SQLite + Chromium '+browser.version()+': capability matrix, private credentials save/delete/reset, empty chapter rejection, quality preview and source switch; 320/390/1280px without overflow; e-ink preset; OPDS credential lifecycle and authenticated catalog');
+} catch(error) {if(page){await page.screenshot({path:join(shots,'failure.png')});console.error((await page.locator('body').innerText()).slice(-3500));}throw error;}
+finally {await browser?.close();await app?.close();db?.close();const cleanupRelative=relative(resolve(tmpdir()),resolve(root));if(!cleanupRelative||cleanupRelative.startsWith('..')||isAbsolute(cleanupRelative))throw new Error('Unsafe cleanup path');await rm(root,{recursive:true,force:true});}