diff --git a/scripts/test_viewer_render_cancellation.py b/scripts/test_viewer_render_cancellation.py new file mode 100644 index 000000000..570899d17 --- /dev/null +++ b/scripts/test_viewer_render_cancellation.py @@ -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() diff --git a/src/main/resources/static/assets/viewer/viewer.js b/src/main/resources/static/assets/viewer/viewer.js index a998675c7..33ec05397 100644 --- a/src/main/resources/static/assets/viewer/viewer.js +++ b/src/main/resources/static/assets/viewer/viewer.js @@ -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); @@ -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"); @@ -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(); } @@ -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; @@ -348,4 +376,4 @@ async function init() { start(); } -void init(); +void init(); \ No newline at end of file