diff --git a/.orchestration/plan-27a-diagnostic.md b/.orchestration/plan-27a-diagnostic.md new file mode 100644 index 000000000..0a3ddccbb --- /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, captures the active PDF canvas, and measures marker-local raster crops. +3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds. +4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds. + +Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls. + +## 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 records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate. + +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.44 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`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues). + +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/.orchestration/plan-27a-remediation-round-2.md b/.orchestration/plan-27a-remediation-round-2.md new file mode 100644 index 000000000..5fa66ff0c --- /dev/null +++ b/.orchestration/plan-27a-remediation-round-2.md @@ -0,0 +1,22 @@ +# Plan 27A remediation round 2 + +Date: 2026-09-06 +Base: `ae8e2f76f` + +## Focused fixes + +- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph. +- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop. +- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass. +- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error. +- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases. +- Removed trailing spaces from `plan-27a-remediation.md`. + +## Verification + +- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed. +- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed. +- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected. +- `git diff --check` — passed after remediation-document whitespace cleanup. + +Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made. diff --git a/.orchestration/plan-27a-remediation.md b/.orchestration/plan-27a-remediation.md new file mode 100644 index 000000000..f445acf14 --- /dev/null +++ b/.orchestration/plan-27a-remediation.md @@ -0,0 +1,24 @@ +# Plan 27A remediation + +Date: 2026-09-06 +Base: `61b58ae9a` +Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only. + +## Remediated findings + +- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used. +- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines. +- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added. +- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic. +- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname. + +## Verification + +- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed. +- `git diff --check` — passed. +- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected. +- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed. +- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed. +- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues). + +Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete. diff --git a/tests/e2e/fixtures/offline-font-diagnostic.ts b/tests/e2e/fixtures/offline-font-diagnostic.ts new file mode 100644 index 000000000..04a94f711 --- /dev/null +++ b/tests/e2e/fixtures/offline-font-diagnostic.ts @@ -0,0 +1,5 @@ +export function assertPdfDownloadReceived(downloadStatus: string): asserts downloadStatus is "received" { + if (downloadStatus !== "received") { + throw new Error(`PDF download diagnostic did not receive a download: ${downloadStatus}`); + } +} diff --git a/tests/e2e/fixtures/offline-font-markers.test.ts b/tests/e2e/fixtures/offline-font-markers.test.ts new file mode 100644 index 000000000..c10611337 --- /dev/null +++ b/tests/e2e/fixtures/offline-font-markers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { assertPdfDownloadReceived } from "./offline-font-diagnostic"; +import { classifyRasterInk, classifyRasterMeasurements, locatePdfMarkerBoxes } from "./offline-font-markers"; + +const marker = { name: "cjk", marker: "简体中文" } as const; + +const item = (str: string, x = 10, y = 100, width = str.length * 5) => ({ + str, + x, + y, + width, + height: 12, +}); + +describe("locatePdfMarkerBoxes", () => { + it("rejects duplicate marker sources instead of selecting an arbitrary crop", () => { + const result = locatePdfMarkerBoxes([item(marker.marker), item(marker.marker, 10, 70)], [marker], 200); + + expect(result).toEqual([{ ...marker, box: null }]); + }); + + it("unions text items when one marker is split across PDF text items", () => { + const result = locatePdfMarkerBoxes([item("简体"), item("中文", 20)], [marker], 200); + + expect(result[0]?.box).toEqual({ x: 10, y: 88, width: 20, height: 12 }); + }); + + it("rejects text-item neighbors that could contaminate marker-local raster evidence", () => { + const result = locatePdfMarkerBoxes([item(`prefix${marker.marker}suffix`)], [marker], 200); + + expect(result).toEqual([{ ...marker, box: null }]); + }); +}); + +describe("classifyRasterInk", () => { + it("does not treat a blank crop as visible", () => { + expect(classifyRasterInk({ inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 })).toBe("blank"); + }); + + it("does not treat tofu-like outline as visible", () => { + expect(classifyRasterInk({ inkPixels: 100, interiorInk: 0, trimmedWidth: 20, trimmedHeight: 20 })).toBe( + "tofu-like", + ); + }); + + it("classifies raw browser measurements through shared raster classifier", () => { + expect( + classifyRasterMeasurements([ + { name: "blank", located: true, inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 }, + { name: "tofu", located: true, inkPixels: 100, interiorInk: 0, trimmedWidth: 20, trimmedHeight: 20 }, + { name: "visible", located: true, inkPixels: 100, interiorInk: 20, trimmedWidth: 20, trimmedHeight: 20 }, + { name: "missing", located: false, inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 }, + ]), + ).toEqual([ + { name: "blank", status: "blank", inkPixels: 0, trimmedWidth: 0, trimmedHeight: 0 }, + { name: "tofu", status: "tofu-like", inkPixels: 100, trimmedWidth: 20, trimmedHeight: 20 }, + { name: "visible", status: "visible", inkPixels: 100, trimmedWidth: 20, trimmedHeight: 20 }, + { name: "missing", status: "not-located", inkPixels: 0, trimmedWidth: 0, trimmedHeight: 0 }, + ]); + }); +}); + +describe("assertPdfDownloadReceived", () => { + it("fails diagnostic when browser PDF download errors", () => { + expect(() => assertPdfDownloadReceived("download-error")).toThrow( + "PDF download diagnostic did not receive a download", + ); + }); + + it("accepts received browser PDF download", () => { + expect(() => assertPdfDownloadReceived("received")).not.toThrow(); + }); +}); diff --git a/tests/e2e/fixtures/offline-font-markers.ts b/tests/e2e/fixtures/offline-font-markers.ts new file mode 100644 index 000000000..0a956e89f --- /dev/null +++ b/tests/e2e/fixtures/offline-font-markers.ts @@ -0,0 +1,133 @@ +export type PdfTextItem = { + str: string; + x: number; + y: number; + width: number; + height: number; +}; + +export type PdfMarker = { + name: string; + marker: string; +}; + +export type PdfMarkerBox = { + x: number; + y: number; + width: number; + height: number; +}; + +export type PdfMarkerLocation = TMarker & { + box: PdfMarkerBox | null; +}; + +export type RasterInkMeasurements = { + inkPixels: number; + interiorInk: number; + trimmedWidth: number; + trimmedHeight: number; +}; + +export type RasterGlyphStatus = "visible" | "blank" | "tofu-like"; + +export type RasterGlyphMeasurement = RasterInkMeasurements & { + name: TName; + located: boolean; +}; + +export type RasterGlyphEvidence = Omit & { + name: TName; + status: RasterGlyphStatus | "not-located"; +}; + +type TextItemRange = PdfTextItem & { + start: number; + end: number; +}; + +const textItemRanges = (textItems: PdfTextItem[]): TextItemRange[] => { + let offset = 0; + return textItems.map((item) => { + const start = offset; + offset += item.str.length; + return { ...item, start, end: offset }; + }); +}; + +const findMarkerOccurrences = (text: string, marker: string): number[] => { + if (marker.length === 0) return []; + + const occurrences: number[] = []; + let start = 0; + while (start <= text.length - marker.length) { + const match = text.indexOf(marker, start); + if (match === -1) break; + occurrences.push(match); + start = match + marker.length; + } + return occurrences; +}; + +export const locatePdfMarkerBoxes = ( + textItems: PdfTextItem[], + markers: readonly TMarker[], + pageHeight: number, +): PdfMarkerLocation[] => { + const ranges = textItemRanges(textItems); + const text = textItems.map((item) => item.str).join(""); + + return markers.map((marker) => { + const [start] = findMarkerOccurrences(text, marker.marker); + const end = start === undefined ? undefined : start + marker.marker.length; + if (start === undefined || end === undefined || findMarkerOccurrences(text, marker.marker).length !== 1) { + return { ...marker, box: null }; + } + + const coveredRanges = ranges.filter((range) => range.start < end && range.end > start); + const firstRange = coveredRanges[0]; + const lastRange = coveredRanges.at(-1); + if (!firstRange || !lastRange) return { ...marker, box: null }; + + const firstLocalStart = start - firstRange.start; + const lastLocalEnd = end - lastRange.start; + const leadingText = firstRange.str.slice(0, firstLocalStart); + const trailingText = lastRange.str.slice(lastLocalEnd); + if (leadingText.trim() || trailingText.trim()) return { ...marker, box: null }; + + const left = Math.min(...coveredRanges.map((range) => range.x)); + const top = Math.min(...coveredRanges.map((range) => pageHeight - range.y - Math.max(range.height, 1))); + const right = Math.max(...coveredRanges.map((range) => range.x + Math.max(range.width, 1))); + const bottom = Math.max(...coveredRanges.map((range) => pageHeight - range.y)); + + return { + ...marker, + box: { + x: left, + y: top, + width: Math.max(right - left, 1), + height: Math.max(bottom - top, 1), + }, + }; + }); +}; + +export const classifyRasterInk = ({ + inkPixels, + interiorInk, + trimmedWidth, + trimmedHeight, +}: RasterInkMeasurements): RasterGlyphStatus => { + if (inkPixels === 0) return "blank"; + const interiorRatio = interiorInk / Math.max(inkPixels, 1); + return interiorRatio < 0.08 && trimmedWidth >= 8 && trimmedHeight >= 8 ? "tofu-like" : "visible"; +}; + +export const classifyRasterMeasurements = ( + measurements: readonly RasterGlyphMeasurement[], +): RasterGlyphEvidence[] => + measurements.map(({ name, located, interiorInk, ...measurements }) => ({ + ...measurements, + name, + status: located ? classifyRasterInk({ ...measurements, interiorInk }) : "not-located", + })); diff --git a/tests/e2e/fixtures/offline-fonts.ts b/tests/e2e/fixtures/offline-fonts.ts new file mode 100644 index 000000000..912a3ea94 --- /dev/null +++ b/tests/e2e/fixtures/offline-fonts.ts @@ -0,0 +1,68 @@ +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 offlineFontFixtureSummary = 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: "Offline font diagnostic" }, + // Keep each marker in one dedicated block so raster evidence can map each script to a local PDF crop. + { path: ["summary", "content"], value: offlineFontFixtureSummary }, + { 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..b92661207 --- /dev/null +++ b/tests/e2e/specs/offline-fonts.spec.ts @@ -0,0 +1,446 @@ +import type { Browser, BrowserContext, Page, TestInfo } from "@playwright/test"; +import type { + RasterGlyphEvidence as MarkerRasterGlyphEvidence, + RasterGlyphMeasurement as MarkerRasterGlyphMeasurement, +} from "../fixtures/offline-font-markers"; +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { assertPdfDownloadReceived } from "../fixtures/offline-font-diagnostic"; +import { classifyRasterMeasurements, locatePdfMarkerBoxes } from "../fixtures/offline-font-markers"; +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 serverRestartFlag = 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>; + +type RasterGlyphEvidence = MarkerRasterGlyphEvidence<(typeof offlineFontScriptSamples)[number]["name"]>; +type RasterGlyphMeasurement = MarkerRasterGlyphMeasurement<(typeof offlineFontScriptSamples)[number]["name"]>; + +type PdfRasterEvidence = { + rasterDataUrl: string; + textLayerMarkers: PdfMarkers; + referenceGlyphs: RasterGlyphEvidence[]; + previewGlyphs: RasterGlyphEvidence[]; +}; + +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 extractedMarkerResult(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] : [])).join(""), + ); + } + return pages.join("\n"); + } finally { + await loadingTask.destroy(); + } + } + + async function capturePdfBytes(page: Page) { + await page.addInitScript(() => { + const read = Blob.prototype.arrayBuffer; + Blob.prototype.arrayBuffer = async function () { + const buffer = await read.call(this); + const bytes = new Uint8Array(buffer); + if (String.fromCharCode(...bytes.subarray(0, 5)) === "%PDF-") { + (window as Window & { offlineFontPdfBytes?: number[] }).offlineFontPdfBytes = Array.from(bytes); + } + return buffer; + }; + }); + } + + async function renderPdfRasterEvidence(page: Page, bytes: Uint8Array): Promise { + const require = createRequire(`${process.cwd()}/package.json`); + await page.route("**/__offline_font_pdfjs/*", async (route) => { + const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs"); + await route.fulfill({ + contentType: "text/javascript", + path: require.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`), + }); + }); + + const textEvidence = await page.evaluate( + async ({ bytes }) => { + const moduleUrl = `${location.origin}/__offline_font_pdfjs/pdf.mjs`; + const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl); + pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__offline_font_pdfjs/worker.mjs`; + const loadingTask = pdfjs.getDocument({ data: Uint8Array.from(bytes), useSystemFonts: false }); + try { + const pdfDocument = await loadingTask.promise; + const pdfPage = await pdfDocument.getPage(1); + const textContent = await pdfPage.getTextContent(); + return { + pageHeight: pdfPage.getViewport({ scale: 1 }).height, + textItems: textContent.items.flatMap((item) => + "str" in item + ? [ + { + str: item.str, + x: item.transform[4] ?? 0, + y: item.transform[5] ?? 0, + width: item.width, + height: Math.max(item.height, Math.abs(item.transform[3] ?? 0), 1), + }, + ] + : [], + ), + }; + } finally { + await loadingTask.destroy(); + } + }, + { bytes: Array.from(bytes) }, + ); + const boxes = locatePdfMarkerBoxes(textEvidence.textItems, offlineFontScriptSamples, textEvidence.pageHeight); + const textLayerMarkers = extractedMarkerResult(textEvidence.textItems.map(({ str }) => str).join("")); + + const rasterEvidence = await page.evaluate( + async ({ bytes, boxes }) => { + const moduleUrl = `${location.origin}/__offline_font_pdfjs/pdf.mjs`; + const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl); + pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__offline_font_pdfjs/worker.mjs`; + const loadingTask = pdfjs.getDocument({ data: Uint8Array.from(bytes), useSystemFonts: false }); + try { + const pdfDocument = await loadingTask.promise; + const pdfPage = await pdfDocument.getPage(1); + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const rasterScale = 4; + const viewport = pdfPage.getViewport({ scale: rasterScale }); + const raster = globalThis.document.createElement("canvas"); + raster.width = Math.ceil(viewport.width); + raster.height = Math.ceil(viewport.height); + const rasterContext = raster.getContext("2d"); + if (!rasterContext) throw new Error("Missing PDF raster context."); + await pdfPage.render({ + canvas: raster, + canvasContext: rasterContext, + viewport, + annotationMode: pdfjs.AnnotationMode.DISABLE, + background: "white", + }).promise; + + function measure(canvas: HTMLCanvasElement, scale: number): RasterGlyphMeasurement[] { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Missing PDF preview raster context."); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + return boxes.map(({ name, box }) => { + if (!box) + return { + name, + located: false, + inkPixels: 0, + interiorInk: 0, + trimmedWidth: 0, + trimmedHeight: 0, + }; + const markerLeft = box.x * scale; + const markerTop = box.y * scale; + const markerRight = (box.x + box.width) * scale; + const markerBottom = (box.y + box.height) * scale; + const padding = 2 * scale; + const left = Math.max(0, Math.floor(box.x * scale - padding)); + const top = Math.max(0, Math.floor(box.y * scale - padding)); + const right = Math.min(canvas.width, Math.ceil((box.x + box.width) * scale + padding)); + const bottom = Math.min(canvas.height, Math.ceil((box.y + box.height) * scale + padding)); + let inkPixels = 0; + let minX = right; + let minY = bottom; + let maxX = left; + let maxY = top; + for (let y = top; y < bottom; y += 1) { + for (let x = left; x < right; x += 1) { + // Scan with padding for antialiasing, but count ink only inside marker box. + // Neighboring glyphs must never make blank/tofu marker evidence pass. + if (x < markerLeft || x >= markerRight || y < markerTop || y >= markerBottom) continue; + const index = (y * pixels.width + x) * 4; + const red = pixels.data[index] ?? 255; + const green = pixels.data[index + 1] ?? 255; + const blue = pixels.data[index + 2] ?? 255; + if (red >= 245 && green >= 245 && blue >= 245) continue; + inkPixels += 1; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + if (inkPixels === 0) { + return { name, located: true, inkPixels, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 }; + } + let interiorInk = 0; + for ( + let y = minY + Math.floor((maxY - minY + 1) * 0.2); + y < maxY - Math.floor((maxY - minY + 1) * 0.2); + y += 1 + ) { + for ( + let x = minX + Math.floor((maxX - minX + 1) * 0.2); + x < maxX - Math.floor((maxX - minX + 1) * 0.2); + x += 1 + ) { + const index = (y * pixels.width + x) * 4; + if ( + (pixels.data[index] ?? 255) < 245 || + (pixels.data[index + 1] ?? 255) < 245 || + (pixels.data[index + 2] ?? 255) < 245 + ) + interiorInk += 1; + } + } + const trimmedWidth = maxX - minX + 1; + const trimmedHeight = maxY - minY + 1; + return { name, located: true, inkPixels, interiorInk, trimmedWidth, trimmedHeight }; + }); + } + + const preview = document.querySelector('[aria-hidden="false"] canvas'); + return { + rasterDataUrl: raster.toDataURL(), + referenceGlyphs: measure(raster, rasterScale), + previewGlyphs: preview ? measure(preview, preview.width / baseViewport.width) : [], + }; + } finally { + await loadingTask.destroy(); + } + }, + { bytes: Array.from(bytes), boxes }, + ); + return { + ...rasterEvidence, + referenceGlyphs: classifyRasterMeasurements(rasterEvidence.referenceGlyphs), + previewGlyphs: classifyRasterMeasurements(rasterEvidence.previewGlyphs), + textLayerMarkers, + }; + } + + 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(); + await capturePdfBytes(page); + try { + await page.goto(fixture.builderURL); + await page.waitForTimeout(5_000); + const previewCanvas = page.locator('[aria-hidden="false"] canvas').first(); + const canvasVisible = await previewCanvas.isVisible().catch(() => false); + const pdfBytes = await page.evaluate( + () => (window as Window & { offlineFontPdfBytes?: number[] }).offlineFontPdfBytes, + ); + expect(pdfBytes).toBeDefined(); + const rasterEvidence = pdfBytes ? await renderPdfRasterEvidence(page, Uint8Array.from(pdfBytes)) : null; + if (rasterEvidence) { + await testInfo.attach("builder-preview-raster.png", { + body: Buffer.from(rasterEvidence.rasterDataUrl.split(",")[1] ?? "", "base64"), + contentType: "image/png", + }); + expect(rasterEvidence.previewGlyphs).toHaveLength(offlineFontScriptSamples.length); + expect(rasterEvidence.previewGlyphs.every(({ status }) => status === "visible")).toBe(true); + } + expect(canvasVisible).toBe(true); + await report(testInfo, "builder-preview", { + cache: "new-browser-context", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: networkStatus(cold.blockedRequests), + canvasVisible, + textLayerMarkers: rasterEvidence?.textLayerMarkers ?? "not-extracted", + glyphStatus: rasterEvidence?.previewGlyphs ?? "not-rasterized", + }); + } 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 rasterEvidence: PdfRasterEvidence | null = null; + let downloadStatus = "not-started"; + let rasterEvidenceStatus = "not-attempted"; + 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); + const bytes = new Uint8Array(await readFile(path)); + try { + markerResultValue = extractedMarkerResult(await readPdfText(bytes)); + rasterEvidence = await renderPdfRasterEvidence(page, bytes); + await testInfo.attach("browser-download-raster.png", { + body: Buffer.from(rasterEvidence.rasterDataUrl.split(",")[1] ?? "", "base64"), + contentType: "image/png", + }); + rasterEvidenceStatus = "received"; + } catch { + rasterEvidenceStatus = "unresolved-raster-evidence-error"; + } + } catch { + downloadStatus = "download-error"; + } finally { + await report(testInfo, "browser-download", { + cache: "new-browser-context", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: networkStatus(cold.blockedRequests), + downloadStatus, + rasterEvidenceStatus, + textLayerMarkers: markerResultValue ?? "not-extracted", + glyphStatus: rasterEvidence?.referenceGlyphs ?? "not-rasterized", + }); + await cold.context.close(); + } + assertPdfDownloadReceived(downloadStatus); + expect(rasterEvidenceStatus).toBe("received"); + expect(rasterEvidence).not.toBeNull(); + if (!rasterEvidence) return; + expect(rasterEvidence.referenceGlyphs).toHaveLength(offlineFontScriptSamples.length); + expect(rasterEvidence.referenceGlyphs.every(({ status }) => status === "visible")).toBe(true); + }); + + 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 (!serverRestartFlag) { + 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 = extractedMarkerResult(await readPdfText(new Uint8Array(bytes))); + } + } + } finally { + await report(testInfo, "server-pdf", { + cache: "new-browser-context; server-process-state-is-external", + serverRestartFlag, + serverGateStatus: "unresolved-external-host-level-blocker", + blockedExternalFontRequests: cold.blockedRequests, + networkStatus: "server-outbound-requests-unobservable-from-playwright", + responseStatus, + textLayerMarkers: markerResultValue ?? "not-extracted", + glyphStatus: "not-rasterized-server-surface", + limitation: + "Server outbound request capture and verifiable restart identity remain unresolved external host-level blockers; the caller flag is not restart proof. Playwright route interception sees browser requests only; do not treat this run as a cold-network gate.", + }); + 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": {