diff --git a/.orchestration/plan-27a-diagnostic.md b/.orchestration/plan-27a-diagnostic.md new file mode 100644 index 000000000..b3b9e869c --- /dev/null +++ b/.orchestration/plan-27a-diagnostic.md @@ -0,0 +1,157 @@ +# Plan 27 Phase A diagnostic evidence + +Date: 2026-09-06 +Issue: [#3377](https://github.com/amruthpillai/reactive-resume/issues/3377) +Revision: `2a4a1583b` (`origin/main` at run start) +Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed. + +## Drift and authority + +- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty. +- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof. +- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL. +- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`. +- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches. +- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence. + +## Deterministic fixture + +`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface. + +The exact markers are versioned as `offline-font-scripts-v1`: + +| Marker | Script or coverage | +| --- | --- | +| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency | +| `简体中文` | Han / Simplified Chinese | +| `العربية` | Arabic | +| `עברית` | Hebrew | +| `ไทย` | Thai | +| `Emoji 🚀` | Emoji | + +`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape. + +The four surfaces are separate tests: + +1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads. +2. Builder PDF preview navigates to the builder and records whether a PDF canvas appears. +3. Browser PDF download uses the Export dialog and extracts marker presence from the downloaded PDF when generation succeeds. +4. Server PDF calls the public PDF endpoint and extracts marker presence from its response when generation succeeds. + +PDF marker results classify missing text as a glyph result (`true`/`false` per marker), while blocked browser font requests classify the browser surface as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; it does not claim zero server requests. + +## Run protocol and cold-cache boundary + +Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls: + +```text +OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \ + pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview" +``` + +Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test still preserves the restart confirmation in its report (`serverRestartConfirmed`) for a later run. + +The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list. + +## Administrator-hosted manifest proposal + +This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog. + +```json +{ + "schemaVersion": "offline-fonts-v1", + "mode": "local-only", + "assetRoot": "/fonts/offline/v1", + "families": { + "IBM Plex Serif": { + "normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" }, + "italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" }, + "preview": "ibm-plex-serif/preview.ttf" + }, + "IBM Plex Sans": { + "normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" }, + "italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" }, + "preview": "ibm-plex-sans/preview.ttf" + }, + "Noto Serif": { + "normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" }, + "italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" }, + "preview": "noto-serif/preview.ttf" + }, + "Noto Sans": { + "normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" }, + "italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" }, + "preview": "noto-sans/preview.ttf" + }, + "Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" }, + "Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" }, + "Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" }, + "Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" }, + "Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" }, + "Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" }, + "Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" }, + "Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" }, + "Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" }, + "Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" }, + "Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" }, + "Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" }, + "Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" } + } +} +``` + +### Candidate source, license, script, and size evidence + +Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE). + +| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners | +| --- | --- | --- | --- | --- | ---: | --- | +| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration | +| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration | +| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration | +| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration | +| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback | +| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback | +| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback | +| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback | +| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback | +| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback | + +Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.45 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN. + +### Source and license obligations + +- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources. +- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements. +- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed. +- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest. + +### Missing-family and missing-asset behavior + +Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode. + +Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required. + +## Verification record + +Completed read-only checks before handoff: + +- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests. +- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work. +- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed. +- `git diff --check`: passed. +- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests). +- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests). +- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed. +- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected). +- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made. +- `pnpm exec turbo boundaries`: unresolved repository checker failure (418 parser-panicked `.js` files, including existing PDF/web source mirrors); no boundary violation was reported for the new TypeScript imports. + +The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work. diff --git a/tests/e2e/fixtures/offline-fonts.ts b/tests/e2e/fixtures/offline-fonts.ts new file mode 100644 index 000000000..6ecc6978b --- /dev/null +++ b/tests/e2e/fixtures/offline-fonts.ts @@ -0,0 +1,67 @@ +import type { Page } from "@playwright/test"; +import { Pool } from "pg"; + +export const offlineFontScriptSamples = [ + { name: "latin-punctuation", marker: "Latin punctuation • — “quotes” €" }, + { name: "cjk", marker: "简体中文" }, + { name: "arabic", marker: "العربية" }, + { name: "hebrew", marker: "עברית" }, + { name: "thai", marker: "ไทย" }, + { name: "emoji", marker: "Emoji 🚀" }, +] as const; + +const offlineFontFixtureText = offlineFontScriptSamples.map(({ marker }) => marker).join(" | "); + +export type OfflineFontResumeFixture = { + builderURL: string; + slug: string; + username: string; +}; + +/** Seeds one stable multilingual document so each diagnostic surface uses identical glyph input. */ +export async function seedOfflineFontResume(page: Page): Promise { + const builderURL = page.url(); + const resumeId = new URL(builderURL).pathname.split("/").at(-1); + const databaseURL = process.env.DATABASE_URL; + if (!resumeId || !databaseURL) throw new Error("Offline font fixture requires builder URL and DATABASE_URL."); + + const pool = new Pool({ connectionString: databaseURL }); + try { + const patches: Array<{ path: string[]; value: unknown }> = [ + { path: ["picture", "hidden"], value: true }, + { path: ["basics", "name"], value: "Offline Font Diagnostic Fixture" }, + { path: ["basics", "headline"], value: offlineFontFixtureText }, + { path: ["summary", "content"], value: `

