// @vitest-environment jsdom import { describe, expect, it } from 'vitest'; import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts'; import { BOOK_RESOURCE_MARKER } from '../src/formats/book-resource.ts'; /** * Sanitisation and style extraction. * * These run against a real DOM (jsdom) rather than a string, because the whole * point is to catch what the HTML parser produces — a string-level rewrite cannot * see the difference between a `'); sanitiseInjectedContent(node); expect(node.querySelector('script')).toBeNull(); expect(node.textContent).toContain('text'); }); it('strips inline event handlers', () => { // These survive an innerHTML round trip, so they must be removed explicitly // rather than trusted to the parser. const node = parse(''); sanitiseInjectedContent(node); expect(node.querySelector('img')?.getAttribute('onerror')).toBeNull(); }); it('strips a javascript: href but keeps the link text', () => { const node = parse('click'); sanitiseInjectedContent(node); const anchor = node.querySelector('a'); expect(anchor?.hasAttribute('href')).toBe(false); expect(anchor?.textContent).toBe('click'); }); it('drops a remote src so a book cannot report a reading session', () => { const node = parse(''); sanitiseInjectedContent(node); expect(node.querySelector('img')?.hasAttribute('src')).toBe(false); }); it('keeps the internal resource scheme and blob/data URLs', () => { const node = parse( '', ); sanitiseInjectedContent(node); const sources = [...node.querySelectorAll('img')].map((img) => img.getAttribute('src')); expect(sources).toEqual(['reader-res:OEBPS/a.png', 'data:image/png;base64,AA', 'blob:xyz']); }); it('agrees with the server on the name of the book-resource marker', () => { // The server writes this string into the URLs it rewrites // (`server/src/indexer/formats/epub.ts`) and the check below reads it. The two // are the server and the client, so neither can import the other's constant — // each asserts the literal, and a rename that landed on one side would silently // stop every illustration in every EPUB from rendering. expect(BOOK_RESOURCE_MARKER).toBe('__reader-book-resource__'); }); it('keeps a marked absolute asset URL, which is how an illustrated EPUB renders', () => { // The report: "epub的图片没有显示出来" — every illustration in the book came // out as the browser's broken-image placeholder, and the cause was here. // // The server rewrites a chapter's relative references to *absolute* URLs, // because the chapter is fetched from the asset endpoint and `images/pic.png` // resolves to nothing from there. This function then kept an absolute URL only // when it started with the page's own `location.origin` — which is the app's // origin, not the API's — so every rewritten `src` was dropped. The check has // to be "is this the book's resource", which is what the marker says, rather // than "does this look like our origin", which is a guess that was wrong. const node = parse( ``, ); sanitiseInjectedContent(node); const src = node.querySelector('img')?.getAttribute('src') ?? ''; expect(src).toContain('ref=OEBPS%2Fimages%2Fpic.png'); }); it('resolves a relative URL from a book loaded locally', () => { // A book read straight from the file system never went through the server's // rewriter, so its references are still relative. Resolving them against the // document is what makes those illustrations work too — and it is why the // rewrite is a resolution rather than an allowlist of strings. const node = parse(''); sanitiseInjectedContent(node); const src = node.querySelector('img')?.getAttribute('src') ?? ''; expect(src).not.toBe(''); expect(src.startsWith('http')).toBe(true); expect(src.endsWith('images/pic.png')).toBe(true); }); it('still drops a remote URL, marked or not', () => { // The marker is a *statement by the server*, so a book cannot forge it into // permission to reach a third party: an absolute URL is resolved and then // checked against the same rule, and a host that is not the document's own is // dropped either way. Stated as a test because the marker must not have // weakened the property this function exists for. const node = parse(``); sanitiseInjectedContent(node); expect(node.querySelector('img')?.hasAttribute('src')).toBe(false); }); it('drops a remote srcset and keeps the local candidates', () => { const node = parse( '', ); sanitiseInjectedContent(node); const srcset = node.querySelector('img')?.getAttribute('srcset') ?? ''; expect(srcset).not.toContain('tracker.example'); expect(srcset).toContain('images/a.png 1x'); expect(srcset).toContain('images/c.png 3x'); }); it('signs a book resource with the session token, which the browser cannot do itself', () => { // The report: "图片还是加载不出来". The address allow-list was already right — // the illustration survived with its `src` intact — and the request was still // refused, because the asset endpoint is authenticated and a sub-resource request // cannot carry an `Authorization` header. The only credential an `` can // present is one in its own URL, and nothing was putting it there. // // This is the case a functional test cannot see: the request is issued by the // browser, not by the client, so "did the client ask correctly" has nothing to do // with it. What can be asserted is that the address the browser is *handed* is // self-authenticating. const node = parse( ``, ); sanitiseInjectedContent(node, { signAssetUrl: (url) => `${url}&access_token=tok`, }); const src = node.querySelector('img')?.getAttribute('src') ?? ''; expect(src).toContain('access_token=tok'); expect(src).toContain('ref=OEBPS%2Fimages%2Fpic.png'); }); it('never hands the token to an address the allow-list refused', () => { // Ordering, and it is a security property rather than a nicety: the signer runs // *after* the decision that a URL may be fetched. If it ran before, or on the raw // markup, a book could name a third party and have the reader's session token // appended to the request for it — which is precisely the leak the allow-list // exists to prevent. const seen: string[] = []; const node = parse( ``, ); sanitiseInjectedContent(node, { signAssetUrl: (url) => { seen.push(url); return `${url}?access_token=tok`; }, }); expect(seen.some((url) => url.includes('tracker.example'))).toBe(false); // The remote one lost its `src`; the internal scheme is signed, because it is a // resource of this book and never touches the network. expect(node.querySelectorAll('img')[0]?.hasAttribute('src')).toBe(false); expect(seen).toEqual(['reader-res:a.png']); }); it('signs a css url inside an injected style block', () => { // A publisher ships a background image or a `@font-face` as a CSS URL, and the // browser fetches that itself too — the same constraint, the same refusal. The // element's `style` attribute and its ``, ); sanitiseInjectedContent(node, { signAssetUrl: (url) => `${url}&access_token=tok` }); expect(node.querySelector('style')?.textContent ?? '').toContain('access_token=tok'); }); it('leaves addresses alone when there is no session', () => { // No signer means no session (a test, a local file, a signed-out shell), and the // chapter must still render: the function returns null and the address is passed // through exactly as the allow-list left it. const node = parse( ``, ); sanitiseInjectedContent(node, { signAssetUrl: () => null }); expect(node.querySelector('img')?.getAttribute('src')).toBe( `/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=a.png`, ); }); it('removes a base element and a meta refresh, which would navigate away', () => { const node = parse(''); sanitiseInjectedContent(node); expect(node.querySelector('base')).toBeNull(); expect(node.querySelector('meta')).toBeNull(); }); it('removes target so a footnote link cannot replace the reader', () => { const node = parse('note'); sanitiseInjectedContent(node); expect(node.querySelector('a')?.hasAttribute('target')).toBe(false); expect(node.querySelector('a')?.getAttribute('href')).toBe('reader-res:notes.xhtml'); }); it('leaves ordinary content untouched', () => { const node = parse('

