feat(ui): migrate public CV entry and recovery

This commit is contained in:
cesnimda
2026-10-13 00:24:40 +02:00
parent fcacae7384
commit bfa0ab62f8
15 changed files with 10472 additions and 57 deletions
+5 -1
View File
@@ -37,4 +37,8 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
Exact direct receipt is retained at `.artifacts/phase7/p7-03/import-history/final/push-receipt.json`; the portable results include its raw hash and observation. No runtime bytes were replaced by this metadata successor.
| P7-03 observed push receipt / P7-04 entry | `5680c0031f377b0caaae79c0cec900f58e409886` | Records the actual preceding 5d6e3235 clean normal push, closes implemented P7-03 conditionally and establishes exact library/public entry baselines. Entry static PASS (four commands0,11docs/400 local links0errors/57groups/32routes); normal push directly verified2026-10-12 17:35:00.882169UTC: localHEAD=origin=live5680c00, clean worktree, local/origin/live main27657f3 unchanged. No product/runtime/count change |
| P7-04 CV library | Pending normal commit/push after final staged gate | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Public entry remains pending; no main/renderer/backend/AI activation |
| P7-04 CV library | `fcacae7384b4a38c2675e6527a33cc1d09f4b505` | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Final staged hygiene PASS18:03:12UTC (four commands0,11docs406links0errors/57+32); normal push directly verified18:03:41.481787UTC localHEAD=origin=livefcacae7, clean worktree/main27657f3 unchanged. Actual receipt recorded by public successor. Public entry remains pending; no main/renderer/backend/AI activation |
| P7-04 public CV entry | Actual hash follows observed normal push | [Public results](p7-04/evidence/public-results.json): fresh38/3/native8/types/lint/build/fidelity16/36+8/24 PASS;18 actual image reviews;18 all-enabled scans0/12 sandbox-frame incompletes and12 separate settings scans0/223 retained. Current7 source snapshots/74-file export verified. +13/0 gives673/93 and native+8 gives249 derived only. Final staged hygiene/normal push pending; no main/schema/renderer/model activation |
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
+4
View File
@@ -130,3 +130,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
### P7-04 library finite disposition
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
### P7-04 public evidence
Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurrences: generated document context is not fully scanned or human accepted. Keep scripts withheld; M09/M11 remain. Final settings12 WCAG-tagged scans0/223 retain24 disabled Undo/Redo span labels,12 short-text badges,161 overlap and26 partially obscured contexts. Explicit reload observes owner revocation; no live cross-client revocation claim. Actual18 public images and four final PDF comparisons reviewed, not all60 pages. Fresh fidelity16/36+8/24 preserves exact tolerances, no production font/photo/print or provider clearance. RD21/old orphan/concurrency/partial-write/production obligations unchanged; library server-template metadata localisation remains P7-11 residual.
+4
View File
@@ -148,3 +148,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
### P7-04 library finite disposition
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
### P7-04 public finite disposition
[Public results](p7-04/evidence/public-results.json) establish C05/C12/E09 anonymous sharing/download/revocation/rotated-slug/private-boundary and unchanged source/settings/version/sibling safeguards. Fresh38/3/native8/fidelity16/36+8/24/types/lint/build pass;18 PNGs actually inspected. Sandbox without scripts retains12 frame-tested incompletes; final settings12 WCAG-tagged scans0/223 remain qualified. All57 whole statuses stay56 PARTIAL/C11 DEFERRED,32 routes retained. Derived673/93 and249 are not fresh integrated totals. M09/M11/human/device/output/provider/release obligations remain; production NO-GO/P5-13 withheld/Phase8 separate. Public final hygiene and normal push pending; library fcacae7 verified.
+1 -1
View File
@@ -12,7 +12,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
| [P7-01](p7-01/README.md) | Authentication and account-security presentation | Public sub-slice CONDITIONAL GO, pushed8ad720d with direct receipt; account implementation/runtime/actual8-image review CONDITIONAL GO (573/89 +6/22/10); pushedad7b419 with final static PASS/direct receipt19:16:15UTC; main unchanged |
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normally pushed8b4d5c2/direct localHEAD=origin=live receipt05:51:04UTC, clean worktree/main unchanged |
| [P7-03](p7-03/README.md) | Career structured editing and import review | Source-ID and structured-editor commits pushed; final import/history runtime/design CONDITIONAL GO: affected76/6, native35, backend98/seeder24, types/lint/build PASS;33/54 final images inspected. Import17 scans0/302, editor13 scans0/377, Career22 scans0/99 retained. Final staged hygiene PASS; normal push5d6e3235 directly verified17:28:54UTC with clean worktree/main unchanged. RD21/human/provider/output gates remain;57/32 unchanged |
| [P7-04](p7-04/README.md) | CV library and public entry | P7-03 implemented CONDITIONAL GO/push verified; approved library then public-entry boundaries; entry recorded |
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene/normal push pending |
| P7-05 | Discovery, Companies, Offers and Reminders | Existing domain owners and Jobs return context; planned |
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
+23 -1
View File
@@ -1,6 +1,6 @@
# P7-04 — CV library and public entry
**Entry CONDITIONAL GO; implementation and acceptance PENDING.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
**Implemented scope CONDITIONAL GO; library pushed, public final hygiene PASS and normal push pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
[Entry evidence](evidence/entry.json) records the actual clean P7-03 normal push5d6e3235, direct development equality/main unchanged and eight exact committed Git-blob baselines. P7-03 affected76/6/native35/backend98/seeder24 and static checks pass with finite visual/axe/manual qualifications. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited;648/93 frontend,233 ordinary browser and1,034 backend are derived expectations. P7-12 must run fresh integrated regression.
@@ -24,3 +24,25 @@ All **19 final PNGs were displayed and reviewed** across a finite EN/Bokmål/lig
New library **19 all-enabled scans0 violations/64 incomplete occurrences** retain19 invisible-badge short-text,17 modal/menu hidden-focus and28 background overlap. Existing editor **12 retained-scope scans0/122** retain24 disabled Undo/Redo wrapper aria labels,12 invisible-badge short-text,78 overlap and8 obscured iframe-heading occurrences. Full vectors remain; neither scope establishes human screen-reader/modal/contrast acceptance. M09/M11 and all M01–M12 device/zoom/output/provider gates remain. RD21 canonical concurrency/partial-write risk and historical frozen/orphan/output debt are unchanged.
Twelve new cases in the existing suite give **660/93 frontend derived only**; eight new ordinary browser cases give **241 derived only**. Latest whole frontend573/89, backend1,033 and sidecar49 are inherited; backend1,034 is derived. P7-12 must run fresh integrated regression. All57 statuses remain56 PARTIAL/C11 DEFERRED,32 routes retained; P5-13 stays withheld. No backend/schema/renderer/PDF geometry or Phase8 work. Production NO-GO/main unchanged.
### Observed library push / public-entry start
Library implemented scope **CONDITIONAL GO** is normally pushed as `fcacae7384b4a38c2675e6527a33cc1d09f4b505`. Direct verification2026-10-12 18:03:41.481787UTC proves localHEAD=origin=live development, clean worktree and unchanged local/origin/live main27657f3. Final staged hygiene passes (four commands0,11docs406links0errors/57groups32routes); affected38/4/native8/editor4/backend72/types/lint/build and actual19-image review pass with manual/axe/localisation/production qualifications retained. [Public entry](evidence/public-entry.json) records exact preceding Git-blob baselines and this observed receipt. Public migration/final acceptance is pending; completing the library does not qualify Phase7 or production.
### Public CV implemented result
**P7-04 implemented scope CONDITIONAL GO; public final hygiene/normal push pending.** [Public results](evidence/public-results.json) retain final source, raw commands/reports, failed attempts and exact **74-file** `CzJrqceuLUrp0d6604-dJ` export. Fresh affected **38/3**, eight new ordinary anonymous browser cases, app/browser types, lint and production build PASS. Existing public suite1→14 (+13/0), browser+8 gives **673/93 frontend and249 browser derived only**; whole573/89/backend1,033/sidecar49 remain inherited until P7-12. The bundler emits53 JS files versus library54; other file-type counts match. Routes remain32, separately checked; output count is not a fixed capability budget.
PageHeader and shared feedback provide localized EN/Bokmål loading, missing/revoked-link and transient error states with explicit keyboard Retry. Atomic slug-owned read state hides prior HTML/PDF/error/name/height before the next effect and ignores late requests. Slugs remain one encoded API/PDF segment. Locale changes leave source facts and request counts unchanged. Existing Next metadata keeps Jobbjakt during loading; loaded name changes the title and owned cleanup restores the prior title without overwriting a newer route. No new saving, publication or canonical-editing owner is introduced.
Real HTTP anonymous public HTML/PDF200 with `%PDF` bytes, noindex, private401/numeric404 and revoked/rotated-link404 are verified. Owner source/settings/revisions and private sibling remain unchanged. Explicit reload observes revocation; live cross-client invalidation is not claimed. Harness history exercises slug/back/forward state without adding product navigation. All **18 final public PNGs were displayed and reviewed**, including balanced320/360/390/768/1024/1280-landscape/1440/1920 EN/Bokmål/light/dark and two long-document samples. Chrome wraps without horizontal overflow; white A4 canvas, original scaling/measurement/sandbox remain.
[Fresh final fidelity](evidence/public-fidelity-results.json) passes **16 fixtures/36 pages plus8/24** after the final source/build; native corpus1/settings4 also pass. Exact membership and existing dimension0.5mm/position2mm/font0.5pt/RGB1/255 tolerances are unchanged.236 exact corpus files are frozen; four final comparison PNGs (long-nb-code first/last and dark-nb-after first/last) were actually reviewed. Not all60 pages or44 native settings PNGs were visually inspected; production font/photo/print qualification remains.
Public **18 all-enabled top-document scans0 violations/12 incomplete occurrences** are entirely `frame-tested`: axe could not fully inspect the sandboxed document. This is an open document accessibility qualification, not full CV conformance; do not enable scripts to quiet the scan. Final settings **12 WCAG-tagged scans0/223** separately retain24 disabled Undo/Redo span labels,12 badge short-text,161 overlapping and26 partially obscured contexts. All node vectors are retained. M09/M11/human screen-reader and all M01–M12 device/zoom/output/provider gates remain unverified.
Meaningful RED4/6 and title RED13/1 are frozen separately. Initial native6/2 reflects a30s second-scan timeout plus an exact initial-title fixture mismatch with the verified Next metadata owner. The fixture now asserts Jobbjakt and uses90s for that two-scan case; no assertion/rule relaxation or skip. An intermediate38/3 passed but lint failed a captured-ref cleanup warning; final captured originalTitle fixes the warning without suppression. Final eight ordinary cases pass with retries0. All57 whole-group statuses remain56 PARTIAL/C11 DEFERRED; C05/C12/E09 have finite sharing/revocation/source/export proof. RD21 concurrency/partial writes, historical output/orphan debt and release gates remain. P5-13 withheld, main unchanged/production NO-GO, Phase8 separate.
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
Final public [staged hygiene](evidence/public-static-checks.json) PASS at2026-10-12 22:24:04UTC: four commands0,11docs/412 local links0errors,57groups/32routes. Normal development push pending; actual hash and direct remote receipt follow in the next checkpoint.
@@ -3,7 +3,7 @@
"checkpoint": "P7-04",
"slice": "CV library",
"recordedUtc": "2026-10-12T18:03:01.494389+00:00",
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_STATIC_AND_NORMAL_PUSH_PENDING",
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_NORMAL_PUSH_VERIFIED",
"entryReceipt": {
"observedUtc": "2026-10-12T17:35:00.882169+00:00",
"branch": "codex/ui-redesign-phase4",
@@ -3273,5 +3273,106 @@
"freshIntegratedAcceptance": "P7-12 pending"
},
"preservation": "All57 remain56 PARTIAL/C11 DEFERRED;32 routes;C05/C12/E09 plus source/version/frozen/export guards supported by finite proof. No source/settings/API/schema/renderer/model/provider/save-contract/production activation.",
"remaining": "Public entry boundary pending; full P7-04 pending. Shared gallery editor consumer default disabled=false and original frame4 pass."
"remaining": "Public entry boundary pending; full P7-04 pending. Shared gallery editor consumer default disabled=false and original frame4 pass.",
"normalPushReceipt": {
"observedUtc": "2026-10-12T18:03:41.481787+00:00",
"branch": "codex/ui-redesign-phase4",
"head": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"originDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"liveDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"workingTreeClean": true,
"pendingPaths": [],
"commands": [
{
"command": [
"rtk",
"proxy",
"git",
"branch",
"--show-current"
],
"exitCode": 0,
"stdout": "codex/ui-redesign-phase4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"HEAD"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"origin/codex/ui-redesign-phase4"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"main"
],
"exitCode": 0,
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"origin/main"
],
"exitCode": 0,
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"ls-remote",
"origin",
"refs/heads/codex/ui-redesign-phase4",
"refs/heads/main"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"status",
"--porcelain=v1"
],
"exitCode": 0,
"stdout": "",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
}
]
},
"finalStaticChecks": "docs/redesign/phase-7/p7-04/evidence/library-static-checks.json"
}
@@ -0,0 +1,149 @@
{
"checkpoint": "P7-04",
"slice": "Public entry",
"entry": "CONDITIONAL GO; migration pending",
"libraryPushReceipt": {
"observedUtc": "2026-10-12T18:03:41.481787+00:00",
"branch": "codex/ui-redesign-phase4",
"head": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"originDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"liveDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"workingTreeClean": true,
"pendingPaths": [],
"commands": [
{
"command": [
"rtk",
"proxy",
"git",
"branch",
"--show-current"
],
"exitCode": 0,
"stdout": "codex/ui-redesign-phase4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"HEAD"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"origin/codex/ui-redesign-phase4"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"main"
],
"exitCode": 0,
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"rev-parse",
"origin/main"
],
"exitCode": 0,
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"ls-remote",
"origin",
"refs/heads/codex/ui-redesign-phase4",
"refs/heads/main"
],
"exitCode": 0,
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
},
{
"command": [
"rtk",
"proxy",
"git",
"status",
"--porcelain=v1"
],
"exitCode": 0,
"stdout": "",
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
}
]
},
"baselines": [
{
"source": "job-tracker-ui/src/views/PublicCvPage.tsx",
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/views/PublicCvPage.tsx",
"sha256": "9b32e53f92359052e69583a90526fdebce2f2df02c80369d0d9e6748ad1a6210",
"bytes": 3574,
"hashPolicy": "Exact Git blob bytes"
},
{
"source": "job-tracker-ui/src/public-cv-page.test.tsx",
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/public-cv-page.test.tsx",
"sha256": "f9f96d2cea8689ce795f998553db7741f4672e32c542d9b1b920be3b893fad1c",
"bytes": 1581,
"hashPolicy": "Exact Git blob bytes"
},
{
"source": "job-tracker-ui/src/App.tsx",
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/App.tsx",
"sha256": "b7032f52def046a6f6104839a0acb6e6bd8c85b9cdfb73b6912a0923a85a0b16",
"bytes": 27290,
"hashPolicy": "Exact Git blob bytes"
},
{
"source": "job-tracker-ui/src/components/ViewStateNotice.tsx",
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/components/ViewStateNotice.tsx",
"sha256": "7b6c3e6c311860897eb5327936dbb129e860781929cfd9a4d4e17f81c607c70c",
"bytes": 2312,
"hashPolicy": "Exact Git blob bytes"
},
{
"source": "job-tracker-ui/src/layout/PageLayout.tsx",
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/layout/PageLayout.tsx",
"sha256": "31c7510446382039cd24b96e5709d5d8dfd00a0b84fd319a53183d1da6c2efa4",
"bytes": 2280,
"hashPolicy": "Exact Git blob bytes"
}
],
"libraryAcceptance": "Conditional with human/context/provider/output/production qualifications",
"dependencies": "Keep exact fixedA4/white iframe/sandbox/noindex/anonymous public HTML+PDF guards; prescribed16/36+8/24 when preview touched; no backend/model/save/Phase8/main work"
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
{
"schemaVersion": 1,
"checkpoint": "p7-04",
"status": "PASS",
"checkedUtc": "2026-10-12T22:24:04.886934+00:00",
"commands": [
{
"name": "secret-scan",
"command": [
"rtk",
"proxy",
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
"-X",
"utf8",
"scripts/supply-chain.py",
"scan-secrets"
],
"startedUtc": "2026-10-12T22:24:01.388438+00:00",
"finishedUtc": "2026-10-12T22:24:04.240173+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261012T222401Z/secret-scan.log",
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
},
{
"name": "working-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check"
],
"startedUtc": "2026-10-12T22:24:04.240675+00:00",
"finishedUtc": "2026-10-12T22:24:04.325036+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261012T222401Z/working-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "staged-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--cached",
"--check"
],
"startedUtc": "2026-10-12T22:24:04.325036+00:00",
"finishedUtc": "2026-10-12T22:24:04.415611+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261012T222401Z/staged-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "phase7-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check",
"b5319981ad3d1bd635daff861453d23c0de5320b"
],
"startedUtc": "2026-10-12T22:24:04.415611+00:00",
"finishedUtc": "2026-10-12T22:24:04.546953+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261012T222401Z/phase7-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
}
],
"untrackedSecretScan": {
"files": 0,
"findings": 0
},
"documentation": {
"files": 11,
"relativeLinks": 412,
"errors": []
},
"preservation": {
"groups": 57,
"statuses": {
"PARTIAL": 56,
"PASS": 0,
"FAIL": 0,
"DEFERRED": 1
},
"originalRoutesPreserved": true,
"currentConcreteRoutes": 32
},
"production": {
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"status": "NO-GO",
"qualification": "Local fetched refs; direct push observation recorded separately."
},
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
"limits": [
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
"Local links check target existence, not URL/heading anchors.",
"Static evidence is not runtime/manual/device/provider/production acceptance."
]
}
+1 -1
View File
@@ -86,7 +86,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
- [ ] P7-04 CV library and public entry: Library finite implementation CONDITIONAL GO (38/4 unit,8 native,4 editor,72 backend,19 actual PNG reviews; types/lint/build pass); staged hygiene/normal push and separate public entry pending. Manual/M09/M11/production qualifications retained.
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene/normal push pending; sandbox frame-tested12 and human/device/production qualifications retained.
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
- [ ] P7-06 remaining Application forms and global correspondence.
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
+402
View File
@@ -0,0 +1,402 @@
import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type Response, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFile } from 'node:fs/promises';
import { translations } from '../src/i18n/translations';
import type { CvVariant } from '../src/cvBuilder';
import { emptyStructuredCv } from '../src/profileCv';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const a4Width = 210 / 25.4 * 96;
const a4Height = 297 / 25.4 * 96;
type Owner = { api: APIRequestContext; id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type Documents = { published: CvVariant; sibling: CvVariant };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext();
const api = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const email = `p7-public-cv-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200);
const owner = { api, ...await login(api, email) };
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await provide(owner);
} finally { await api.dispose(); await admin.dispose(); }
},
});
async function seed(owner: Owner, long = false): Promise<Documents> {
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
fullName: 'Åse Nordlys — recorded owner-reviewed identity', headline: 'Recorded canonical professional context',
email: 'reviewed-professional-contact@example.test', location: 'Trondheim, Norge' },
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'],
skills: ['C#', 'React', 'Norsk'],
jobs: Array.from({ length: long ? 14 : 1 }, (_, index) => ({ id: `public-source-job-${index}`, title: `Recorded professional role ${index + 1}`,
company: `Recorded Norwegian organisation ${index + 1}`, start: 'Jan 2020', end: 'Dec 2023',
bullets: long ? ['Recorded implementation responsibility with owner-reviewed professional context across reliable services and accessible interfaces.',
'Recorded collaboration with colleagues on documented delivery and operational responsibilities across Norwegian organisations.',
'Recorded review and maintenance of existing services, preserving factual source material without inventing metrics or qualifications.'] : ['Recorded owner-reviewed source evidence.'], skills: ['C#'] })),
};
await json(await owner.api.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile, cvText: 'Exact owner-reviewed original source — æøå\nRecorded work and qualifications.\n' } }));
const variant = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Recorded shared CV', settings: { themeId: 'minimal', language: 'en', showPhoto: false, showPageNumbers: false, showIcons: true,
sections: [], customSections: [], headline: 'Recorded CV-specific shared presentation',
overrides: { 'public-source-job-0': { title: 'CV-specific reviewed public title', bullets: ['CV-specific reviewed public evidence.'] } } } } }));
const sibling = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Recorded private sibling', settings: { themeId: 'minimal', showPhoto: false, showPageNumbers: false, showIcons: true,
sections: [], overrides: {}, customSections: [], headline: 'Recorded alternate CV presentation' } } }));
const published = await publish(owner, variant.id, true);
return { published, sibling };
}
async function publish(owner: Owner, id: number, isPublic: boolean) {
return json<CvVariant>(await owner.api.put(`${apiUrl}/cv/variants/${id}/public`, { headers: owner.headers, data: { isPublic } }));
}
async function preserved(owner: Owner, documents: Documents) {
const current = await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}`));
return { canonical: await json<unknown>(await owner.api.get(`${apiUrl}/career/profile`)),
sourceHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/career/profile/versions`)),
selected: { name: current.name, settings: current.settings, version: current.version, jobApplicationId: current.jobApplicationId },
selectedHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}/versions`)),
sibling: await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}`)),
siblingHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}/versions`)) };
}
async function enter(page: Page, slug: string, sample: Sample = desktop) {
await page.addInitScript(({ language, theme }) => {
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/cv/${slug}?source=phase7-public#preserved`);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (stored.language !== sample.language || stored.theme !== sample.theme) {
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample);
await page.reload();
}
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 1, name: translations[sample.language].publicCvTitle, exact: true })).toBeVisible();
}
async function ready(page: Page, language: Sample['language']) {
const frame = page.getByTitle(translations[language].publicCvTitle, { exact: true });
await expect(frame).toBeVisible();
await expect.poll(() => frame.evaluate(element => {
const doc = (element as HTMLIFrameElement).contentDocument;
return doc?.readyState === 'complete' && !!doc.body.textContent?.includes('CV-specific reviewed public title');
})).toBe(true);
await expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
await expect(page.getByRole('link', { name: translations[language].publicCvDownloadPdf, exact: true })).toBeVisible();
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
return frame;
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, documentExpected = true) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
overflowElements: [...document.querySelectorAll('body *')]
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
}));
const frame = page.getByTitle(translations[sample.language].publicCvTitle, { exact: true });
const documentMetrics = documentExpected ? await frame.evaluate(async element => {
const iframe = element as HTMLIFrameElement;
const doc = iframe.contentDocument!;
await doc.fonts.ready;
const style = getComputedStyle(iframe);
const rect = iframe.getBoundingClientRect();
const wrapper = iframe.parentElement!;
const wrapperRect = wrapper.getBoundingClientRect();
return { cssWidth: parseFloat(style.width), cssHeight: parseFloat(style.height), renderedWidth: rect.width, renderedHeight: rect.height,
left: rect.left, right: rect.right, scale: new DOMMatrix(style.transform).a, frameBackground: style.backgroundColor,
wrapperWidth: wrapperRect.width, wrapperHeight: wrapperRect.height, wrapperLeft: wrapperRect.left, wrapperRight: wrapperRect.right,
documentLang: doc.documentElement.lang, documentWidth: doc.documentElement.clientWidth,
documentScrollWidth: doc.documentElement.scrollWidth, contentHeight: doc.body.scrollHeight,
documentScrollHeight: doc.documentElement.scrollHeight, reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches };
}) : null;
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-public-cv-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, documentMetrics, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled rules plus target-size; complete iframe/incomplete contexts retained', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Automated local Chromium; no human screen reader, physical touch/software keyboard, actual browser zoom or production font/photo qualification.',
'Document locale is separate from app locale. CSS-pixel A4/white-canvas measurements are not manual print, Linux-font or PDF pagination-fidelity acceptance.',
'Same-origin sandbox and noindex are retained; any untestable iframe axe contexts remain incomplete rather than being hidden.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
if (documentMetrics) {
expect(Math.abs(documentMetrics.cssWidth - a4Width)).toBeLessThan(0.05);
expect(Math.abs(documentMetrics.renderedWidth - Math.min(a4Width, geometry.width - 32))).toBeLessThan(1);
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(a4Height - 0.05);
expect(documentMetrics.documentScrollWidth - documentMetrics.documentWidth).toBeLessThanOrEqual(1);
expect(documentMetrics.left).toBeGreaterThanOrEqual(0);
expect(documentMetrics.right).toBeLessThanOrEqual(geometry.width + 1);
expect(documentMetrics.left).toBeGreaterThanOrEqual(documentMetrics.wrapperLeft - 1);
expect(documentMetrics.right).toBeLessThanOrEqual(documentMetrics.wrapperRight + 1);
expect(Math.abs(documentMetrics.renderedWidth - documentMetrics.cssWidth * documentMetrics.scale)).toBeLessThan(1);
expect(Math.abs(documentMetrics.wrapperWidth - documentMetrics.renderedWidth)).toBeLessThan(1);
expect(Math.abs(documentMetrics.wrapperHeight - documentMetrics.renderedHeight)).toBeLessThan(1);
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(documentMetrics.contentHeight - 1);
expect(documentMetrics.frameBackground).toBe('rgb(255, 255, 255)');
expect(documentMetrics.reducedMotion).toBe(true);
} else await expect(frame).toHaveCount(0);
expect(axe.violations).toEqual([]);
return documentMetrics;
}
test('[P7-04 PRES-C05 C07 C12 A02] anonymous shared CV keeps sandbox noindex private denial and actual keyboard PDF download', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
const response = await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`);
const shared = await json<{ html: string; name: string }>(response);
expect(response.headers()['x-robots-tag']).toBe('noindex, nofollow');
expect(shared.html).toContain('CV-specific reviewed public title');
expect((await page.request.get(`${apiUrl}/cv/variants/${documents.published.id}`)).status()).toBe(401);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.sibling.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.id}`)).status()).toBe(404);
await enter(page, documents.published.publicSlug);
const frame = await ready(page, 'en');
expect(await frame.getAttribute('srcdoc')).toBe(shared.html);
expect(await page.title()).toContain(shared.name);
const downloadResponse = page.waitForResponse(result => result.url() === `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
const downloadEvent = page.waitForEvent('download');
const link = page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true });
await expect(link).toHaveAttribute('href', `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
await link.focus(); await page.keyboard.press('Enter');
const downloaded = await downloadEvent;
const pdfResponse = await downloadResponse;
expect(pdfResponse.status()).toBe(200); expect(pdfResponse.headers()['content-type']).toContain('application/pdf');
expect(await downloaded.failure()).toBeNull();
const downloadedPath = await downloaded.path(); expect(downloadedPath).not.toBeNull();
const pdf = await readFile(downloadedPath!);
expect(pdf.length).toBeGreaterThan(1_000); expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
await info.attach('public-cv-actual-keyboard-download.pdf', { body: pdf, contentType: 'application/pdf' });
expect(await preserved(owner, documents)).toEqual(before);
await capture(page, info, desktop, 'anonymous-download');
await info.attach('public-cv-anonymous-contract', { body: JSON.stringify({ privateStatus: 401, unknownNumericSlugStatus: 404,
privateSiblingSlugStatus: 404, pdfStatus: pdfResponse.status(), pdfBytes: pdf.length, fileName: downloaded.suggestedFilename(),
qualification: 'Actual anonymous HTML and local Chromium PDF bytes, triggered by keyboard Enter on the real download link. This does not establish print pagination, deployed Linux fonts/photos or production acceptance.' }), contentType: 'application/json' });
await page.evaluate(() => {
history.pushState(null, '', '/login');
dispatchEvent(new PopStateEvent('popstate'));
});
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
await expect(page).toHaveTitle('Jobbjakt');
});
test('[P7-04 PRES-C05 C12] owner revocation removes anonymous view and PDF; republishing rotates the revoked link', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
await enter(page, documents.published.publicSlug, sample); await ready(page, 'nb');
await publish(owner, documents.published.id, false);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
await page.reload();
await expect(page.getByText(translations.nb.publicCvUnavailableTitle, { exact: true })).toBeVisible();
await expect(page.getByText(translations.nb.publicCvUnavailableBody, { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: translations.nb.retry, exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'revoked', false);
const republished = await publish(owner, documents.published.id, true);
expect(republished.publicSlug).not.toBe(documents.published.publicSlug);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
await enter(page, republished.publicSlug, sample); await ready(page, 'nb');
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-revocation-readback', { body: JSON.stringify({ oldSlug: documents.published.publicSlug, newSlug: republished.publicSlug,
qualification: 'Actual owner publication PUTs and anonymous old-link HTML/PDF404; settings, versions, source and private sibling are unchanged. Existing open content is refreshed explicitly to observe revocation; no claim of live cross-client revocation events.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] transient error retries by keyboard with held real read polite progress and stable H1 focus', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => {
reads++;
if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled public CV read unavailable' }) });
if (reads === 2) await held;
return route.continue();
});
await enter(page, documents.published.publicSlug, sample);
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toBeVisible();
await expect(page.getByText(translations.nb.publicCvLoadFailedBody, { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'transient-failure', false);
const title = page.getByRole('heading', { level: 1, name: translations.nb.publicCvTitle, exact: true });
try {
await page.getByRole('button', { name: translations.nb.retry, exact: true }).focus(); await page.keyboard.press('Enter');
await expect.poll(() => reads).toBe(2);
await expect(title).toBeFocused();
await expect(page.getByRole('progressbar', { name: translations.nb.loading, exact: true })).toBeVisible();
await expect(page.getByRole('status')).toHaveText(translations.nb.loading);
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
await page.keyboard.press('Enter'); expect(reads).toBe(2);
} finally { release(); }
await ready(page, 'nb'); await expect(title).toBeFocused();
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toHaveCount(0);
expect(reads).toBe(2); expect(await preserved(owner, documents)).toEqual(before);
await capture(page, info, sample, 'explicit-recovery');
await info.attach('public-cv-controlled-retry', { body: JSON.stringify({ reads,
qualification: 'First GET is a controlled500; only keyboard Retry reaches the held then continued actual HTTP read. Successful document HTML is not fabricated; no automatic retry or source mutation.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] initial held anonymous read announces loading without a document or download', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'en', theme: 'dark', width: 360, height: 844 };
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => { reads++; await held; return route.continue(); });
try {
await enter(page, documents.published.publicSlug, sample);
await expect.poll(() => reads).toBe(1);
await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible();
await expect(page.getByRole('status')).toHaveText(translations.en.loading);
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
expect(await page.title()).toBe('Jobbjakt');
await capture(page, info, sample, 'initial-held-read', false);
} finally { release(); }
await ready(page, 'en'); expect(reads).toBe(1);
expect(await preserved(owner, documents)).toEqual(before);
});
test('[P7-04 PRES-C12 E08] unknown and numeric slugs use localized unavailable states without Retry or PDF controls', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const samples: Sample[] = [
{ language: 'en', theme: 'light', width: 320, height: 844 },
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
];
for (let index = 0; index < samples.length; index++) {
const sample = samples[index];
const slug = index === 0 ? `missing-${owner.id}` : String(documents.published.id);
expect((await page.request.get(`${apiUrl}/public-cv/${slug}`)).status()).toBe(404);
await enter(page, slug, sample);
const t = translations[sample.language];
await expect(page.getByText(t.publicCvUnavailableTitle, { exact: true })).toBeVisible();
await expect(page.getByText(t.publicCvUnavailableBody, { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: t.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'unavailable', false);
}
expect(await preserved(owner, documents)).toEqual(before);
});
test('[P7-04 PRES-C12] same-document slug change removes prior HTML title and download before the next actual response', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const alternate = await publish(owner, documents.sibling.id, true);
const before = await preserved(owner, documents);
await enter(page, documents.published.publicSlug); await ready(page, 'en');
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${alternate.publicSlug}`, async route => { reads++; await held; return route.continue(); });
try {
await page.evaluate(slug => {
history.pushState(null, '', `/cv/${slug}?source=phase7-public-route#preserved`);
dispatchEvent(new PopStateEvent('popstate'));
}, alternate.publicSlug);
await expect.poll(() => reads).toBe(1);
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
expect(await page.title()).toBe('Jobbjakt');
await capture(page, info, desktop, 'changed-slug-held', false);
} finally { release(); }
const alternateFrame = page.getByTitle(translations.en.publicCvTitle, { exact: true });
await expect(alternateFrame).toBeVisible();
await expect.poll(() => alternateFrame.getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
expect(await alternateFrame.getAttribute('srcdoc')).not.toContain('Recorded CV-specific shared presentation');
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveAttribute('href', `${apiUrl}/public-cv/${alternate.publicSlug}/pdf`);
await page.goBack(); await ready(page, 'en');
await page.goForward();
await expect.poll(() => page.getByTitle(translations.en.publicCvTitle, { exact: true }).getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-same-document-transition', { body: JSON.stringify({ originalSlug: documents.published.publicSlug, alternateSlug: alternate.publicSlug, reads,
qualification: 'A test-harness browser History pushState/popstate establishes a same-document route change; no product navigation is added. Back/forward then use actual browser history. Both documents and GETs are real; late-response isolation also requires focused unit evidence.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] finite public width locale theme matrix preserves A4 white document and scaled wrapper boundaries', async ({ page, owner }, info) => {
test.setTimeout(180_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
const samples: Sample[] = [
{ language: 'nb', theme: 'light', width: 320, height: 844 },
{ language: 'en', theme: 'dark', width: 360, height: 844 },
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
{ language: 'en', theme: 'light', width: 768, height: 1024 },
{ language: 'nb', theme: 'light', width: 1024, height: 768 },
{ language: 'en', theme: 'dark', width: 1280, height: 320 },
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
{ language: 'en', theme: 'light', width: 1920, height: 1080 },
];
for (const sample of samples) {
await enter(page, documents.published.publicSlug, sample); await ready(page, sample.language);
const link = page.getByRole('link', { name: translations[sample.language].publicCvDownloadPdf, exact: true });
await page.getByRole('heading', { level: 1 }).focus(); await page.keyboard.press('Tab');
await expect(link).toBeFocused();
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
await capture(page, info, sample, 'loaded');
}
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-finite-matrix', { body: JSON.stringify({ samples,
qualification: 'Eight balanced viewport/locale/theme samples, not the complete Cartesian product. Keyboard Tab reaches the PDF link; A4 CSS pixels and transformed wrapper boundaries are measured separately. Renderer settings and document locale remain unchanged.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 C07] long public CV height remains reachable without shrinking A4 geometry or mutating source', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner, true);
const before = await preserved(owner, documents);
const samples: Sample[] = [
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
{ language: 'en', theme: 'light', width: 320, height: 844 },
];
for (const sample of samples) {
await enter(page, documents.published.publicSlug, sample); const frame = await ready(page, sample.language);
await expect.poll(() => frame.evaluate(element => parseFloat(getComputedStyle(element).height))).toBeGreaterThan(a4Height);
const metrics = await capture(page, info, sample, 'long-document');
expect(metrics!.cssHeight).toBeGreaterThan(a4Height);
expect(metrics!.documentScrollHeight).toBeLessThanOrEqual(metrics!.cssHeight + 1);
}
expect(await preserved(owner, documents)).toEqual(before);
});
+14
View File
@@ -784,6 +784,13 @@ export const translations = {
cvEditorRestoredVersion: "Restored version {version}.",
cvEditorRestoreFailed: "Restore failed.",
cvDashboardTitle: "CV Builder",
publicCvTitle: "Public CV",
publicCvDescription: "A read-only CV shared by its owner. Download the PDF to keep a copy.",
publicCvDownloadPdf: "Download PDF",
publicCvUnavailableTitle: "CV unavailable",
publicCvUnavailableBody: "This CV isn’t available. The link may be wrong, or the owner has made it private.",
publicCvLoadFailedTitle: "Could not load CV",
publicCvLoadFailedBody: "This CV can’t be loaded right now. Please try again in a moment.",
cvDashboardLibraryTitle: "Your CVs",
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
cvDashboardUntitled: "Untitled CV",
@@ -3418,6 +3425,13 @@ export const translations = {
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
cvDashboardTitle: "CV-bygger",
publicCvTitle: "Offentlig CV",
publicCvDescription: "En CV som eieren har delt. Du kan lese den og laste ned en kopi som PDF.",
publicCvDownloadPdf: "Last ned PDF",
publicCvUnavailableTitle: "CV-en er utilgjengelig",
publicCvUnavailableBody: "Denne CV-en er ikke tilgjengelig. Lenken kan være feil, eller eieren kan ha gjort CV-en privat.",
publicCvLoadFailedTitle: "Kunne ikke laste CV",
publicCvLoadFailedBody: "CV-en kan ikke lastes akkurat nå. Prøv igjen om litt.",
cvDashboardLibraryTitle: "Dine CV-er",
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
cvDashboardUntitled: "CV uten tittel",
+241 -10
View File
@@ -1,10 +1,11 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { api } from './api';
import PublicCvPage from './views/PublicCvPage';
import { I18nProvider } from './i18n/I18nProvider';
jest.mock('./api', () => ({
api: {
@@ -14,24 +15,254 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
const originalInnerWidth = window.innerWidth;
const originalTitle = document.title;
function RouteControls() {
const navigate = useNavigate();
return <><button onClick={() => navigate('/cv/slug-a')}>Open A</button><button onClick={() => navigate('/cv/slug-b')}>Open B</button></>;
}
function renderPage(path = '/cv/public-slug') {
return render(
<I18nProvider>
<MemoryRouter initialEntries={[path]}>
<RouteControls />
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
</MemoryRouter>
</I18nProvider>,
);
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function switchLanguage(language: 'en' | 'nb') {
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
}
beforeEach(() => {
mockedApi.get.mockReset();
window.localStorage.removeItem('uiLanguage');
document.title = originalTitle;
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
window.localStorage.removeItem('uiLanguage');
document.title = originalTitle;
});
test('public CV exposes the rendered CV and PDF download', async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
render(
<MemoryRouter initialEntries={['/cv/public-slug']}>
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
</MemoryRouter>,
);
renderPage();
const frame = await screen.findByTitle('Public CV');
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
});
test('a public slug containing path and URL delimiters stays one encoded API and PDF path segment', async () => {
const slug = 'owner/document?mode=preview#section%';
const encodedSlug = encodeURIComponent(slug);
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
renderPage(`/cv/${encodedSlug}`);
expect(mockedApi.get).toHaveBeenCalledWith(`/public-cv/${encodedSlug}`);
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
await act(async () => { read.resolve({ data: { html: '<p>Encoded slug document</p>', name: 'Candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Encoded slug document</p>');
expect(screen.getByTitle('Public CV')).toHaveAttribute('sandbox', 'allow-same-origin');
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', `/api/public-cv/${encodedSlug}/pdf`);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test('a held public CV read has a named page and loading announcement without a document or download', async () => {
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
renderPage();
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { read.resolve({ data: { html: '<p>Current public document</p>', name: 'Ada' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current public document</p>');
});
test('a revoked or missing public slug shows an unavailable state without retry or download', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } });
renderPage();
expect(await screen.findByText('CV unavailable')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent(/link|owner|private/i);
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test('a transient failure retries only when requested and keeps focus on the page heading during its held read', async () => {
const retry = deferred<any>();
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockImplementationOnce(() => retry.promise);
renderPage();
expect(await screen.findByText('Could not load CV')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
const retryButton = screen.getByRole('button', { name: 'Retry' });
retryButton.focus();
fireEvent.click(retryButton);
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toHaveFocus();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
await act(async () => { retry.resolve({ data: { html: '<p>Recovered CV</p>', name: 'Recovered candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Recovered CV</p>');
expect(screen.queryByText('Could not load CV')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('changing public slugs immediately hides the previous document, name and PDF and resets its measured height', async () => {
const second = deferred<any>();
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate A</p>', name: 'Candidate A' } } as any).mockImplementationOnce(() => second.promise);
renderPage('/cv/slug-a');
const firstFrame = await screen.findByTitle('Public CV') as HTMLIFrameElement;
Object.defineProperty(firstFrame.contentDocument!.documentElement, 'scrollHeight', { configurable: true, value: 3000 });
fireEvent.load(firstFrame);
expect(firstFrame.style.height).toBe('3000px');
expect(document.title).toBe('Candidate A — CV');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(document.title).not.toContain('Candidate A');
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
await act(async () => { second.resolve({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } }); });
const secondFrame = await screen.findByTitle('Public CV');
expect(secondFrame).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(Number.parseFloat(secondFrame.style.height)).toBeCloseTo((297 / 25.4) * 96);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
expect(document.title).toBe('Candidate B — CV');
});
test('a failed public slug cannot hide a subsequently loaded different public CV', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
renderPage('/cv/slug-a');
await screen.findByText(/link.*wrong|wrong.*link/i);
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
});
test.each(['success', 'failure'])('a late %s from the previous slug cannot replace the current CV or its title', async (outcome) => {
const previous = deferred<any>();
mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
renderPage('/cv/slug-a');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
await act(async () => {
if (outcome === 'success') previous.resolve({ data: { html: '<p>Late candidate A</p>', name: 'Late candidate A' } });
else previous.reject({ response: { status: 404 } });
});
expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(document.title).toBe('Candidate B — CV');
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('language changes translate loading, failure and loaded chrome without refetching anonymous CV content', async () => {
const first = deferred<any>();
mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '<p>Untranslated candidate facts</p>', name: 'Candidate' } } as any);
renderPage();
switchLanguage('nb');
expect(screen.getByRole('heading', { level: 1, name: 'Offentlig CV' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Laster/i);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { first.reject({ response: { status: 500 } }); });
expect(await screen.findByText('Kunne ikke laste CV')).toBeInTheDocument();
switchLanguage('en');
expect(screen.getByText('Could not load CV')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
switchLanguage('nb');
expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
expect(screen.getByRole('link', { name: 'Last ned PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('the public page removes only its own noindex metadata and ignores a response after unmount', async () => {
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
const unrelated = document.createElement('meta');
unrelated.name = 'robots';
unrelated.content = 'index, follow';
document.head.appendChild(unrelated);
try {
const page = renderPage();
const owned = Array.from(document.head.querySelectorAll('meta[name="robots"]')).find((item) => item !== unrelated)!;
expect(owned).toHaveAttribute('content', 'noindex, nofollow');
expect(unrelated).toHaveAttribute('content', 'index, follow');
page.unmount();
expect(owned).not.toBeInTheDocument();
expect(unrelated).toBeInTheDocument();
document.title = 'Destination after unmount';
await act(async () => { read.resolve({ data: { html: '<p>Late response</p>', name: 'Late candidate' } }); });
expect(document.title).toBe('Destination after unmount');
expect(unrelated).toHaveAttribute('content', 'index, follow');
} finally { unrelated.remove(); }
});
test('leaving a loaded public CV restores the prior page title when its candidate title is still current', async () => {
document.title = 'Jobs dashboard — Jobbjakt';
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
const page = renderPage();
await screen.findByTitle('Public CV');
expect(document.title).toBe('Candidate identity — CV');
page.unmount();
expect(document.title).toBe('Jobs dashboard — Jobbjakt');
});
test('leaving a loaded public CV preserves a page title already replaced by another owner', async () => {
document.title = 'Jobs dashboard — Jobbjakt';
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
const page = renderPage();
await screen.findByTitle('Public CV');
expect(document.title).toBe('Candidate identity — CV');
document.title = 'Destination page — Jobbjakt';
page.unmount();
expect(document.title).toBe('Destination page — Jobbjakt');
});
test('returning from A through B to A ignores both earlier held reads until the current A response arrives', async () => {
const originalA = deferred<any>();
const previousB = deferred<any>();
const currentA = deferred<any>();
mockedApi.get.mockImplementationOnce(() => originalA.promise)
.mockImplementationOnce(() => previousB.promise)
.mockImplementationOnce(() => currentA.promise);
renderPage('/cv/slug-a');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
fireEvent.click(screen.getByRole('button', { name: 'Open A' }));
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/public-cv/slug-a', '/public-cv/slug-b', '/public-cv/slug-a']);
await act(async () => { originalA.resolve({ data: { html: '<p>Stale original A</p>', name: 'Stale original A' } }); });
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(document.title).not.toContain('Stale original A');
await act(async () => { previousB.reject({ response: { status: 404 } }); });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
await act(async () => { currentA.resolve({ data: { html: '<p>Current A document</p>', name: 'Current A candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current A document</p>');
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-a/pdf');
expect(document.title).toBe('Current A candidate — CV');
expect(mockedApi.get).toHaveBeenCalledTimes(3);
});
+73 -41
View File
@@ -1,43 +1,78 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Button } from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader } from "../layout/PageLayout";
import ViewStateNotice from "../components/ViewStateNotice";
const A4_WIDTH_PX = (210 / 25.4) * 96;
const A4_HEIGHT_PX = (297 / 25.4) * 96;
type PublicCvRead = {
slug?: string;
html?: string;
name?: string;
error?: "missing" | "transient";
loading: boolean;
height: number;
};
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
export default function PublicCvPage() {
const { slug } = useParams();
const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const pdfUrl = `${api.defaults.baseURL}/public-cv/${encodeURIComponent(slug ?? "")}/pdf`;
const [read, setRead] = useState<PublicCvRead>({ slug, loading: true, height: A4_HEIGHT_PX });
const [retry, setRetry] = useState(0);
const pending = useRef(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const previousTitle = useRef("");
const ownedTitle = useRef<string | null>(null);
const [frameScale, setFrameScale] = useState(1);
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
// Hide the old document on the first render of a new route, before its effect runs.
const current = read.slug === slug ? read : null;
const loading = !current || current.loading;
const frameHeight = current?.height ?? A4_HEIGHT_PX;
const title = t("publicCvTitle");
const unavailable = current?.error === "missing";
const errorTitle = t(unavailable ? "publicCvUnavailableTitle" : "publicCvLoadFailedTitle");
useEffect(() => {
const originalTitle = document.title;
previousTitle.current = originalTitle;
const meta = document.createElement("meta");
meta.name = "robots";
meta.content = "noindex, nofollow";
document.head.appendChild(meta);
return () => {
meta.remove();
if (document.title === ownedTitle.current) document.title = originalTitle;
};
}, []);
useEffect(() => {
let alive = true;
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
.then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
pending.current = true;
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
api.get<{ html: string; name: string }>(`/public-cv/${encodeURIComponent(slug ?? "")}`)
.then((r) => { if (alive) setRead({ slug, html: r.data.html, name: r.data.name, loading: false, height: A4_HEIGHT_PX }); })
.catch((err) => {
if (!alive) return;
// 404 = unknown slug or the owner made it private again. Anything else is a transient error.
setError(err?.response?.status === 404
? "This CV isn’t available. The link may be wrong, or the owner has made it private."
: "This CV can’t be loaded right now. Please try again in a moment.");
});
setRead({ slug, error: err?.response?.status === 404 ? "missing" : "transient", loading: false, height: A4_HEIGHT_PX });
})
.finally(() => { if (alive) pending.current = false; });
return () => { alive = false; };
}, [slug, retry]);
return () => {
alive = false;
document.head.removeChild(meta);
};
}, [slug]);
useEffect(() => {
const nextTitle = current?.name ? `${current.name} — CV` : previousTitle.current;
document.title = nextTitle;
ownedTitle.current = nextTitle;
}, [current?.name]);
useEffect(() => {
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
@@ -46,41 +81,38 @@ export default function PublicCvPage() {
return () => window.removeEventListener("resize", updateScale);
}, []);
if (error) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
{error}
</div>
);
}
if (html === null) {
return (
<div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
Loading…
</div>
);
}
return (
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
Download PDF
</a>
<div
<Box component="main" sx={{ minHeight: "100vh", bgcolor: "background.default", color: "text.primary", py: 3, px: "16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 2, minWidth: 0 }}>
<Box sx={{ width: "100%", maxWidth: A4_WIDTH_PX, minWidth: 0 }}>
<PageHeader title={title} titleRef={headingRef} description={t("publicCvDescription")}
actions={current?.html !== undefined ? <Button component="a" variant="contained" href={pdfUrl} download>{t("publicCvDownloadPdf")}</Button> : undefined} />
<ViewStateNotice compact loading={loading} title={errorTitle}
error={current?.error ? { kind: "unavailable", message: errorTitle, retryable: !unavailable } : null}
description={t(unavailable ? "publicCvUnavailableBody" : "publicCvLoadFailedBody")}
onRetry={() => {
if (pending.current || loading) return;
pending.current = true;
headingRef.current?.focus();
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
setRetry((value) => value + 1);
}} />
</Box>
{current?.html !== undefined && <div
data-testid="public-cv-frame-container"
style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
>
<iframe
title="Public CV"
srcDoc={html}
title={title}
srcDoc={current.html}
sandbox="allow-same-origin"
onLoad={(event) => {
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
setRead((previous) => previous.slug === slug && previous.html === current.html && !previous.loading
? { ...previous, height: Math.max(A4_HEIGHT_PX, documentHeight) } : previous);
}}
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
/>
</div>
</div>
</div>}
</Box>
);
}