Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions scripts/check-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
24 changes: 24 additions & 0 deletions scripts/check-javascript.mjs
Original file line number Diff line number Diff line change
@@ -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;
123 changes: 123 additions & 0 deletions scripts/test-suites.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 10 additions & 2 deletions src/viewer/print-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};
}
Expand Down
3 changes: 2 additions & 1 deletion tests/document-scrubber.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading