diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4ea0a0..c1f845c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,22 +1,80 @@ -name: Build extension +name: PDF Viewer CI on: pull_request: push: branches: [main] +permissions: + contents: read + +concurrency: + group: pdf-viewer-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + quality: + name: Quality / syntax and suite inventory runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: - node-version: 22 - - run: npm install + node-version: "22" + - run: npm run check:syntax + - run: npm run check:suites - run: bash -n native/install-host.sh - - run: python3 -m unittest discover -s tests -p 'test_*.py' - - run: npm test + + feature-tests: + name: Tests / ${{ matrix.feature }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + feature: + - rendering + - navigation + - references + - footnotes + - search + - selection + - document + - appearance + - chatgpt + - security + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22" + - run: npm install --no-audit --no-fund + - run: node scripts/test-suites.mjs ${{ matrix.feature }} + + native-host: + name: Native host / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: python -m unittest discover -s tests -p 'test_*.py' -v + # The historical native enhancer tests are not named test_*.py. + - run: python tests/native-enhancer.test.py -v + + packaging: + name: Packaging / build and manifest integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "22" + - run: npm install --no-audit --no-fund - run: npm run build - run: npm run check - name: Upload unpacked extension diff --git a/README.md b/README.md index 62dffa2..f17090e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ A Chrome Manifest V3 extension that replaces the normal PDF tab with a local PDF - Prepares compressed previews of every page in the background for fast scrolling, while keeping sharp rendering and selectable text near the current page. - Shows a PDF Enhancer button in the top-left section slot when a document has no section navigator; enhanced local copies retain the original web reference URL for link copying and ChatGPT summaries. +## Continuous integration and tests + +GitHub Actions runs individually reported feature suites (rendering, navigation, references, footnotes, search, selection, document metadata, appearance, ChatGPT and security), independent Linux/macOS native-host tests, JavaScript syntax checks and a separate extension packaging check. All JavaScript tests must be assigned to exactly one feature suite. See [testing documentation](docs/testing.md) for commands and coverage limits. + ## Load directly in Chrome Install dependencies once: diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..68b9ee0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,34 @@ +# PDF viewer tests and CI + +Every pull request and push to main runs distinct GitHub Actions checks: + +| Check | Coverage | +| --- | --- | +| Quality | JavaScript parse validation for all source, scripts and tests; feature-suite inventory; shell installer syntax | +| Rendering | PDF data sources, enhancement, previews, page rendering, zoom/position, printing and loading | +| Navigation | Page controls, minimap, scrubber, page tabs and printed-page navigation | +| References | Clickable PDF and section references | +| Footnotes | Indexing, scan/column/page continuations, jumps, copy and cross-references | +| Search | Normalization, multiline and URL matching, navigation and visual cues | +| Selection | Text bounds, clipboard fallback, Markdown, tagged paragraphs and punctuation | +| Document | Titles, DOI/date/source metadata and local-file favicon | +| Appearance | Themes and narrow layouts | +| ChatGPT | Summary popup and PDF transfer integration | +| Security | Manifest permissions policy, local-only executable resources, scoped integrations and accessible controls | +| Native host (Linux and macOS) | Python native messaging/installer tests, including concurrency and locking suite | +| Packaging | Build output, PDF worker/assets, bundled stream support, manifest resources and extension artifact | + +All *.test.mjs files must be owned by **exactly one** suite, enforced by the runner. Add each new test file explicitly to its corresponding feature in scripts/test-suites.mjs; otherwise quality and feature checks fail rather than silently skipping it. + +~~~sh +npm install +npm test # run every JavaScript test +npm run check:suites # show counts and validate suite ownership +npm run test:feature -- search # run just one feature's tests +npm run check:syntax # verify JavaScript syntax across project +npm run build && npm run check # validate unpacked build, manifest and resources +python3 -m unittest discover -s tests -p 'test_*.py' -v +python3 tests/native-enhancer.test.py -v +~~~ + +CI currently tests functions, PDF fixtures, source/packaging contracts and native host subprocesses. It does **not** yet run real Chrome/Brave extension UI end-to-end tests. Manual checks remain necessary for Chrome PDF MIME interception, actual browser rendering, theme color interactions and operating-system permissions. diff --git a/package.json b/package.json index 814a18e..2ab8b26 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "build": "node scripts/build.mjs", "check": "node scripts/check-extension.mjs", - "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs" + "test": "node scripts/test-suites.mjs all", + "test:feature": "node scripts/test-suites.mjs", + "check:suites": "node scripts/test-suites.mjs --list", + "check:syntax": "node scripts/check-javascript.mjs" }, "dependencies": { "pdfjs-dist": "6.3.289", diff --git a/scripts/check-extension.mjs b/scripts/check-extension.mjs index 9d1813e..a80e66b 100644 --- a/scripts/check-extension.mjs +++ b/scripts/check-extension.mjs @@ -54,6 +54,25 @@ async function validateManifest(path, expected) { } } + // Manifest resources must survive rebasing from src/ to dist/. + for (const icon of Object.values(manifest.icons || {})) { + await requireFile(join(root, icon), "extension icon"); + } + if (manifest.action?.default_icon) { + await requireFile(join(root, manifest.action.default_icon), "toolbar icon"); + } + for (const group of manifest.web_accessible_resources || []) { + for (const resource of group.resources || []) { + if (resource.includes("*")) fail(path + ": unexpected web-accessible resource wildcard: " + resource); + await requireFile(join(root, resource), "web-accessible resource"); + } + } + for (const script of manifest.content_scripts || []) { + if (!script.matches?.every((pattern) => pattern.startsWith("https://"))) { + fail(path + ": content scripts must be HTTPS scoped"); + } + } + return manifest; } diff --git a/scripts/check-javascript.mjs b/scripts/check-javascript.mjs new file mode 100644 index 0000000..3ee8394 --- /dev/null +++ b/scripts/check-javascript.mjs @@ -0,0 +1,24 @@ +import { readdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +const files = []; +async function collect(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await collect(path); + else if (entry.isFile() && /\.(?:m?js)$/.test(entry.name)) files.push(path); + } +} +for (const directory of ["src", "scripts", "tests"]) await collect(directory); +if (!files.length) throw new Error("No JavaScript sources found"); +let failed = 0; +for (const file of files.sort()) { + const checked = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" }); + if (checked.status !== 0) { + failed++; + console.error("Syntax check failed: " + file + "\n" + (checked.stderr || checked.stdout)); + } +} +console.log("Checked JavaScript syntax in " + files.length + " files; failures: " + failed); +if (failed) process.exitCode = 1; diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs new file mode 100644 index 0000000..40c7678 --- /dev/null +++ b/scripts/test-suites.mjs @@ -0,0 +1,123 @@ +import { readdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const testDirectoryUrl = new URL("../tests/", import.meta.url); +const testDirectory = fileURLToPath(testDirectoryUrl); + +// Explicit feature ownership: unlisted or multiply assigned tests fail CI. +export const suites = Object.freeze({ + "rendering": [ + "enhance-pdf.test.mjs", + "enhancer-setup.test.mjs", + "first-page-position.test.mjs", + "highlight-regions.test.mjs", + "initial-page.test.mjs", + "loading-image-setting.test.mjs", + "loading-screen.test.mjs", + "page-previews.test.mjs", + "page-resume.test.mjs", + "pdf-source.test.mjs", + "print-renderer.test.mjs", + "render-window.test.mjs", + "zoom-position.test.mjs" + ], + "navigation": [ + "document-scrubber.test.mjs", + "minimap-cache.test.mjs", + "minimap-position.test.mjs", + "minimap.test.mjs", + "page-jump.test.mjs", + "page-number-input.test.mjs", + "page-tab-labels.test.mjs", + "printed-contents.test.mjs", + "printed-page-jump.test.mjs", + "rapid-page-navigation.test.mjs" + ], + "references": [ + "clickable-reference-links.test.mjs", + "section-cross-references.test.mjs" + ], + "footnotes": [ + "footnote-click-copy.test.mjs", + "footnote-continuation-pdf.test.mjs", + "footnote-cross-references.test.mjs", + "footnote-jump.test.mjs", + "footnote-scanned-hearing.test.mjs", + "footnote-scanned-petition.test.mjs" + ], + "search": [ + "search-empty-state.test.mjs", + "search-highlight-cues.test.mjs", + "search-matches.test.mjs", + "search-navigation.test.mjs", + "search-selected-text.test.mjs", + "search-text-normalization.test.mjs", + "search-url-wrapping.test.mjs" + ], + "selection": [ + "clipboard-fallback.test.mjs", + "markdown-copy.test.mjs", + "selection-gaps.test.mjs", + "selection-lines.test.mjs", + "tagged-paragraph-copy.test.mjs", + "terminal-punctuation-selection.test.mjs" + ], + "document": [ + "document-reference-url.test.mjs", + "document-title.test.mjs", + "local-file-favicon.test.mjs", + "metadata-date.test.mjs", + "metadata-doi.test.mjs" + ], + "appearance": [ + "accent-color.test.mjs", + "narrow-viewer-layout.test.mjs" + ], + "chatgpt": [ + "chatgpt-pdf-relay.test.mjs", + "chatgpt-summary-popup.test.mjs" + ], + "security": [ + "extension-contract.test.mjs" + ] +}); + +const present = (await readdir(testDirectory)).filter((name) => name.endsWith(".test.mjs")).sort(); +const ownership = new Map(present.map((name) => [name, []])); +const missing = []; + +for (const [suite, names] of Object.entries(suites)) { + if (!names.length) throw new Error("Empty test suite: " + suite); + for (const name of names) { + if (!ownership.has(name)) missing.push(suite + ": " + name); + else ownership.get(name).push(suite); + } +} +const unowned = [...ownership].filter(([, owners]) => owners.length === 0).map(([name]) => name); +const duplicated = [...ownership].filter(([, owners]) => owners.length > 1).map(([name, owners]) => name + " (" + owners.join(", ") + ")"); +if (missing.length || unowned.length || duplicated.length) { + throw new Error([ + "Invalid CI feature test ownership:", + ...missing.map((line) => " missing: " + line), + ...unowned.map((line) => " unowned: " + line), + ...duplicated.map((line) => " duplicate: " + line), + ].join("\n")); +} + +const suite = process.argv[2] || "all"; +if (suite === "--list") { + for (const [name, files] of Object.entries(suites)) console.log(name.padEnd(12) + " " + files.length + " files"); + console.log("Total: " + present.length + " JavaScript test files; each belongs to exactly one suite."); +} else { + if (suite !== "all" && !Object.hasOwn(suites, suite)) { + throw new Error("Unknown suite '" + suite + "'. Expected: " + Object.keys(suites).join(", ") + ", all, or --list"); + } + const selected = suite === "all" ? present : suites[suite]; + console.log("Running " + selected.length + " " + suite + " test files"); + const files = selected.map((name) => fileURLToPath(new URL(name, testDirectoryUrl))); + const command = spawnSync(process.execPath, ["--test", ...files], { stdio: "inherit" }); + if (command.error) throw command.error; + if (command.signal) throw new Error("Node test runner terminated by " + command.signal); + process.exitCode = command.status ?? 1; +} diff --git a/src/viewer/print-renderer.js b/src/viewer/print-renderer.js index 756782e..557d857 100644 --- a/src/viewer/print-renderer.js +++ b/src/viewer/print-renderer.js @@ -2,13 +2,21 @@ const POINTS_PER_INCH = 72; export const PRINT_DPI = 300; +function printPixels(points, scale) { + const pixels = points * scale; + const nearestInteger = Math.round(pixels); + // Floating point multiplication can produce 3300.0000000000005 for an + // exact 11-inch page at 300 DPI. Avoid inventing a surplus raster pixel. + return Math.max(1, Math.abs(pixels - nearestInteger) < 1e-9 ? nearestInteger : Math.ceil(pixels)); +} + export function printPageMetrics(baseViewport, dpi = PRINT_DPI) { const scale = dpi / POINTS_PER_INCH; return { scale, - pixelWidth: Math.max(1, Math.ceil(baseViewport.width * scale)), - pixelHeight: Math.max(1, Math.ceil(baseViewport.height * scale)), + pixelWidth: printPixels(baseViewport.width, scale), + pixelHeight: printPixels(baseViewport.height, scale), cssWidth: `${baseViewport.width}pt`, }; } diff --git a/tests/document-scrubber.test.mjs b/tests/document-scrubber.test.mjs index 67d10e2..d23d784 100644 --- a/tests/document-scrubber.test.mjs +++ b/tests/document-scrubber.test.mjs @@ -112,7 +112,8 @@ async function loadScrubber(pages, operators, options = {}) { } const context = vm.createContext({ document: { createElement: () => element(Symbol()), querySelector: element, documentElement: element("root"), elementFromPoint: () => null }, - window: { addEventListener() {}, matchMedia: () => motionPreference, innerHeight: 800, innerWidth: 1000 }, + window: { addEventListener() {}, dispatchEvent() {}, matchMedia: () => motionPreference, innerHeight: 800, innerWidth: 1000 }, + CustomEvent: class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, pdfDocumentSessionReady: Promise.resolve({ operators, document: { numPages: pages.length, getPage: async (number) => { diff --git a/tests/enhance-pdf.test.mjs b/tests/enhance-pdf.test.mjs index 8fbc754..facdaf4 100644 --- a/tests/enhance-pdf.test.mjs +++ b/tests/enhance-pdf.test.mjs @@ -10,14 +10,16 @@ const markup = readFileSync(new URL("../src/viewer.html", import.meta.url), "utf async function enhancerFixture(sendNativeMessage, pageNumber = "1", data = new Uint8Array([37, 80, 68, 70, 45]), sourceUrl = "https://example.com/source.pdf", documentOverrides = {}, setupOptions = {}) { const elements = new Map(); const clickHandlers = new Map(); - for (const id of ["enhance-nav", "enhance-pdf", "section-enhance-pdf", "section-popover", "section-toggle", "install-enhancer", "section-nav", "page-number", "toast", "enhance-progress", "enhance-progress-bar", "enhance-progress-label", "enhance-progress-percentage"]) { + for (const id of ["enhance-nav", "enhance-pdf", "section-enhance-pdf", "section-popover", "section-toggle", "install-enhancer", "section-nav", "page-number", "toast", "enhance-progress", "enhance-progress-bar", "enhance-progress-label", "enhance-progress-percentage", "enhancer-setup-dialog", "enhancer-setup-form", "enhancer-setup-close", "enhancer-repository-url", "enhancer-checkout-path", "enhancer-setup-status", "open-enhancer-setup", "tools-menu", "tools-button"]) { elements.set(`#${id}`, { hidden: id !== "enhance-pdf", title: "Enhance PDF", value: "1", setAttribute() {}, removeAttribute() {}, - addEventListener(type, handler) { if (type === "click") clickHandlers.set(id, handler); }, + addEventListener(type, handler) { if (type === "click") clickHandlers.set(id, handler); if (type === "submit") clickHandlers.set(id + ":submit", handler); }, + showModal() { this.open = true; }, + close() { this.open = false; }, classList: { add() {}, remove() {} }, style: { setProperty() {} }, }); @@ -28,6 +30,7 @@ async function enhancerFixture(sendNativeMessage, pageNumber = "1", data = new U const progressSnapshots = []; const setupPrompts = []; const clipboardWrites = []; + const setupDownloads = []; let messageListener; let disconnectListener; const runtime = { @@ -62,6 +65,7 @@ async function enhancerFixture(sendNativeMessage, pageNumber = "1", data = new U const context = vm.createContext({ document: { querySelector: (selector) => elements.get(selector), documentElement: { classList: { add() {}, remove() {} } } }, MutationObserver: class { observe() {} }, + downloadSetupZip: (options) => setupDownloads.push(options), setTimeout() {}, clearTimeout() {}, URL, btoa: (binary) => Buffer.from(binary, "binary").toString("base64"), pdfDocumentSessionReady: Promise.resolve({ document: { getData: async () => data, ...documentOverrides } }), @@ -83,13 +87,14 @@ async function enhancerFixture(sendNativeMessage, pageNumber = "1", data = new U resolvePdfSource: async () => ({ originalUrl: new URL(sourceUrl) }), resolveDocumentReferenceUrl: async () => "https://example.com/source.pdf", chrome: { + storage: { local: { get: async () => ({}), set: async () => {} } }, runtime, tabs: { getCurrent: async () => ({ id: 42 }), update: async (_, destination) => destinations.push(destination.url) }, }, }); vm.runInContext(source, context); await vm.runInContext("enhanceCurrentPdf()", context); - return { elements, clickHandlers, destinations, messages, progressSnapshots, setupPrompts, clipboardWrites }; + return { elements, clickHandlers, destinations, messages, progressSnapshots, setupPrompts, clipboardWrites, setupDownloads }; } test("Enhance appears at the bottom of the sections menu and uses the same action", async () => { @@ -108,32 +113,34 @@ test("Enhance appears at the bottom of the sections menu and uses the same actio assert.equal(elements.get("#section-enhance-pdf").disabled, false); }); -test("missing native host asks for consent and copies a self-contained setup command", async () => { +test("missing native host opens the user-controlled setup dialog instead of executing commands", async () => { const { elements, setupPrompts, clipboardWrites } = await enhancerFixture(async () => { throw new Error("Specified native messaging host not found."); }); - assert.equal(setupPrompts.length, 1); - assert.match(setupPrompts[0], /may create or reuse your personal enhancer fork/i); - assert.deepEqual(clipboardWrites, [ - "curl -fsSL https://raw.githubusercontent.com/PDF-magic/viewer/main/native/install-host.sh | PDF_MAGIC_SETUP_APPROVED=1 bash -s -- abcdefghijklmnopabcdefghijklmnop --fork-if-available", - ]); - assert.equal(elements.get("#enhance-pdf").hidden, false); - assert.equal(elements.get("#install-enhancer").hidden, true); + assert.equal(elements.get("#enhancer-setup-dialog").open, true); + assert.equal(elements.get("#enhancer-repository-url").value, "https://github.com/PDF-magic/enhancer"); + assert.deepEqual(setupPrompts, [], "Setup never uses the old browser confirmation prompt"); + assert.deepEqual(clipboardWrites, [], "No shell commands are silently copied"); + assert.equal(elements.get("#enhance-pdf").disabled, false); }); -test("declining missing-host setup leaves the documented installer link available", async () => { - const { elements, clipboardWrites } = await enhancerFixture( - async () => { throw new Error("Specified native messaging host not found."); }, - "1", - undefined, - "https://example.com/source.pdf", - {}, - { approveSetup: false }, - ); - assert.deepEqual(clipboardWrites, []); - assert.equal(elements.get("#enhance-pdf").hidden, true); - assert.equal(elements.get("#install-enhancer").hidden, false); - assert.match(markup, / { + const { elements, clickHandlers, setupDownloads } = await enhancerFixture(async () => { + throw new Error("Specified native messaging host not found."); + }); + assert.deepEqual(setupDownloads, []); + elements.get("#enhancer-repository-url").value = "https://github.com/example/enhancer"; + elements.get("#enhancer-checkout-path").value = "/tmp/my-enhancer"; + let prevented = false; + await clickHandlers.get("enhancer-setup-form:submit")({ + preventDefault() { prevented = true; }, + }); + assert.equal(prevented, true); + assert.deepEqual(setupDownloads.map((option) => ({ ...option })), [{ + extensionId: "abcdefghijklmnopabcdefghijklmnop", + repositoryUrl: "https://github.com/example/enhancer", + checkoutPath: "/tmp/my-enhancer", + }]); }); test("enhancement errors keep the enhance action available", async () => { diff --git a/tests/extension-contract.test.mjs b/tests/extension-contract.test.mjs new file mode 100644 index 0000000..77d6b34 --- /dev/null +++ b/tests/extension-contract.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const manifest = JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")); +const markup = readFileSync(new URL("../src/viewer.html", import.meta.url), "utf8"); + +test("PDF MIME integration retains the local handler and embeddable PDF support", () => { + assert.equal(manifest.manifest_version, 3); + assert.equal(manifest.background.service_worker, "src/background.js"); + assert.deepEqual(manifest.mime_types_handler["application/pdf"], { + handler_url: "src/viewer.html", + can_embed: true, + }); +}); + +test("extension pages reject remote, inline and unsafe-eval executable code", () => { + const policy = manifest.content_security_policy?.extension_pages; + assert.ok(policy); + assert.match(policy, /script-src\s+'self'/); + assert.match(policy, /object-src\s+'self'/); + assert.doesNotMatch(policy, /'unsafe-inline'|'unsafe-eval'|https?:\/\//i); + for (const [, url] of markup.matchAll(/]*\bsrc="([^"]+)"/g)) { + assert.doesNotMatch(url, /^(?:https?:)?\/\//i); + } +}); + +test("page integrations execute only on declared HTTPS sites", () => { + assert.deepEqual(manifest.content_scripts.map((script) => script.matches), [ + ["https://www.sec.gov/*"], ["https://chatgpt.com/*"], + ]); + for (const script of manifest.content_scripts) { + for (const pattern of script.matches) assert.match(pattern, /^https:\/\//); + for (const path of [...(script.js || []), ...(script.css || [])]) { + assert.doesNotThrow(() => readFileSync(new URL("../" + path, import.meta.url))); + } + } +}); + +test("web-accessible resources are narrow, existing and site-scoped", () => { + for (const group of manifest.web_accessible_resources || []) { + assert.ok(group.resources.length > 0); + for (const path of group.resources) { + assert.ok(!path.includes("*"), "No broad web-accessible resource wildcard"); + assert.doesNotThrow(() => readFileSync(new URL("../" + path, import.meta.url))); + } + for (const pattern of group.matches) { + assert.match(pattern, /^https:\/\/[^*]+\//, "Resources must be scoped to a site"); + } + } +}); + +test("viewer exposes accessible labels for navigation, search and sharing", () => { + for (const id of ["previous-page", "next-page", "page-number", "search-input", + "search-next", "search-previous", "copy-file-url", "share-page", "tools-button"]) { + const position = markup.indexOf('id="' + id + '"'); + assert.ok(position >= 0, "Missing control #" + id); + const tag = markup.slice(markup.lastIndexOf("<", position), markup.indexOf(">", position) + 1); + assert.match(tag, /aria-label="[^"]+"/, "Missing accessible label for #" + id); + } +}); diff --git a/tests/footnote-jump.test.mjs b/tests/footnote-jump.test.mjs index 8f01f72..49a9147 100644 --- a/tests/footnote-jump.test.mjs +++ b/tests/footnote-jump.test.mjs @@ -12,6 +12,7 @@ const styles = readFileSync(new URL("../src/viewer/viewer.css", import.meta.url) function loadFootnoteHelpers() { const context = vm.createContext({ + window: { addEventListener() {} }, document: { querySelector() { return null; @@ -143,7 +144,7 @@ test("footnote indexing ignores an edge page number in a running footer", () => }, ); - assert.deepEqual(footnotesForPage(items, viewport, 5), []); + assert.equal(footnotesForPage(items, viewport, 5).length, 0); }); diff --git a/tests/page-number-input.test.mjs b/tests/page-number-input.test.mjs index 834b08f..1e7d40a 100644 --- a/tests/page-number-input.test.mjs +++ b/tests/page-number-input.test.mjs @@ -21,6 +21,7 @@ test('clicking the page-number field selects its full value', () => { }; const document = { querySelector(selector) { + if (selector === '#page-count') return { addEventListener() {} }; assert.equal(selector, '#page-number'); return pageNumberInput; }, @@ -31,3 +32,25 @@ test('clicking the page-number field selects its full value', () => { clickListener(); assert.equal(selectionCount, 1); }); + + +test('clicking the page count dispatches the ordinary page-change event for the final page', () => { + const listeners = {}; + const dispatched = []; + const input = { + max: "17", + value: "3", + addEventListener(type, listener) { listeners["input:" + type] = listener; }, + dispatchEvent(event) { dispatched.push(event.type); }, + }; + const count = { + addEventListener(type, listener) { listeners["count:" + type] = listener; }, + }; + const document = { querySelector(selector) { + return selector === "#page-number" ? input : count; + } }; + vm.runInContext(source, vm.createContext({ document, Event })); + listeners["count:click"](); + assert.equal(input.value, "17"); + assert.deepEqual(dispatched, ["change"]); +}); diff --git a/tests/render-window.test.mjs b/tests/render-window.test.mjs index 88cbfb5..1e09181 100644 --- a/tests/render-window.test.mjs +++ b/tests/render-window.test.mjs @@ -28,7 +28,7 @@ test("sharp rendering uses fractional CSS widths and oversamples without fixed d clientWidth: 160, getBoundingClientRect: () => ({ width: 160.5 }), style: { setProperty() {} }, - classList: { add() {} }, replaceChildren(...nodes) { children = nodes; }, + classList: { add() {} }, contains() { return false; }, replaceChildren(...nodes) { children = nodes; }, }; const context = vm.createContext({ renderedPages: new Set(), staleRenderedPages: new Set([1]), @@ -38,11 +38,14 @@ test("sharp rendering uses fractional CSS widths and oversamples without fixed d document: { createElement: () => ({ style: {}, getContext: () => ({}) }) }, TextLayer: class { textDivs = []; async render() {} }, pageIsInRenderWindow: () => true, + findPrintedContentsEntries: () => [], + markPdfTextStyles() {}, clickableSectionTargets: async () => new Map(), registerSearchText() {}, highlightTextLayer() {}, renderFootnoteHighlight() {}, markDocumentReady() {}, pagePreviews: { hide() {} }, pdfDocument: { async getPage() { return { getViewport: ({ scale }) => ({ width: 600 * scale, height: 800 * scale, scale, clone() { return this; } }), async getTextContent() { return { items: [] }; }, + async getStructTree() { return null; }, async getAnnotations() { return []; }, render({ viewport, transform }) { renderedViewport = viewport; @@ -150,6 +153,8 @@ test("scrolling shows cached previews immediately and defers sharp rendering unt renderingAllPages: false, priorityRenderQueue: new Set([1]), backgroundRenderQueue: new Set([2]), sharpRenderTimer: undefined, + recordCurrentPage() {}, + window: { dispatchEvent() {} }, CustomEvent: class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, clearTimeout() {}, setTimeout(callback, delay) { timers.push({ callback, delay }); return timers.length; }, pagePreviews: { update: (number) => events.push(["preview", number]) }, queuePageRender: (number) => events.push(["sharp", number]),