Skip to content
Draft
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
203 changes: 203 additions & 0 deletions scripts/test_viewer_render_cancellation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Exercise viewer PDF rendering cancellation in a real JavaScript runtime."""

from __future__ import annotations

import shutil
import subprocess
import unittest
from pathlib import Path


REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
VIEWER_SOURCE = REPOSITORY_ROOT / "src/main/resources/static/assets/viewer/viewer.js"

NODE_HARNESS = r"""
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");

function makeElement(tagName = "div") {
return {
tagName: tagName.toUpperCase(),
hidden: false,
disabled: false,
textContent: "",
className: "",
href: "",
target: "",
rel: "",
clientWidth: 800,
children: [],
style: {},
attributes: new Map(),
setAttribute(name, value) {
this.attributes.set(name, String(value));
},
getAttribute(name) {
return this.attributes.get(name) ?? null;
},
addEventListener() {},
appendChild(child) {
this.children.push(child);
return child;
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
remove() {},
focus() {},
getContext() {
return {};
},
};
}

(async () => {
const elements = new Map();
const ids = [
"doc-meta",
"live-status",
"error",
"error-title",
"error-message",
"retry-btn",
"open-json-link",
"preview",
];
for (const id of ids) {
elements.set(id, makeElement());
}

const documentStub = {
getElementById(id) {
return elements.get(id) ?? null;
},
querySelector() {
return null;
},
createElement(tagName) {
return makeElement(tagName);
},
};

const windowStub = {
location: {
origin: "https://viewer.example.test",
search: "",
},
setTimeout() {},
open() {
return null;
},
};

const context = {
AbortController,
URL,
URLSearchParams,
console,
document: documentStub,
window: windowStub,
};
context.globalThis = context;
vm.createContext(context);

const viewerPath = process.argv[1];
const viewerSource = fs.readFileSync(viewerPath, "utf8");
vm.runInContext(
`${viewerSource}\n;globalThis.__viewerTest = { renderPdfInline };`,
context,
{ filename: viewerPath },
);

let renderStarted;
const renderStartedPromise = new Promise(resolve => {
renderStarted = resolve;
});
let resolveRender;
const renderPromise = new Promise(resolve => {
resolveRender = resolve;
});
const pdfDocument = {
numPages: 1,
async getPage() {
return {
getViewport({ scale }) {
return { width: 100 * scale, height: 200 * scale };
},
render() {
renderStarted();
return { promise: renderPromise };
},
};
},
async destroy() {},
};
context.__pdfJs = {
GlobalWorkerOptions: {},
getDocument() {
return { promise: Promise.resolve(pdfDocument) };
},
};
vm.runInContext(
"pdfJsModulePromise = Promise.resolve(globalThis.__pdfJs);",
context,
);

const controller = new AbortController();
const rendering = context.__viewerTest.renderPdfInline(
"/artifacts/document.pdf",
controller.signal,
);

// Wait until page.render() starts so the post-render abort boundary is deterministic.
await renderStartedPromise;
controller.abort();
resolveRender();
assert.equal(
await rendering,
false,
"an aborted render must report failure",
);

const preview = elements.get("preview");
assert.equal(
preview.children.length,
0,
"a superseded PDF render must not publish canvas or metadata",
);
})().catch(error => {
console.error(error.stack || String(error));
process.exitCode = 1;
});
"""


class ViewerRenderCancellationTest(unittest.TestCase):
"""Validate PDF render supersession behavior through the Node.js harness."""

def test_superseded_pdf_render_does_not_publish(self) -> None:
"""Abort an in-flight render and require zero stale DOM publication."""

node = shutil.which("node")
self.assertIsNotNone(node, "Node.js is required for the viewer runtime regression")
assert node is not None

result = subprocess.run(
[node, "-e", NODE_HARNESS, str(VIEWER_SOURCE)],
cwd=REPOSITORY_ROOT,
capture_output=True,
check=False,
text=True,
timeout=15,
)

self.assertEqual(result.returncode, 0, result.stdout + result.stderr)


if __name__ == "__main__":
unittest.main()
34 changes: 31 additions & 3 deletions src/main/resources/static/assets/viewer/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,36 @@ function getPdfJsModule() {
return pdfJsModulePromise;
}

async function renderPdfInline(path) {
async function renderPdfInline(path, abortSignal) {
const resolvedPath = resolveSameOriginHttpUrl(path);
if (resolvedPath === null) {
throw new Error("PDF preview resource is invalid");
}

if (abortSignal?.aborted) {
return false;
}

const pdfJs = await getPdfJsModule();
if (abortSignal?.aborted) {
return false;
}

const loadingTask = pdfJs.getDocument({
url: resolvedPath,
withCredentials: true,
});
const pdfDocument = await loadingTask.promise;
try {
if (abortSignal?.aborted) {
return false;
}

const page = await pdfDocument.getPage(1);
if (abortSignal?.aborted) {
return false;
}

const unscaledViewport = page.getViewport({ scale: 1 });
const availableWidth = Math.max(320, el.preview.clientWidth - 32);
const renderScale = Math.min(2, availableWidth / unscaledViewport.width);
Expand All @@ -164,6 +180,10 @@ async function renderPdfInline(path) {
canvas.setAttribute("aria-label", `Rendered first page of a ${pdfDocument.numPages}-page PDF`);

await page.render({ canvasContext: context, viewport }).promise;
if (abortSignal?.aborted) {
return false;
}

el.preview.appendChild(canvas);

const metadata = document.createElement("p");
Expand All @@ -172,6 +192,7 @@ async function renderPdfInline(path) {
? "Showing the document page."
: `Showing page 1 of ${pdfDocument.numPages}. Open the artifact for the complete document.`;
el.preview.appendChild(metadata);
return true;
} finally {
await pdfDocument.destroy();
}
Expand Down Expand Up @@ -265,12 +286,19 @@ async function poll(docId, abortSignal) {
clearPreview();
const path = bootstrap.data.previewResourcePath;
if (typeof path === "string" && path.endsWith(".pdf")) {
await renderPdfInline(path);
await renderPdfInline(path, abortSignal);
if (abortSignal.aborted) {
return;
}
}
if (typeof path === "string" && path.length > 0) {
renderPreviewLink(path);
}

if (abortSignal.aborted) {
return;
}

el.preview.setAttribute("aria-busy", "false");
el.liveStatus.textContent = "Ready.";
el.retryBtn.disabled = false;
Expand Down Expand Up @@ -348,4 +376,4 @@ async function init() {
start();
}

void init();
void init();
Loading