正文

强调漢かん'); sanitiseInjectedContent(node); expect(node.textContent).toBe('正文强调漢かん'); expect(node.querySelector('ruby')).not.toBeNull(); }); }); describe('the sheet for content inside a chapter', () => { /** * The rules that keep a book's own markup from breaking the page. * * Asserted on the *host's* adopted sheets rather than on a rendered layout, * because jsdom does no layout: what is checkable here is that the sheet exists, * that it names the properties that matter, and — the one that decides whether the * author's design survives — that it sits *before* the book's own stylesheet in * the shadow root, so a chapter that styles its own images still wins. */ it('clamps images that are wider than the column', () => { const host = createBookHost(); const css = [...host.shadow.querySelectorAll('style')].map((el) => el.textContent ?? '').join('\n'); // The clamp itself, and the `height: auto` that keeps the aspect ratio: a // `max-width` alone squashes the plate, which is the failure a fix for the // width introduces. expect(css).toMatch(/\.book-flow img[^}]*max-width:\s*min\(100%/); expect(css).toMatch(/\.book-flow img[^}]*height:\s*auto/); }); it('keeps the sheet below the book, so an author who sizes an image still wins', () => { const host = createBookHost(); const sheets = [...host.shadow.querySelectorAll('style')]; // The order is the fix, not decoration: same specificity means the later sheet // wins, and the book's own stylesheet is the one that has to. const content = sheets.findIndex((el) => (el.textContent ?? '').includes('reader-image-max')); const book = sheets.findIndex((el) => el === (host as unknown as { styleEl: HTMLStyleElement }).styleEl); expect(content).toBeGreaterThanOrEqual(0); expect(book).toBeGreaterThan(content); }); }); describe('extractBody', () => { it('returns only the body of a full XHTML document', () => { const html = 't

内容

'; expect(extractBody(html)).toBe('

内容

'); }); it('returns the input unchanged when there is no body element', () => { expect(extractBody('

fragment

')).toBe('

fragment

'); }); }); describe('extractInlineStyles', () => { it('collects style blocks in document order', () => { const html = '

x

'; expect(extractInlineStyles(html)).toEqual(['a{color:red}', 'b{color:blue}']); }); it('returns an empty list when there are none', () => { expect(extractInlineStyles('

x

')).toEqual([]); }); }); /** * The host's own styling. * * These exist because the reading column was, for a while, completely unstyled * and nothing said so. `.book-flow` is inside a shadow root, so the rules in * `styles/reader.css` matched nothing at all — a document stylesheet cannot reach * into a shadow root, and the failure is invisible from the outside: the book's * own CSS still applied, so the page looked *styled*, just without a measure, a * margin, a font scale or any columns. What is asserted here is the mechanism, * not the appearance: the layout sheet is in the shadow root, it survives a * chapter change, and it reaches the host's own state attribute through `:host()` * rather than through a class selector that cannot match. */ describe('BookShadowHost', () => { it('injects the reading column rules into the shadow root', () => { const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; // The measure, the margins, the font scale and the columns: the four things // the document stylesheet was supposedly providing. expect(sheet).toContain('.book-flow'); expect(sheet).toContain('max-inline-size: var(--reader-measure'); expect(sheet).toContain('padding-inline: var(--reader-page-margin'); expect(sheet).toContain('font-size: calc(1em * var(--reader-font-scale'); // A column is one *page*, so it is sized to the box it is paginated into rather // than to the viewport. `100vw` was the same number only while the reading // surface happened to span the whole screen — and it does not: the two floating // bars inset it, and the reader's page margin is a property of the column. A // column wider than its page makes the stride arithmetic walk positions that are // not page boundaries, and the last column of every chapter becomes unreachable. const pagedRule = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? ''; expect(pagedRule).toContain('column-width: 100%'); expect(pagedRule).not.toContain('100vw'); }); it('keeps the page margin in paged mode, and drops only the vertical padding', () => { // The same book looked deliberate in scroll mode and unfinished in paged mode, // because the paged rule zeroed the whole `padding` — so a chapter was typeset // flush against the edge of the screen the moment the reader chose columns. // There is no screenshot assertion that catches this and no functional one // either: the text is *there*, it is simply in the wrong place. const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; const paged = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? ''; expect(paged).toContain('padding-block: 0'); expect(paged).not.toMatch(/padding:\s*0/); }); it('reaches the host state through :host(), not through a class selector', () => { // `data-paginated` and `data-animating` are set on the host element, which is // *outside* the shadow tree. `.book-host[data-paginated] .book-flow` cannot // match it from inside — which is why paged mode silently produced no columns // and a page turn had nowhere to go. const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; expect(sheet).toContain(":host([data-paginated='true']) .book-flow"); // The page-turn selector carries the *axis* as well as the style and direction: // a paged turn is horizontal and a scroll-mode turn is vertical, and the axis // has to come from the selector because the two modes share one host. expect(sheet).toContain(":host([data-animating='slide-next-x']) .book-flow"); expect(sheet).toContain(":host([data-animating='slide-next-y']) .book-flow"); expect(sheet).not.toContain('.book-host['); }); it('clips a page turn on the box the reader does not scroll', () => { // The clip has to land on a box that is *not* the scroll container, and which box // that is depends on the mode — `ReaderView.scroller` says so. An `overflow: clip` // on the scroller replaces it with a box that cannot scroll, and a box that cannot // scroll has no scroll offset: the turn had already written the new offset, the // attribute then discarded it, the animation played over the *old* page, and the // offset reappeared one frame after `data-animating` was cleared. That is the // reader's report — a slide, then the page jumping to where it was always going. // // So this asserts the *pair*: the clip is on the host in paged mode (where the flow // is the scroller) and on the flow in scroll mode (where the host is). A single // selector for both would be the bug, whichever one it named. const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; const paged = /:host\(\[data-animating\]\[data-paginated='true'\]\) \{[^}]*\}/.exec(sheet)?.[0] ?? ''; const scroll = /:host\(\[data-animating\]:not\(\[data-paginated='true'\]\)\) \.book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? ''; expect(paged).toContain('overflow: clip'); expect(scroll).toContain('overflow: clip'); // And the old form — the clip unconditionally on the flow, which is the scroller in // paged mode — is gone, because it is the defect rather than a simplification. expect(sheet).not.toMatch(/:host\(\[data-animating\]\) \.book-flow \{[^}]*overflow: clip/); }); it('separates two paginated columns by exactly two page margins', () => { // The page margin is padding *inside* the scrolling box, so the page the reader // sees is `clientWidth` wide while a page *step* was `clientWidth - 2 * margin`. // The two disagree by the margin, which is why the right edge of every page // showed the first characters of the next one — the bleed reported as // "左右翻页的样式不对". A gap of both margins makes the step equal the page and // draws the gutter between columns at the same time. const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; expect(sheet).toContain('column-gap: calc(var(--reader-page-margin, 1.5rem) * 2)'); }); it('declares border-box for the shadow tree, which the document cannot reach', () => { // The app's own universal `box-sizing: border-box` is a *document* selector, and // a document selector cannot match a shadow tree's elements. The reading column // is width 100% plus the reader's page margin, which under the initial // `content-box` is a box wider than the page it is paginated into. const host = createBookHost(); const sheet = host.shadow.querySelector('style')?.textContent ?? ''; expect(sheet).toContain('box-sizing: border-box'); }); it('keeps the layout sheet when a chapter replaces the book styles', () => { // The book's styles change with every chapter; the reader's layout rules must // not, or the column loses its measure for a frame on every chapter change. const host = createBookHost(); const before = sheets(host)[0]; host.setContent('

正文

', ['p { color: red }']); const after = sheets(host); expect(after[0]).toBe(before); expect(after[after.length - 1]).toContain('p { color: red }'); // The layout sheet is first, so the book's own rules still win the cascade. expect(after[0]).toContain('.book-flow'); }); it('keeps the layout sheet through clear()', () => { const host = createBookHost(); host.clear(); const after = sheets(host); expect(after[0]).toContain('.book-flow'); // Only the *book's* sheet is emptied by a clear; the reader's own sheets are // not this method's business. expect(after[after.length - 1]).toBe(''); }); it('injects the plain-text typesheet only when the book asks for it', () => { // A TXT has no author stylesheet to defer to, so the reader supplies one; an // EPUB does, so it must be absent rather than merely overridable. The absence // is the contract, which is why it is asserted and not just the presence. const host = createBookHost(); expect(sheets(host).join('\n')).not.toContain('.txt-body'); host.setPlainText(true); expect(sheets(host).join('\n')).toContain('.txt-body > p'); expect(host.hasAttribute('data-plain-text')).toBe(true); host.setPlainText(false); expect(sheets(host).join('\n')).not.toContain('.txt-body'); expect(host.hasAttribute('data-plain-text')).toBe(false); }); it('puts the plain-text sheet before the book sheet, so a book still wins', () => { // A TXT has no styles to lose, but a book *classified* as one (a `.txt` that is // really an XHTML fragment) does, and the cascade has to leave room for it. const host = createBookHost(); host.setPlainText(true); host.setContent('

正文

', ['p { color: red }']); const after = sheets(host); const txt = after.findIndex((sheet) => sheet.includes('.txt-body')); const book = after.findIndex((sheet) => sheet.includes('p { color: red }')); expect(txt).toBeGreaterThanOrEqual(0); expect(txt).toBeLessThan(book); }); it('keeps the plain-text decision across a chapter change', () => { // Which typesheet applies is a property of the *book*, not of the chapter on // screen; a TXT that lost its stylesheet on the second chapter would read as a // rendering bug at exactly the moment the reader turned the page. const host = createBookHost(); host.setPlainText(true); host.setContent('

第一章

', []); host.setContent('

第二章

', []); expect(host.shadow.querySelector('.txt-body')).not.toBeNull(); expect(sheets(host).join('\n')).toContain('.txt-body > p'); }); }); /** The shadow root's sheets, in cascade order. */ function sheets(host: ReturnType): string[] { return [...host.shadow.querySelectorAll('style')].map((node) => node.textContent ?? ''); } describe('plain-text detection', () => { it('is driven by the format, and confirmed against the markup', async () => { // The two have to agree, and the client cannot assume they do. A server that // windows a TXT as `reflowable` — a reasonable choice, since a TXT is reflowable // — declares `format: 'reflowable'` while sending the reader's own plain-text // markup; believing the label alone left those paragraphs unstyled and the indent // control doing nothing. const { ReaderView } = await import('../src/ui/reader-view.ts'); const container = document.createElement('div'); document.body.append(container); const declared = new ReaderView({ container, doc: textDoc('txt', '

一

') }); expect(declared.isPlainText()).toBe(true); const observed = new ReaderView({ container, doc: textDoc('epub', '

一

') }); expect(observed.isPlainText()).toBe(true); // An EPUB chapter that merely *mentions* the marker in its prose is not one. const prose = new ReaderView({ container, doc: textDoc('epub', '

他说 div class="txt-body" 是纯文本用的

') }); expect(prose.isPlainText()).toBe(false); // Neither is one with no markup at all — *unless* the section declares itself. // And that declaration is the case the marker cannot cover: the server now sends // a TXT chapter as bare characters with no wrapper at all, so a chapter that is // nothing but plain text carries nothing to sniff. Sniffing for the marker // answered "not plain text" and the reader drew a novel with no paragraphs, no // indent and no stylesheet — a defect that reads as a typography preference // rather than a bug. const bare = new ReaderView({ container, doc: textDoc('epub', '

正文

') }); expect(bare.isPlainText()).toBe(false); const declaredBare = new ReaderView({ container, doc: textDoc('epub', '第一章 惊蛰\n\n第 1 段。', { plainText: true }), }); expect(declaredBare.isPlainText()).toBe(true); }); }); /** A one-section document, for the detection test. */ function textDoc(format: string, html: string, section: Record = {}) { return { format, layout: 'reflowable', render: 'reflowable', direction: 'ltr', sections: [{ id: 'c0', label: '一', html, depth: 0, ...section }], toc: [], styles: [], resources: new Map(), orderedByBook: true, } as never; }