${offlineFontFixtureText}

` }, + { path: ["metadata", "typography", "body", "fontFamily"], value: "IBM Plex Serif" }, + { path: ["metadata", "typography", "body", "fontWeights"], value: ["400", "700"] }, + { path: ["metadata", "typography", "heading", "fontFamily"], value: "IBM Plex Serif" }, + { path: ["metadata", "typography", "heading", "fontWeights"], value: ["400", "700"] }, + ]; + + for (const patch of patches) { + await pool.query( + `update "resume" + set data = jsonb_set(data, $2::text[], $3::jsonb, true), + updated_at = now() + where id = $1`, + [resumeId, patch.path, JSON.stringify(patch.value)], + ); + } + + await pool.query('update "resume" set is_public = true, updated_at = now() where id = $1', [resumeId]); + const result = await pool.query<{ slug: string; user_id: string }>( + 'select slug, user_id from "resume" where id = $1', + [resumeId], + ); + const row = result.rows[0]; + if (!row) throw new Error("Offline font fixture resume was not found after seeding."); + + const owner = await pool.query<{ username: string }>('select username from "user" where id = $1', [row.user_id]); + const ownerUsername = owner.rows[0]?.username; + if (!ownerUsername) throw new Error("Offline font fixture owner was not found after seeding."); + + return { builderURL, slug: row.slug, username: ownerUsername }; + } finally { + await pool.end(); + } +} diff --git a/tests/e2e/specs/offline-fonts.spec.ts b/tests/e2e/specs/offline-fonts.spec.ts new file mode 100644 index 000000000..eb35cbbd0 --- /dev/null +++ b/tests/e2e/specs/offline-fonts.spec.ts @@ -0,0 +1,213 @@ +import type { Browser, BrowserContext, Page, TestInfo } from "@playwright/test"; +import { readFile, writeFile } from "node:fs/promises"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { offlineFontScriptSamples, seedOfflineFontResume } from "../fixtures/offline-fonts"; +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +const diagnosticEnabled = process.env.OFFLINE_FONT_DIAGNOSTIC === "1"; +const serverRestartConfirmed = process.env.OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED === "1"; + +type BlockedRequest = { + hostname: string; + path: string; +}; + +type ColdContext = { + context: BrowserContext; + blockedRequests: BlockedRequest[]; +}; + +type PdfMarkers = Record<(typeof offlineFontScriptSamples)[number]["name"], boolean>; + +test.describe("offline font diagnostic", () => { + test.describe.configure({ mode: "serial" }); + test.skip(!diagnosticEnabled, "Set OFFLINE_FONT_DIAGNOSTIC=1 to run network diagnostics."); + test.setTimeout(120_000); + + async function createColdContext(browser: Browser, page: Page, testInfo: TestInfo): Promise { + const baseURL = String(testInfo.project.use.baseURL ?? "http://localhost:3000"); + const allowedOrigin = new URL(baseURL).origin; + const context = await browser.newContext({ + baseURL, + serviceWorkers: "block", + storageState: await page.context().storageState(), + }); + const blockedRequests: BlockedRequest[] = []; + + await context.route("**/*", async (route) => { + const requestURL = new URL(route.request().url()); + if (requestURL.origin === allowedOrigin || requestURL.protocol === "data:" || requestURL.protocol === "blob:") { + await route.continue(); + return; + } + + blockedRequests.push({ hostname: requestURL.hostname, path: requestURL.pathname }); + await route.abort("blockedbyclient"); + }); + + return { context, blockedRequests }; + } + + function markerResult(text: string): PdfMarkers { + return Object.fromEntries( + offlineFontScriptSamples.map((sample) => [sample.name, text.includes(sample.marker)]), + ) as PdfMarkers; + } + + function networkStatus(blockedRequests: BlockedRequest[]) { + return blockedRequests.length > 0 ? "network-error" : "no-browser-network-error"; + } + + async function readPdfText(bytes: Uint8Array) { + const loadingTask = getDocument({ data: bytes, useSystemFonts: false }); + try { + const document = await loadingTask.promise; + const pages: string[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const pdfPage = await document.getPage(pageNumber); + pages.push(...(await pdfPage.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : []))); + } + return pages.join(" "); + } finally { + await loadingTask.destroy(); + } + } + + async function report(testInfo: TestInfo, name: string, reportData: Record) { + const body = JSON.stringify({ + version: 1, + fixture: "offline-font-scripts-v1", + surface: name, + ...reportData, + }); + expect(body).not.toMatch(/https?:\/\/|[?&](token|secret|password|auth)=/i); + await testInfo.attach(`${name}.json`, { body, contentType: "application/json" }); + console.log(`[offline-fonts] ${body}`); + } + + test("captures cold font picker preview requests", async ({ browser, authPage: seedPage }, testInfo) => { + await createSampleResumeFromDashboard(seedPage, testInfo); + const fixture = await seedOfflineFontResume(seedPage); + const cold = await createColdContext(browser, seedPage, testInfo); + const page = await cold.context.newPage(); + try { + await page.goto(fixture.builderURL); + await openSidebarSection(page, "Typography"); + await page.getByRole("combobox").first().click(); + await expect(page.getByRole("option").first()).toBeVisible(); + await page.waitForTimeout(1_000); + await report(testInfo, "picker-preview", { + cache: "new-browser-context", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: networkStatus(cold.blockedRequests), + glyphStatus: "not-applicable-picker-only", + }); + } finally { + await cold.context.close(); + } + }); + + test("captures cold builder PDF preview and classifies glyph/network results", async ({ + browser, + authPage: seedPage, + }, testInfo) => { + await createSampleResumeFromDashboard(seedPage, testInfo); + const fixture = await seedOfflineFontResume(seedPage); + const cold = await createColdContext(browser, seedPage, testInfo); + const page = await cold.context.newPage(); + try { + await page.goto(fixture.builderURL); + await page.waitForTimeout(5_000); + const canvasVisible = await page + .locator('[aria-hidden="false"] canvas') + .first() + .isVisible() + .catch(() => false); + await report(testInfo, "builder-preview", { + cache: "new-browser-context", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: networkStatus(cold.blockedRequests), + canvasVisible, + glyphStatus: "not-measured-preview-canvas", + }); + } finally { + await cold.context.close(); + } + }); + + test("captures cold browser PDF download and classifies extracted glyphs", async ({ + browser, + authPage: seedPage, + }, testInfo) => { + await createSampleResumeFromDashboard(seedPage, testInfo); + const fixture = await seedOfflineFontResume(seedPage); + const cold = await createColdContext(browser, seedPage, testInfo); + const page = await cold.context.newPage(); + let markerResultValue: PdfMarkers | null = null; + let downloadStatus = "not-started"; + try { + await page.goto(fixture.builderURL); + await openSidebarSection(page, "Export"); + await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); + const downloadPromise = page.waitForEvent("download", { timeout: 20_000 }); + await page.getByRole("button", { name: "Download PDF", exact: true }).click(); + const download = await downloadPromise; + downloadStatus = "received"; + const path = testInfo.outputPath("offline-font-browser-download.pdf"); + await download.saveAs(path); + markerResultValue = markerResult(await readPdfText(new Uint8Array(await readFile(path)))); + } catch { + downloadStatus = "renderer-or-network-error"; + } finally { + await report(testInfo, "browser-download", { + cache: "new-browser-context", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: networkStatus(cold.blockedRequests), + downloadStatus, + glyphStatus: markerResultValue ?? "not-extracted", + }); + await cold.context.close(); + } + }); + + test("exercises restarted-server PDF and records server observability boundary", async ({ + browser, + authPage: seedPage, + }, testInfo) => { + await createSampleResumeFromDashboard(seedPage, testInfo); + const fixture = await seedOfflineFontResume(seedPage); + const cold = await createColdContext(browser, seedPage, testInfo); + const page = await cold.context.newPage(); + let responseStatus = "not-requested"; + let markerResultValue: PdfMarkers | null = null; + try { + await page.goto(fixture.builderURL); + if (!serverRestartConfirmed) { + responseStatus = "blocked-before-request"; + } else { + const response = await page.request.get( + `/api/resumes/${encodeURIComponent(fixture.username)}/${encodeURIComponent(fixture.slug)}/pdf`, + ); + responseStatus = String(response.status()); + if (response.ok()) { + const bytes = await response.body(); + await writeFile(testInfo.outputPath("offline-font-server.pdf"), bytes); + markerResultValue = markerResult(await readPdfText(new Uint8Array(bytes))); + } + } + } finally { + await report(testInfo, "server-pdf", { + cache: "new-browser-context; server-process-state-is-external", + serverRestartConfirmed, + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: "server-outbound-requests-unobservable-from-playwright", + responseStatus, + glyphStatus: markerResultValue ?? "not-extracted", + limitation: + "Playwright route interception sees browser requests only; server fetches need a restarted process plus host-level egress capture.", + }); + await cold.context.close(); + } + }); +}); diff --git a/turbo.json b/turbo.json index ee5384e87..d95fde1ec 100644 --- a/turbo.json +++ b/turbo.json @@ -89,7 +89,9 @@ "FLAG_SHOW_SPONSORS", "FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI", "FLAG_ALLOW_UNSAFE_AI_BASE_URL", - "AI_TEST_TIMEOUT_MS" + "AI_TEST_TIMEOUT_MS", + "OFFLINE_FONT_DIAGNOSTIC", + "OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED" ], "tasks": { "transit": {