// SPDX-License-Identifier: MIT // Bounded, signed-out browser tests using synthetic files only. import { chromium } from "@playwright/test"; import { PDFDocument } from "pdf-lib"; import { execFileSync } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { createHash } from "node:crypto"; const directory = resolve(process.env.PDF_STUDY_DIR || "public/research/scanned-pdf-fillers/2026-09-29"); const evidence = resolve(process.env.PDF_STUDY_EVIDENCE || "/tmp/nofuss-study-evidence"); const providerFilter = (process.env.PDF_STUDY_PROVIDERS || "nofuss,simplepdf,pdf24,sejda,pdfescape,smallpdf").split(","); const fixtureFilter = (process.env.PDF_STUDY_FIXTURES || "flat,scan").split(","); const scout = process.env.PDF_STUDY_SCOUT === "1"; const scoutEdit = process.env.PDF_STUDY_SCOUT_EDIT === "1"; const providers = [ { id: "nofuss", name: "No Fuss PDF", url: "https://nofusspdf.com/fill-pdf" }, { id: "simplepdf", name: "SimplePDF", url: "https://simplepdf.com/editor" }, { id: "pdf24", name: "PDF24", url: "https://tools.pdf24.org/en/edit-pdf" }, { id: "sejda", name: "Sejda", url: "https://www.sejda.com/sign-pdf" }, { id: "pdfescape", name: "PDFescape", url: "https://www.pdfescape.com/online-pdf-editor/" }, { id: "smallpdf", name: "Smallpdf", url: "https://smallpdf.com/pdf-form-filler" } ].filter((provider) => providerFilter.includes(provider.id)); const fixtures = JSON.parse(await readFile(resolve(directory, "fixtures.json"), "utf8")).filter((fixture) => fixtureFilter.includes(fixture.id)); await mkdir(evidence, { recursive: true }); async function describe(page) { return page.evaluate(() => ({ title: document.title, text: document.body.innerText.slice(0, 2400), inputs: [...document.querySelectorAll("textarea,[contenteditable='true'],input")].map((el) => ({ tag: el.tagName, type: el.getAttribute("type"), id: el.id, name: el.getAttribute("name"), accept: el.getAttribute("accept"), placeholder: el.getAttribute("placeholder"), label: el.getAttribute("aria-label") })).slice(0, 35), buttons: [...document.querySelectorAll("button,[role='button']")].filter((el) => el.getBoundingClientRect().width).map((el) => ({ text: (el.innerText || "").trim().slice(0, 70), title: el.getAttribute("title"), label: el.getAttribute("aria-label"), id: el.id })).slice(0, 50), canvases: [...document.querySelectorAll("canvas")].map((el) => ({ class: el.className, width: el.width, height: el.height })), frames: [...document.querySelectorAll("iframe")].map((el) => ({ title: el.title, src: el.src.split("?")[0] })) })); } async function rejectOptionalCookies(page) { for (const frame of page.frames()) { if (frame.url().includes("consent.pdf24.org")) { const settingsLink = frame.getByText("Settings", { exact: true }); if (await settingsLink.isVisible().catch(() => false)) { await settingsLink.click(); const reject = frame.getByText(/^(Reject all|Reject|Refuse all)$/i).first(); await reject.waitFor({ state: "visible", timeout: 5000 }); await reject.click(); return; } } for (const name of [/Reject all/i, /Decline optional/i, /Only necessary/i, /Continue without accepting/i]) { const button = frame.getByRole("button", { name }).first(); if (await button.isVisible().catch(() => false)) { await button.click(); return; } } const settings = frame.getByRole("button", { name: /^(Cookie settings|Settings)$/i }).first(); if (await settings.isVisible().catch(() => false)) { await settings.click(); const saveSettings = frame.getByRole("button", { name: "Save All Settings", exact: true }); if (await saveSettings.isVisible().catch(() => false)) { for (const check of await frame.getByRole("checkbox").all()) { if (await check.isVisible() && await check.isEnabled()) await check.uncheck(); } await saveSettings.click(); return; } for (const name of [/Reject all/i, /Deny/i, /Save.*preferences/i, /Confirm.*choices/i, /Save All Settings/i, /Save settings/i]) { const button = frame.getByRole("button", { name }).first(); if (await button.isVisible().catch(() => false)) { await button.click(); return; } } } } } async function upload(page, provider, fixturePath) { await rejectOptionalCookies(page); if (provider.id === "pdfescape") { const free = page.getByRole("link", { name: /Use Unregistered for Free|Use Free/i }).first(); if (await free.isVisible().catch(() => false)) { await free.click(); await page.waitForTimeout(2000); await rejectOptionalCookies(page); } const uploadLink = page.getByText(/Upload PDF to PDFescape|Upload PDF/i).first(); if (await uploadLink.isVisible().catch(() => false)) await uploadLink.click(); } const fileInput = page.locator('input[type="file"]'); await fileInput.first().waitFor({ state: "attached", timeout: 15000 }); const count = await fileInput.count(); let chosen = fileInput.first(); for (let index = 0; index < count; index++) { const accept = await fileInput.nth(index).getAttribute("accept"); if (accept?.toLowerCase().includes("pdf")) { chosen = fileInput.nth(index); break; } } await chosen.setInputFiles(fixturePath); } async function startText(page, provider) { await rejectOptionalCookies(page); if (provider.id === "sejda") { const tool = page.getByRole("button", { name: "Text", exact: true }); if (!(await tool.getAttribute("class"))?.split(" ").includes("active")) await tool.click(); } if (provider.id === "pdf24") await page.locator('[data-role="addTextButton"]').click(); if (provider.id === "smallpdf") { const labelled = page.getByRole("button", { name: "Add text", exact: true }); if (await labelled.isVisible().catch(() => false)) await labelled.click(); else { // The alternate toolbar renders an unlabelled T icon; selector observed in its live DOM. await page.locator("button").filter({ has: page.locator('svg path[d="M12 5v14h4v1H7v-1h4V5H5v3H4V4h15v4h-1V5z"]') }).click(); } } const selectors = { nofuss: ".pdf-page", simplepdf: "canvas.react-pdf__Page__canvas", pdf24: "canvas.upper-canvas", sejda: "canvas.page", smallpdf: "canvas.hacc" }; if (!selectors[provider.id]) throw new Error("Provider interaction adapter not yet verified"); const frame = page.locator(selectors[provider.id]).last(); await frame.waitFor({ state: "visible" }); const bounds = await frame.boundingBox(); if (provider.id === "pdf24") { await page.mouse.move(bounds.x + bounds.width * .5, bounds.y + bounds.height * .55); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width * .27, bounds.y + bounds.height * .32, { steps: 10 }); await page.mouse.up(); await page.mouse.dblclick(bounds.x + bounds.width * .27, bounds.y + bounds.height * .32); } else { await frame.scrollIntoViewIfNeeded(); const current = await frame.boundingBox(); await page.mouse.click(current.x + current.width * .25, current.y + current.height * .32); } } async function fillAndExport(page, provider, answer) { await startText(page, provider); if (provider.id === "nofuss") { await page.getByRole("textbox", { name: "Text", exact: true }).fill(answer); const pending = page.waitForEvent("download", { timeout: 20000 }); void pending.catch(() => undefined); await page.getByRole("button", { name: "Download filled PDF" }).click(); return pending; } if (provider.id === "simplepdf") { await page.getByPlaceholder("Aa", { exact: true }).fill(answer); await page.getByRole("button", { name: "Download", exact: true }).click({ trial: true }); const pending = page.waitForEvent("download", { timeout: 20000 }); void pending.catch(() => undefined); await page.getByRole("button", { name: "Download", exact: true }).click(); return pending; } if (provider.id === "sejda") { const editor = page.locator('[contenteditable="true"]').last(); await editor.fill(answer); await page.getByRole("button", { name: "Apply changes", exact: true }).click(); const pending = page.waitForEvent("download", { timeout: 30000 }); void pending.catch(() => undefined); await page.frameLocator('iframe[src*="/result"]').getByText("Download", { exact: true }).first().click(); return pending; } if (provider.id === "pdf24" || provider.id === "smallpdf") { await page.keyboard.press("ControlOrMeta+A"); await page.keyboard.type(answer); await page.keyboard.press("Escape"); const pending = page.waitForEvent("download", { timeout: 30000 }); void pending.catch(() => undefined); if (provider.id === "smallpdf") await page.getByRole("button", { name: "Download", exact: true }).click(); else { const save = page.locator('[data-role="savePdf"]'); if (await save.isVisible().catch(() => false)) await save.click(); else await page.getByRole("button", { name: "Save PDF", exact: true }).click(); await page.frameLocator('iframe[src*="job-monitor"]').getByText("Download", { exact: true }).first().click(); } return pending; } throw new Error("Provider text/download adapter not yet verified"); } const results = []; const browser = await chromium.launch(); try { for (const provider of providers) { for (const fixture of fixtures) { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); if (provider.id === "nofuss") await context.setExtraHTTPHeaders({ "X-No-Fuss-Test": "playwright" }); const page = await context.newPage(); page.setDefaultTimeout(15000); const input = await readFile(resolve(directory, fixture.file)); const hosts = new Set(); const contentRequests = []; let phase = "load"; const result = { provider: provider.id, name: provider.name, url: provider.url, fixture: fixture.id, inputSha256: fixture.sha256, checkedAt: new Date().toISOString(), browser: `Chromium ${browser.version()}`, viewport: [1440, 1000], completed: false, output: null, observedContentRequests: contentRequests, observedHosts: [], reached: null, error: null }; context.on("request", (request) => { let url; try { url = new URL(request.url()); } catch { return; } if (!/^https?:$/.test(url.protocol)) return; hosts.add(url.hostname); const body = request.postDataBuffer(); const encodedInputPrefix = Buffer.from(input.toString("base64").slice(0, 80)); if (body && (body.includes(Buffer.from("%PDF-")) || body.includes(encodedInputPrefix) || body.includes(Buffer.from("STUDY-DOCUMENT-MARKER-20260929")) || body.equals(input))) { contentRequests.push({ host: url.hostname, method: request.method(), bodyBytes: body.length, pdfHeaderDetected: body.includes(Buffer.from("%PDF-")), base64InputPrefixDetected: body.includes(encodedInputPrefix), sourceMarkerDetected: body.includes(Buffer.from("STUDY-DOCUMENT-MARKER-20260929")), exactFileDetected: body.equals(input), phase }); } }); try { await page.goto(provider.url, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); phase = "select"; await upload(page, provider, resolve(directory, fixture.file)); // Settling time is not a performance measurement. await page.waitForTimeout(3000); if (scoutEdit) { phase = "place-text"; await startText(page, provider); } result.reached = await describe(page); if (!scout) { phase = "fill-export"; const download = await fillAndExport(page, provider, fixture.answer); const outputPath = resolve(evidence, `${provider.id}-${fixture.id}.pdf`); await download.saveAs(outputPath); const bytes = await readFile(outputPath); const output = await PDFDocument.load(bytes, { updateMetadata: false }); const extracted = execFileSync("pdftotext", [outputPath, "-"], { encoding: "utf8", timeout: 15000 }); execFileSync("pdfinfo", [outputPath], { timeout: 15000, stdio: "pipe" }); execFileSync("pdftoppm", ["-singlefile", "-scale-to", "1200", "-png", outputPath, resolve(evidence, `${provider.id}-${fixture.id}-output`)], { timeout: 15000, stdio: "pipe" }); result.completed = true; result.output = { file: `${provider.id}-${fixture.id}.pdf`, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, pages: output.getPageCount(), geometry: output.getPages().map((entry) => [entry.getWidth(), entry.getHeight(), entry.getRotation().angle]), answerExtractable: extracted.includes(fixture.answer), independentlyReopened: true, visualInspection: "pending" }; } } catch (error) { result.error = { phase, message: error instanceof Error ? error.message.split("\n")[0] : String(error), interpretation: "Incomplete browser test, not proof of an unsupported feature" }; result.reached = await describe(page).catch(() => null); } await page.screenshot({ path: resolve(evidence, `${provider.id}-${fixture.id}-ui.png`), fullPage: false }).catch(() => undefined); result.observedHosts = [...hosts].sort(); results.push(result); await writeFile(resolve(evidence, `${provider.id}-${fixture.id}.json`), JSON.stringify(result, null, 2) + "\n"); console.log(JSON.stringify({ provider: provider.id, fixture: fixture.id, completed: result.completed, contentRequests: contentRequests.length, reached: scout ? result.reached : undefined, error: result.error })); await context.close(); } } } finally { await browser.close(); } await writeFile(resolve(evidence, "results.json"), JSON.stringify({ generatedAt: new Date().toISOString(), protocol: "2026-09-29", publisherOwns: "nofuss", scout, results }, null, 2) + "\n");