From 46086e8838c424bcb64936589d8bee8571e254c8 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:06:49 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A7=AA=20Expand=20feature-isolated=20?= =?UTF-8?q?CI=20and=20regression=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run all 53 existing JavaScript test files across ten independent feature checks, add manifest security/accessibility contract cases, and enforce complete test ownership. Separate native-host tests on Linux/macOS from syntax and extension packaging validation, and document local test commands. Co-authored-by: Codex Web --- .github/workflows/build.yml | 70 +++++++++++++++-- README.md | 4 + docs/testing.md | 34 +++++++++ package.json | 5 +- scripts/check-extension.mjs | 19 +++++ scripts/check-javascript.mjs | 24 ++++++ scripts/test-suites.mjs | 123 ++++++++++++++++++++++++++++++ tests/extension-contract.test.mjs | 61 +++++++++++++++ 8 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 docs/testing.md create mode 100644 scripts/check-javascript.mjs create mode 100644 scripts/test-suites.mjs create mode 100644 tests/extension-contract.test.mjs 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/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); + } +}); From 31b3565cc8dafdc36c6736883323df7c5fd62c2b Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:10:32 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Repair=20newly=20activated?= =?UTF-8?q?=20PDF=20viewer=20regression=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update historical VM browser fixtures for enhancer setup, footnote navigation, page controls, and rendering. Fix print raster rounding that introduced an extra pixel for exact 300 DPI page dimensions. Co-authored-by: Codex Web --- src/viewer/print-renderer.js | 12 +++++-- tests/document-scrubber.test.mjs | 3 +- tests/enhance-pdf.test.mjs | 55 ++++++++++++++++++-------------- tests/footnote-jump.test.mjs | 1 + tests/page-number-input.test.mjs | 23 +++++++++++++ tests/render-window.test.mjs | 7 +++- 6 files changed, 73 insertions(+), 28 deletions(-) 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..940d4d0 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, [{ + 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/footnote-jump.test.mjs b/tests/footnote-jump.test.mjs index 8f01f72..3071169 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; 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]), From 59cf3de828b893d29c340cf5c01072029e0ee80b Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:12:23 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Compare=20cross-realm=20test?= =?UTF-8?q?=20fixtures=20by=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize setup options returned from the VM test context and assert footnote list cardinality so Node's strict assertions do not reject otherwise equal cross-realm array and object prototypes. Co-authored-by: Codex Web --- tests/enhance-pdf.test.mjs | 2 +- tests/footnote-jump.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/enhance-pdf.test.mjs b/tests/enhance-pdf.test.mjs index 940d4d0..facdaf4 100644 --- a/tests/enhance-pdf.test.mjs +++ b/tests/enhance-pdf.test.mjs @@ -136,7 +136,7 @@ test("enhancer setup submits a ZIP request only after explicit form submission", preventDefault() { prevented = true; }, }); assert.equal(prevented, true); - assert.deepEqual(setupDownloads, [{ + assert.deepEqual(setupDownloads.map((option) => ({ ...option })), [{ extensionId: "abcdefghijklmnopabcdefghijklmnop", repositoryUrl: "https://github.com/example/enhancer", checkoutPath: "/tmp/my-enhancer", diff --git a/tests/footnote-jump.test.mjs b/tests/footnote-jump.test.mjs index 3071169..49a9147 100644 --- a/tests/footnote-jump.test.mjs +++ b/tests/footnote-jump.test.mjs @@ -144,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); });