@@ -0,0 +1,175 @@
import { expect , test , type APIRequestContext , type Page , type TestInfo } from '@playwright/test' ;
import AxeBuilder from '@axe-core/playwright' ;
import { translations } from '../src/i18n/translations' ;
const apiUrl = 'http://localhost:5302/api' ;
const password = 'E2ePassword123!' ;
async function login ( api : APIRequestContext , email : string ) {
expect ( ( await api . post ( ` ${ apiUrl } /auth/login ` , { data : { email , password } } ) ) . status ( ) ) . toBe ( 200 ) ;
const me = await ( await api . get ( ` ${ apiUrl } /auth/me ` ) ) . json ( ) ;
return { 'X-CSRF-TOKEN' : ( await api . storageState ( ) ) . cookies . find ( cookie = > cookie . name === 'XSRF-TOKEN' ) ! . value , 'X-Session-Context' : me . sessionContext as string } ;
}
test ( '[PRES-W01 W08 M01 E08] Job section loading and partial read failures stay explicit and recover locally' , async ( { page } , info ) = > {
const headers = await login ( page . request , 'e2e@example.test' ) ;
const company = await page . request . post ( ` ${ apiUrl } /companies ` , { headers , data : { name : 'Section state fixture' } } ) ;
expect ( company . status ( ) ) . toBe ( 201 ) ;
const created = await page . request . post ( ` ${ apiUrl } /jobapplications ` , { headers , data : { companyId : ( await company . json ( ) ) . id , jobTitle : 'Section state fixture' , status : 'Saved' , notes : 'Retained personal notes' } } ) ;
expect ( created . status ( ) ) . toBe ( 201 ) ; const id = ( await created . json ( ) ) . id ;
await page . setViewportSize ( { width : 390 , height : 844 } ) ;
let release : ( ( ) = > void ) | undefined ; let fail = true ;
const hold = new Promise < void > ( resolve = > { release = resolve ; } ) ;
await page . route ( ` ${ apiUrl } /attachments/ ${ id } ` , async route = > {
await hold ;
return fail ? route . fulfill ( { status : 503 , json : { message : 'Temporary attachment read failure' } } ) : route . continue ( ) ;
} ) ;
await page . goto ( ` /jobs/ ${ id } ?section=documents ` ) ;
await expect ( page . getByRole ( 'region' , { name : translations.en.workspaceCurrentDocuments } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'progressbar' ) . first ( ) ) . toBeVisible ( ) ;
await capture ( page , info , 'sections-documents-loading' ) ; release ! ( ) ;
await expect ( page . getByRole ( 'alert' ) . filter ( { hasText : 'Temporary attachment read failure' } ) ) . toBeVisible ( ) ;
await expect ( page . getByText ( translations . en . attachmentsEmpty , { exact : true } ) ) . toHaveCount ( 0 ) ;
await capture ( page , info , 'sections-documents-error' ) ; fail = false ;
await page . getByRole ( 'button' , { name : 'Retry' , exact : true } ) . click ( ) ;
await expect ( page . getByText ( translations . en . attachmentsEmpty , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , 'sections-documents-recovered' ) ;
await page . route ( ` ${ apiUrl } /correspondence/ ${ id } ` , route = > route . fulfill ( { status : 503 , json : { message : 'Temporary correspondence read failure' } } ) ) ;
await page . goto ( ` /jobs/ ${ id } ?section=communication&followMode=waiting-update ` ) ;
await expect ( page . getByRole ( 'alert' ) . filter ( { hasText : 'Temporary correspondence read failure' } ) ) . toBeVisible ( ) ;
await expect ( page . getByText ( translations . en . correspondenceNoMessages , { exact : true } ) ) . toHaveCount ( 0 ) ;
await expect ( page . getByRole ( 'textbox' , { name : translations.en.correspondenceLogNoteOrMessage } ) ) . toBeVisible ( ) ;
await capture ( page , info , 'sections-correspondence-error' ) ;
await page . goto ( ` /jobs/ ${ id } ?section=tasks ` ) ;
await expect ( page . getByRole ( 'region' , { name : translations.en.workspaceNotes , exact : true } ) ) . toContainText ( 'Retained personal notes' ) ;
await capture ( page , info , 'sections-sparse-tasks' ) ;
await page . goto ( ` /jobs/ ${ id } ?section=interview ` ) ;
await expect ( page . getByText ( translations . en . interviewPrepEarly , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , 'sections-sparse-interview' ) ;
} ) ;
async function capture ( page : Page , info : TestInfo , name : string ) {
await page . evaluate ( ( ) = > document . fonts . ready ) ;
const path = info . outputPath ( ` ${ name } .png ` ) ;
await page . screenshot ( { path , fullPage : false , animations : 'disabled' } ) ;
await info . attach ( name , { path , contentType : 'image/png' } ) ;
if ( name . endsWith ( 'en-light' ) && page . viewportSize ( ) ? . width === 1440 && [ 'sections-documents-' , 'sections-correspondence-' , 'sections-notes-tasks-' , 'sections-interview-' ] . some ( prefix = > name . startsWith ( prefix ) ) ) {
const scan = await new AxeBuilder ( { page } ) . withTags ( [ 'wcag2a' , 'wcag2aa' , 'wcag21a' , 'wcag21aa' ] ) . analyze ( ) ;
await info . attach ( ` ${ name } -axe ` , { body : JSON.stringify ( { violations : scan.violations , incomplete : scan.incomplete } ) , contentType : 'application/json' } ) ;
expect ( scan . violations ) . toEqual ( [ ] ) ;
}
}
for ( const language of [ 'en' , 'nb' ] as const ) for ( const mode of [ 'light' , 'dark' ] as const ) {
test ( ` [PRES-W01– W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history ( ${ language } , ${ mode } ) ` , async ( { page , playwright } , info ) = > {
test . setTimeout ( 240 _000 ) ;
const t = translations [ language ] ;
const admin = await playwright . request . newContext ( ) ;
const adminHeaders = await login ( admin , 'e2e@example.test' ) ;
const email = ` job-sections- ${ language } - ${ mode } - ${ Date . now ( ) } @example.test ` ;
expect ( ( await admin . post ( ` ${ apiUrl } /users ` , { headers : adminHeaders , data : { email , password , roles : [ ] } } ) ) . status ( ) ) . toBe ( 200 ) ;
await admin . dispose ( ) ;
const headers = await login ( page . request , email ) ;
await page . route ( '**/*' , route = > [ 'localhost' , '127.0.0.1' ] . includes ( new URL ( route . request ( ) . url ( ) ) . hostname ) || [ 'blob:' , 'data:' , 'about:' ] . includes ( new URL ( route . request ( ) . url ( ) ) . protocol ) ? route . continue ( ) : route . abort ( ) ) ;
await page . addInitScript ( ( { language , mode } ) = > { localStorage . setItem ( 'uiLanguage' , language ) ; localStorage . setItem ( 'jobtracker.themeMode' , mode ) ; } , { language , mode } ) ;
expect ( ( await page . request . put ( ` ${ apiUrl } /auth/preferences/language ` , { headers , data : { language : language === 'nb' ? 'nb-NO' : 'en' } } ) ) . status ( ) ) . toBe ( 204 ) ;
expect ( ( await page . request . put ( ` ${ apiUrl } /career/profile ` , { headers , data : { profile : { version : '1' , contact : { fullName : 'Åse Eksempel' , headline : 'Recorded engineer' } , skills : [ 'C#' , 'SQL' ] } } } ) ) . status ( ) ) . toBe ( 200 ) ;
const company = await page . request . post ( ` ${ apiUrl } /companies ` , { headers , data : { name : 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } } ) ;
expect ( company . status ( ) ) . toBe ( 201 ) ; const companyId = ( await company . json ( ) ) . id ;
const title = 'Senior utvikler og rådgiver for langsiktig plattformutvikling' ;
const created = await page . request . post ( ` ${ apiUrl } /jobapplications ` , { headers , data : { companyId , jobTitle : title , status : 'Interview' , description : 'Build C# and SQL services.' , notes : 'Human notes — æøå' , deadline : '2026-12-14T12:00:00Z' , followUpAt : '2026-12-10T12:00:00Z' } } ) ;
expect ( created . status ( ) ) . toBe ( 201 ) ; const id = ( await created . json ( ) ) . id ;
const jobUrl = ` ${ apiUrl } /jobapplications/ ${ id } ` ;
const cv = await page . request . post ( ` ${ apiUrl } /cv/variants ` , { headers , data : { name : 'Application CV — Åse' , jobApplicationId : id } } ) ;
expect ( cv . status ( ) ) . toBe ( 200 ) ; const variant = await cv . json ( ) ;
expect ( ( await page . request . put ( ` ${ jobUrl } /cv ` , { headers , data : { variantId : variant.id } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . put ( ` ${ jobUrl } /cover-letter ` , { headers , data : { text : 'Reviewed letter — æøå' , source : 'manual' } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . put ( ` ${ jobUrl } /application-drafts ` , { headers , data : { applicationAnswerDraft : 'Reviewed answer — æøå' , recruiterMessageDraft : 'Message draft; never sent' } } ) ) . status ( ) ) . toBe ( 204 ) ;
expect ( ( await page . request . post ( ` ${ jobUrl } /checklist ` , { headers , data : { title : 'Review employer questions — æøå' , category : 'custom' } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` ${ jobUrl } /interview-prep ` , { headers , data : { title : 'Ask about sustainable delivery' , category : 'question' , content : 'My recorded question' , source : 'manual' } } ) ) . status ( ) ) . toBe ( 200 ) ;
expect ( ( await page . request . post ( ` ${ apiUrl } /correspondence ` , { headers , data : { jobApplicationId : id , from : 'Company' , subject : 'Interview invitation — æøå' , content : 'Recorded invitation; no inferred company facts' , channel : 'Email' } } ) ) . ok ( ) ) . toBe ( true ) ;
expect ( ( await page . request . post ( ` ${ apiUrl } /attachments ` , { headers , multipart : { jobId : String ( id ) , files : { name : 'Employment evidence — æøå.txt' , mimeType : 'text/plain' , buffer : Buffer.from ( 'Recorded evidence — æøå' ) } } } ) ) . ok ( ) ) . toBe ( true ) ;
await page . setViewportSize ( { width : 1440 , height : 900 } ) ;
const entry = async ( section : string , sub? : string ) = > {
await page . goto ( ` /jobs/ ${ id } ?section= ${ section } ${ sub ? ` &sub= ${ sub } ` : '' } &followMode=waiting-update ` ) ;
await expect ( page . getByRole ( 'heading' , { name : title , exact : true } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'tablist' , { name : t.workspaceSections } ) . getByRole ( 'tab' ) ) . toHaveCount ( 7 ) ;
expect ( new URL ( page . url ( ) ) . searchParams . get ( 'followMode' ) ) . toBe ( 'waiting-update' ) ;
await expect . poll ( ( ) = > page . evaluate ( ( ) = > document . documentElement . scrollWidth <= innerWidth + 1 ) ) . toBe ( true ) ;
} ;
await entry ( 'overview' ) ; await capture ( page , info , ` sections-overview- ${ language } - ${ mode } ` ) ;
await entry ( 'analysis' ) ; await expect ( page . getByRole ( 'heading' , { name : t.workspaceCvMatch , exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-match- ${ language } - ${ mode } ` ) ;
await entry ( 'cv' ) ; await expect ( page . getByRole ( 'combobox' , { name : new RegExp ( t . assetsAttachedCv ) } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'link' , { name : t.assetsEditPreviewExport } ) ) . toHaveAttribute ( 'href' , ` /career/builder/ ${ variant . id } ` ) ;
await expect ( page . getByRole ( 'button' , { name : t.assetsPackageCapture , exact : true } ) ) . toHaveCount ( 0 ) ;
await capture ( page , info , ` sections-current-cv- ${ language } - ${ mode } ` ) ;
await entry ( 'cover-letter' ) ; await expect ( page . getByRole ( 'textbox' , { name : t.jobDetailsCoverLetter , exact : true } ) ) . toHaveValue ( 'Reviewed letter — æøå' ) ;
await capture ( page , info , ` sections-letter- ${ language } - ${ mode } ` ) ;
await entry ( 'application' , 'answers' ) ;
const answer = page . getByRole ( 'textbox' , { name : t.assetsApplicationAnswer , exact : true } ) ;
await expect ( answer ) . toHaveValue ( 'Reviewed answer — æøå' ) ;
await answer . fill ( 'Edited answer, pending explicit save — æøå' ) ;
await page . getByRole ( 'tab' , { name : t.workspaceSubmission , exact : true } ) . click ( ) ;
await expect ( page . getByRole ( 'dialog' , { name : t.workspaceUnsavedTitle } ) ) . toBeVisible ( ) ;
await page . getByRole ( 'button' , { name : t.workspaceKeepEditing , exact : true } ) . click ( ) ;
await expect ( answer ) . toHaveValue ( 'Edited answer, pending explicit save — æøå' ) ;
const save = page . waitForResponse ( r = > r . url ( ) === ` ${ jobUrl } /application-drafts ` && r . request ( ) . method ( ) === 'PUT' ) ;
await page . getByRole ( 'button' , { name : t.assetsSaveDrafts , exact : true } ) . click ( ) ; expect ( ( await save ) . status ( ) ) . toBe ( 204 ) ;
await page . getByRole ( 'tab' , { name : t.workspaceSubmission , exact : true } ) . click ( ) ;
const freeze = page . waitForResponse ( r = > r . url ( ) === ` ${ jobUrl } /submitted-packages ` && r . request ( ) . method ( ) === 'POST' ) ;
await page . getByRole ( 'button' , { name : t.assetsPackageCapture , exact : true } ) . click ( ) ; expect ( ( await freeze ) . status ( ) ) . toBe ( 200 ) ;
await expect ( page . getByText ( t . assetsSubmittedPackageVersion . replace ( '{version}' , '1' ) , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-submission- ${ language } - ${ mode } ` ) ;
await page . getByRole ( 'button' , { name : t.assetsPackageView , exact : true } ) . click ( ) ;
await expect ( page . getByRole ( 'dialog' ) . getByText ( t . workspaceFrozenHelp , { exact : true } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'dialog' ) . getByText ( 'Reviewed letter — æøå' , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-frozen- ${ language } - ${ mode } ` ) ;
await page . keyboard . press ( 'Escape' ) ; await expect ( page . getByRole ( 'button' , { name : t.assetsPackageView , exact : true } ) ) . toBeFocused ( ) ;
await entry ( 'documents' ) ; await expect ( page . getByRole ( 'switch' , { name : ` ${ t . attachmentsAiUse } : Employment evidence — æøå.txt ` } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'columnheader' , { name : t.attachmentsUploaded , exact : true } ) ) . toBeVisible ( ) ;
await expect ( page . getByRole ( 'button' , { name : t.assetsPackageCapture , exact : true } ) ) . toHaveCount ( 0 ) ;
await capture ( page , info , ` sections-documents- ${ language } - ${ mode } ` ) ;
await entry ( 'timeline' ) ; await expect ( page . getByRole ( 'heading' , { name : t.intelligenceTimeline , exact : true } ) ) . toBeVisible ( ) ;
await expect ( page . locator ( 'time' ) . first ( ) ) . toBeVisible ( ) ; await capture ( page , info , ` sections-timeline- ${ language } - ${ mode } ` ) ;
await entry ( 'communication' ) ; await expect ( page . getByText ( 'Interview invitation — æøå' , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-correspondence- ${ language } - ${ mode } ` ) ;
await entry ( 'notes' ) ;
await expect ( page . getByRole ( 'region' , { name : t.workspaceNotes , exact : true } ) ) . toContainText ( 'Human notes — æøå' ) ;
await expect ( page . getByRole ( 'region' , { name : t.workspaceNotes , exact : true } ) ) . not . toContainText ( 'Edited answer' ) ;
const complete = page . waitForResponse ( r = > r . url ( ) . includes ( '/checklist/' ) && r . request ( ) . method ( ) === 'PATCH' ) ;
const task = page . getByRole ( 'checkbox' , { name : 'Review employer questions — æøå' , exact : true } ) ;
await task . click ( ) ; expect ( ( await complete ) . status ( ) ) . toBe ( 200 ) ;
await expect ( task ) . toBeChecked ( ) ; // Existing owner re-reads after the server write; no optimistic tick.
await capture ( page , info , ` sections-notes-tasks- ${ language } - ${ mode } ` ) ;
const notes = page . getByRole ( 'region' , { name : t.workspaceNotes , exact : true } ) ;
await notes . getByRole ( 'button' , { name : t.workspaceEdit , exact : true } ) . click ( ) ;
await page . getByRole ( 'dialog' ) . getByRole ( 'textbox' , { name : t.editJobNotes , exact : true } ) . fill ( 'Edited human notes — æøå' ) ;
const notesSave = page . waitForResponse ( r = > r . url ( ) === jobUrl && r . request ( ) . method ( ) === 'PUT' ) ;
await page . getByRole ( 'dialog' ) . getByRole ( 'button' , { name : t.save , exact : true } ) . click ( ) ; expect ( ( await notesSave ) . status ( ) ) . toBe ( 204 ) ;
await expect ( notes ) . toContainText ( 'Edited human notes — æøå' ) ;
expect ( ( await ( await page . request . get ( ` ${ jobUrl } /workspace ` ) ) . json ( ) ) . applicationAnswerDraft ) . toBe ( 'Edited answer, pending explicit save — æøå' ) ;
await entry ( 'interview' ) ; await expect ( page . getByText ( 'Ask about sustainable delivery' , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-interview- ${ language } - ${ mode } ` ) ;
for ( const [ width , height , section ] of [ [ 768 , 1024 , 'timeline' ] , [ 390 , 844 , 'communication' ] , [ 320 , 844 , 'documents' ] , [ 320 , 844 , 'application' ] ] as const ) {
await page . setViewportSize ( { width , height } ) ; await entry ( section , section === 'application' ? 'submission' : undefined ) ;
const selected = page . getByRole ( 'tablist' , { name : t.workspaceSections } ) . locator ( '[aria-selected=true]' ) ; await selected . focus ( ) ;
await expect ( selected ) . toBeFocused ( ) ;
await capture ( page , info , ` sections- ${ section } - ${ width } - ${ language } - ${ mode } ` ) ;
if ( section === 'documents' ) {
const files = page . getByRole ( 'region' , { name : t.attachmentsTitle.replace ( '{count}' , '1' ) , exact : true } ) ;
await files . focus ( ) ; await page . keyboard . press ( 'ArrowRight' ) ;
await expect . poll ( ( ) = > files . evaluate ( node = > node . scrollLeft ) ) . toBeGreaterThan ( 0 ) ;
await capture ( page , info , ` sections-document-table- ${ width } - ${ language } - ${ mode } ` ) ;
}
}
const scan = await new AxeBuilder ( { page } ) . withTags ( [ 'wcag2a' , 'wcag2aa' , 'wcag21a' , 'wcag21aa' ] ) . analyze ( ) ;
await info . attach ( ` sections-axe- ${ language } - ${ mode } ` , { body : JSON.stringify ( { violations : scan.violations , incomplete : scan.incomplete } ) , contentType : 'application/json' } ) ;
expect ( scan . violations ) . toEqual ( [ ] ) ;
const sparse = await page . request . post ( ` ${ apiUrl } /jobapplications ` , { headers , data : { companyId , jobTitle : 'Sparse sections' , status : 'Saved' } } ) ;
expect ( sparse . status ( ) ) . toBe ( 201 ) ;
await page . goto ( ` /jobs/ ${ ( await sparse . json ( ) ) . id } ?section=documents ` ) ;
await expect ( page . getByText ( t . attachmentsEmpty , { exact : true } ) ) . toBeVisible ( ) ;
await expect ( page . getByText ( t . assetsNoSubmittedPackages , { exact : true } ) ) . toBeVisible ( ) ;
await capture ( page , info , ` sections-sparse- ${ language } - ${ mode } ` ) ;
await info . attach ( ` sections-native-invariants- ${ language } - ${ mode } ` , { body : JSON.stringify ( { packageCapture : true , dirtyAnswersKeptAndExplicitlySaved : true , currentVsFrozenSeparated : true , personalNotesSeparate : true , checklistWriteSucceeded : true , legacyCvLetterCommunicationNotes : true , followModePreserved : true } ) , contentType : 'application/json' } ) ;
} ) ;
}