mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-05 11:13:52 +10:00
Merge pull request #3479 from amruthpillai/codex/issue-3377-offline-font-diagnostic
test(e2e): add offline font diagnostic gates
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 extends PdfMarker = PdfMarker> = 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<TName extends string = string> = RasterInkMeasurements & {
|
||||
name: TName;
|
||||
located: boolean;
|
||||
};
|
||||
|
||||
export type RasterGlyphEvidence<TName extends string = string> = Omit<RasterInkMeasurements, "interiorInk"> & {
|
||||
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 = <TMarker extends PdfMarker>(
|
||||
textItems: PdfTextItem[],
|
||||
markers: readonly TMarker[],
|
||||
pageHeight: number,
|
||||
): PdfMarkerLocation<TMarker>[] => {
|
||||
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 = <TName extends string>(
|
||||
measurements: readonly RasterGlyphMeasurement<TName>[],
|
||||
): RasterGlyphEvidence<TName>[] =>
|
||||
measurements.map(({ name, located, interiorInk, ...measurements }) => ({
|
||||
...measurements,
|
||||
name,
|
||||
status: located ? classifyRasterInk({ ...measurements, interiorInk }) : "not-located",
|
||||
}));
|
||||
@@ -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 }) => `<p>${marker}</p>`).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<OfflineFontResumeFixture> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ColdContext> {
|
||||
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<PdfRasterEvidence> {
|
||||
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<HTMLCanvasElement>('[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<string, unknown>) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user