2026-08-14 13:43:37 +02:00
import { expect , test , type Locator , type Page } from '@playwright/test'
async function dragBy ( page : Page , locator : Locator , deltaX : number , deltaY : number ) {
const box = await locator . boundingBox ()
if ( ! box ) throw new Error ( 'Drag target is not visible' )
const start = { x : box.x + Math . min ( 30 , box . width / 3 ), y : box.y + Math . min ( 14 , box . height / 4 ) }
await page . mouse . move ( start . x , start . y )
await page . mouse . down ()
await page . mouse . move ( start . x + deltaX / 2 , start . y + deltaY / 2 , { steps : 3 })
await page . mouse . move ( start . x + deltaX , start . y + deltaY , { steps : 3 })
await page . mouse . up ()
}
async function boardPosition ( locator : Locator ) {
return locator . evaluate ( element => ({
left : Number.parseFloat (( element as HTMLElement ). style . left ),
top : Number.parseFloat (( element as HTMLElement ). style . top ),
}))
}
async function waitForSave ( page : Page , action : () => Promise < void >) {
const response = page . waitForResponse ( candidate => candidate . request (). method () === 'PUT' && candidate . url (). includes ( '/api/levels/' ) && candidate . ok ())
await action ()
await response
}
2026-08-14 17:59:03 +02:00
test ( 'move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence' , async ({ page }) => {
2026-08-14 13:43:37 +02:00
await page . goto ( '/?level=e2e-level&edit=1' )
await expect ( page . getByRole ( 'heading' , { name : 'Browser Safety Test' })). toBeVisible ()
2026-08-15 09:29:08 +02:00
await expect ( page . getByRole ( 'button' , { name : 'ADMIN' , exact : true })). toBeVisible ()
expect (( await page . locator ( '.menubar nav > button, .menubar nav > .admin-menu > button' ). allTextContents ()). map ( label => label . replace ( /\d+$/ , '' ))). toEqual ([ 'EVIDENCE' , 'CASE BRIEF' , 'TIMELINE' , 'HELP' , 'ADMIN' ])
2026-08-14 18:59:50 +02:00
const temporalLayering = await page . evaluate (() => ({
links : Number.parseInt ( getComputedStyle ( document . querySelector ( '.temporal-links' ) ! ). zIndex , 10 ),
timeline : Number.parseInt ( getComputedStyle ( document . querySelector ( '.timeline' ) ! ). zIndex , 10 ),
}))
expect ( temporalLayering . links ). toBeGreaterThan ( temporalLayering . timeline )
2026-08-15 09:13:30 +02:00
expect ( await page . locator ( '.timeline .year' ). first (). evaluate ( element => Number . parseFloat ( getComputedStyle ( element ). fontSize ))). toBeGreaterThanOrEqual ( 12 )
expect ( await page . locator ( '.timeline-label button' ). evaluate ( element => Number . parseFloat ( getComputedStyle ( element ). fontSize ))). toBeGreaterThanOrEqual ( 11 )
2026-08-14 17:57:00 +02:00
await expect ( page . locator ( '.documents-panel' )). toHaveClass ( /\bclosed\b/ )
2026-08-14 16:18:13 +02:00
await expect ( page . locator ( '.brief-panel' )). toBeVisible ()
await expect ( page . getByRole ( 'button' , { name : 'Case brief' , exact : true })). toContainText ( '2' )
2026-08-14 18:33:04 +02:00
await page . getByRole ( 'button' , { name : 'Minimize brief' , exact : true }). click ()
await expect ( page . locator ( '.brief-panel' )). toHaveClass ( /\bminimized\b/ )
await expect ( page . locator ( '.brief-panel > p' )). toBeHidden ()
await expect ( page . getByRole ( 'button' , { name : 'Close brief' , exact : true })). toBeVisible ()
await page . getByRole ( 'button' , { name : 'Restore brief' , exact : true }). click ()
await expect ( page . locator ( '.brief-panel' )). not . toHaveClass ( /\bminimized\b/ )
2026-08-14 16:18:13 +02:00
await page . getByRole ( 'button' , { name : 'BEGIN INVESTIGATION' , exact : true }). click ()
2026-08-14 13:43:37 +02:00
2026-08-15 09:34:53 +02:00
await page . getByRole ( 'button' , { name : 'EVIDENCE' , exact : true }). click ()
const documentRow = page . locator ( '[data-document-row-id="22222222-2222-4222-8222-222222222222"]' )
await documentRow . click ()
await expect ( documentRow ). toHaveClass ( /\bselected\b/ )
await expect ( page . locator ( '[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]' )). toHaveClass ( /\bdocument-located\b/ )
await expect ( page . locator ( '.document-locator-beam .document-locator-ray' )). toBeVisible ()
await documentRow . dblclick ()
await expect ( page . getByRole ( 'button' , { name : 'Close document' , exact : true })). toBeVisible ()
await page . getByRole ( 'button' , { name : 'Close document' , exact : true }). click ()
await page . getByRole ( 'button' , { name : 'Close documents' , exact : true }). click ()
2026-08-14 19:37:43 +02:00
page . once ( 'dialog' , dialog => dialog . accept ( 'Disposable working theory' ))
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'NEW NOTE' , exact : true }). click ())
const note = page . locator ( '.evidence-card.note' ). filter ({ hasText : 'Disposable working theory' })
const trash = page . locator ( '.board-trash' )
await expect ( note ). toBeVisible ()
const noteBox = await note . boundingBox (), trashBox = await trash . boundingBox ()
if ( ! noteBox || ! trashBox ) throw new Error ( 'Note or exhibit trash is not visible' )
const noteDragStart = { x : noteBox.x + noteBox . width / 2 , y : noteBox.y + noteBox . height / 2 }
const discardSave = page . waitForResponse ( candidate => candidate . request (). method () === 'PUT' && candidate . url (). includes ( '/api/levels/' ) && candidate . ok ())
await page . mouse . move ( noteDragStart . x , noteDragStart . y )
await page . mouse . down ()
await page . mouse . move ( trashBox . x + trashBox . width / 2 , trashBox . y + trashBox . height / 2 , { steps : 8 })
await expect ( trash ). toHaveClass ( /\bactive\b/ )
await page . mouse . up ()
await discardSave
await expect ( note ). toHaveCount ( 0 )
await page . reload ()
await expect ( page . locator ( '.evidence-card.note' )). toHaveCount ( 0 )
2026-08-14 14:09:24 +02:00
const folder = page . locator ( '[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]' )
2026-08-22 16:02:10 +02:00
const file = page . locator ( '[data-temporal-id="widget:22222222-2222-4222-8222-222222222222"]' )
2026-08-14 13:43:37 +02:00
const board = page . locator ( '.board' )
const boardViewport = page . locator ( '.board-viewport' )
const containmentBand = page . locator ( '.folder-bands line' )
await expect ( folder ). toBeVisible ()
await expect ( containmentBand ). toHaveClass ( /\bclosed\b/ )
const folderBefore = await boardPosition ( folder )
await waitForSave ( page , () => dragBy ( page , folder , 70 , 42 ))
const movedFolder = await boardPosition ( folder )
expect ( movedFolder . left ). toBeGreaterThan ( folderBefore . left )
expect ( movedFolder . top ). toBeGreaterThan ( folderBefore . top )
await page . reload ()
await expect ( folder ). toBeVisible ()
expect ( await boardPosition ( folder )). toEqual ( movedFolder )
2026-08-22 16:02:10 +02:00
await waitForSave ( page , () => folder . dblclick ())
2026-08-14 13:43:37 +02:00
await expect ( file ). toHaveClass ( /\bopen\b/ )
await expect ( containmentBand ). toHaveClass ( /\bopen\b/ )
const fileBefore = await boardPosition ( file )
await waitForSave ( page , () => dragBy ( page , file , 56 , 35 ))
const movedFile = await boardPosition ( file )
expect ( movedFile . left ). toBeGreaterThan ( fileBefore . left )
expect ( movedFile . top ). toBeGreaterThan ( fileBefore . top )
await page . reload ()
await expect ( file ). toHaveClass ( /\bopen\b/ )
expect ( await boardPosition ( file )). toEqual ( movedFile )
const folderBeforePan = await boardPosition ( folder )
const transformBeforePan = await board . getAttribute ( 'style' )
2026-08-14 17:59:03 +02:00
await expect ( page . getByRole ( 'button' , { name : 'MOVE' , exact : true })). toHaveClass ( /\bactive\b/ )
2026-08-14 13:43:37 +02:00
const viewportBox = await boardViewport . boundingBox ()
if ( ! viewportBox ) throw new Error ( 'Board viewport is not visible' )
await waitForSave ( page , async () => {
2026-08-14 17:59:03 +02:00
await boardViewport . dispatchEvent ( 'pointerdown' , { pointerId : 31 , pointerType : 'mouse' , isPrimary : true , button : 0 , clientX : viewportBox.x + 240 , clientY : viewportBox.y + 230 })
await boardViewport . dispatchEvent ( 'pointermove' , { pointerId : 31 , pointerType : 'mouse' , isPrimary : true , button : 0 , clientX : viewportBox.x + 290 , clientY : viewportBox.y + 195 })
await boardViewport . dispatchEvent ( 'pointerup' , { pointerId : 31 , pointerType : 'mouse' , isPrimary : true , button : 0 , clientX : viewportBox.x + 290 , clientY : viewportBox.y + 195 })
2026-08-14 13:43:37 +02:00
})
expect ( await boardPosition ( folder )). toEqual ( folderBeforePan )
const transformAfterPan = await board . getAttribute ( 'style' )
expect ( transformAfterPan ). not . toEqual ( transformBeforePan )
await page . reload ()
await expect ( folder ). toBeVisible ()
expect ( await board . getAttribute ( 'style' )). toEqual ( transformAfterPan )
2026-08-14 15:45:03 +02:00
const transformBeforeWheel = await board . getAttribute ( 'style' )
const browserMetricsBeforeWheel = await page . evaluate (() => ({ width : window.innerWidth , height : window.innerHeight , devicePixelRatio : window.devicePixelRatio }))
2026-08-14 16:44:43 +02:00
const folderAnchorBefore = await folder . boundingBox ()
if ( ! folderAnchorBefore ) throw new Error ( 'Folder is not visible for cursor-centered zoom' )
const wheelAnchor = { x : folderAnchorBefore.x + folderAnchorBefore . width / 2 , y : folderAnchorBefore.y + folderAnchorBefore . height / 2 }
await page . mouse . move ( wheelAnchor . x , wheelAnchor . y )
2026-08-14 15:45:03 +02:00
await page . mouse . wheel ( 0 , - 80 )
await expect . poll (() => board . getAttribute ( 'style' )). not . toEqual ( transformBeforeWheel )
2026-08-14 16:44:43 +02:00
const folderAnchorAfter = await folder . boundingBox ()
if ( ! folderAnchorAfter ) throw new Error ( 'Folder disappeared after zoom' )
expect ( folderAnchorAfter . x + folderAnchorAfter . width / 2 ). toBeCloseTo ( wheelAnchor . x , 0 )
expect ( folderAnchorAfter . y + folderAnchorAfter . height / 2 ). toBeCloseTo ( wheelAnchor . y , 0 )
2026-08-14 15:45:03 +02:00
expect ( await page . evaluate (() => ({ width : window.innerWidth , height : window.innerHeight , devicePixelRatio : window.devicePixelRatio }))). toEqual ( browserMetricsBeforeWheel )
const transformBeforeDesktopPinch = await board . getAttribute ( 'style' )
await page . keyboard . down ( 'Control' )
await page . mouse . wheel ( 0 , - 80 )
2026-08-14 13:43:37 +02:00
await page . keyboard . up ( 'Control' )
2026-08-14 15:45:03 +02:00
await page . waitForTimeout ( 100 )
expect ( await board . getAttribute ( 'style' )). toEqual ( transformBeforeDesktopPinch )
expect ( await page . evaluate (() => ({ width : window.innerWidth , height : window.innerHeight , devicePixelRatio : window.devicePixelRatio }))). toEqual ( browserMetricsBeforeWheel )
const transformBeforeTouchPinch = await board . getAttribute ( 'style' )
const touchOrigin = { x : viewportBox.x + 350 , y : viewportBox.y + 240 }
await boardViewport . dispatchEvent ( 'pointerdown' , { pointerId : 41 , pointerType : 'touch' , isPrimary : true , button : 0 , clientX : touchOrigin.x , clientY : touchOrigin.y })
await boardViewport . dispatchEvent ( 'pointerdown' , { pointerId : 42 , pointerType : 'touch' , isPrimary : false , button : 0 , clientX : touchOrigin.x + 100 , clientY : touchOrigin.y })
await boardViewport . dispatchEvent ( 'pointermove' , { pointerId : 42 , pointerType : 'touch' , isPrimary : false , button : 0 , clientX : touchOrigin.x + 150 , clientY : touchOrigin.y })
await expect . poll (() => board . getAttribute ( 'style' )). not . toEqual ( transformBeforeTouchPinch )
await boardViewport . dispatchEvent ( 'pointerup' , { pointerId : 42 , pointerType : 'touch' , isPrimary : false , button : 0 , clientX : touchOrigin.x + 150 , clientY : touchOrigin.y })
await boardViewport . dispatchEvent ( 'pointerup' , { pointerId : 41 , pointerType : 'touch' , isPrimary : true , button : 0 , clientX : touchOrigin.x , clientY : touchOrigin.y })
2026-08-14 14:17:54 +02:00
2026-08-14 16:18:13 +02:00
await page . getByRole ( 'button' , { name : 'Case brief' , exact : true }). click ()
2026-08-14 14:44:58 +02:00
await expect ( page . locator ( '.brief-panel' )). toContainText ( 'Ada Lovelace' )
const personConcept = page . locator ( '.brief-concepts section' ). filter ({ hasText : 'Ada Lovelace' })
2026-08-14 16:18:13 +02:00
await waitForSave ( page , () => personConcept . getByRole ( 'button' , { name : 'PERSON' , exact : true }). click ())
await expect ( page . locator ( '.brief-panel' )). toBeVisible ()
await expect ( page . locator ( '.evidence-card.party.arriving' )). toContainText ( 'Ada Lovelace' )
await personConcept . getByRole ( 'button' , { name : 'EDIT DOSSIER' , exact : true }). click ()
2026-08-14 14:44:58 +02:00
await expect ( page . getByText ( 'Edit person dossier' )). toBeVisible ()
await page . getByLabel ( 'Party aliases' ). fill ( 'A. A. L.' )
await page . locator ( '.party-editor .folder-members input[type="checkbox"]' ). first (). check ()
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE DOSSIER' , exact : true }). click ())
await expect ( page . locator ( '.evidence-card.party' )). toContainText ( 'Ada Lovelace' )
const organizationConcept = page . locator ( '.brief-concepts section' ). filter ({ hasText : 'Difference Engine Bureau' })
2026-08-14 16:18:13 +02:00
await waitForSave ( page , () => organizationConcept . getByRole ( 'button' , { name : 'ORGANIZATION' , exact : true }). click ())
await expect ( page . locator ( '.brief-panel' )). toBeVisible ()
await organizationConcept . getByRole ( 'button' , { name : 'EDIT DOSSIER' , exact : true }). click ()
2026-08-14 14:44:58 +02:00
await page . getByLabel ( 'Organization type' ). selectOption ( 'public_body' )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE DOSSIER' , exact : true }). click ())
2026-08-14 18:17:31 +02:00
await page . getByRole ( 'button' , { name : 'CREATE PARTY NOT LISTED ABOVE' , exact : true }). click ()
await expect ( page . getByText ( 'Create party dossier' , { exact : true })). toBeVisible ()
await page . getByLabel ( 'Party type' ). selectOption ( 'person' )
await page . getByLabel ( 'Party name' ). fill ( 'Mara Elise Voss' )
await page . getByLabel ( 'Party summary' ). fill ( 'Identified by the investigator outside the supplied concept list.' )
await page . locator ( '.party-editor .folder-members input[type="checkbox"]' ). first (). check ()
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE DOSSIER' , exact : true }). click ())
await expect ( page . locator ( '.brief-panel' )). toBeVisible ()
2026-08-14 16:18:13 +02:00
await page . getByRole ( 'button' , { name : 'RETURN TO BOARD' , exact : true }). click ()
2026-08-14 14:44:58 +02:00
await page . reload ()
2026-08-14 16:18:13 +02:00
await expect ( page . locator ( '.brief-panel' )). toBeHidden ()
2026-08-14 18:17:31 +02:00
await expect ( page . locator ( '.evidence-card.party' )). toHaveCount ( 3 )
await expect ( page . locator ( '.evidence-card.party' )). toContainText ([ 'Ada Lovelace' , 'Difference Engine Bureau' , 'Mara Elise Voss' ])
2026-08-14 14:44:58 +02:00
2026-08-22 19:49:44 +02:00
await file . dblclick ()
await page . getByRole ( 'button' ,{ name : 'TYPE' , exact :true }). click ()
await waitForSave ( page ,() => page . getByRole ( 'menuitemradio' ,{ name : /Mugshot/ }). click ())
await expect ( file . locator ( '.mugshot-caption' )). toHaveText ( '' )
await page . getByRole ( 'button' ,{ name : 'Close document' , exact :true }). click ()
const adaParty = page . locator ( '.evidence-card.party' ). filter ({ hasText : 'Ada Lovelace' })
await adaParty . click ()
await page . getByRole ( 'button' ,{ name : 'Red thread' }). click ()
await file . click ()
await expect ( page . getByLabel ( 'Thread tag' )). toHaveValue ( 'Identified as…' )
await waitForSave ( page ,() => page . getByRole ( 'button' ,{ name : 'ADD TAG & TIGHTEN' }). click ())
await expect ( file ). toHaveAttribute ( 'data-identified-party-id' , /\S+/ )
await expect ( file . locator ( '.mugshot-caption' )). toContainText ( 'Ada Lovelace' )
await page . reload ()
await expect ( file . locator ( '.mugshot-caption' )). toContainText ( 'Ada Lovelace' )
await adaParty . getByRole ( 'button' ,{ name : 'EDIT DOSSIER' , exact :true }). click ()
await page . getByLabel ( 'Party name' ). fill ( 'Ada Byron Lovelace' )
await waitForSave ( page ,() => page . getByRole ( 'button' ,{ name : 'SAVE DOSSIER' , exact :true }). click ())
await expect ( file . locator ( '.mugshot-caption' )). toContainText ( 'Ada Byron Lovelace' )
2026-08-14 14:31:52 +02:00
await page . getByRole ( 'button' , { name : 'NEW EVENT' , exact : true }). click ()
await expect ( page . getByText ( 'Edit reconstructed event' )). toBeVisible ()
await page . getByLabel ( 'Event title' ). fill ( 'The browser clue was connected' )
await page . getByLabel ( 'Event narrative' ). fill ( 'The investigator connected the folder to a dated source.' )
await page . locator ( '.event-support-list input[type="checkbox"]' ). first (). check ()
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE EVENT' , exact : true }). click ())
await page . reload ()
await expect ( page . locator ( '.evidence-card.event' )). toContainText ( 'The browser clue was connected' )
2026-08-14 19:57:22 +02:00
await expect ( page . locator ( '.evidence-card.event' )). toContainText ( 'UNDATED' )
2026-08-14 14:31:52 +02:00
await expect ( page . locator ( '.story-strip' )). toContainText ( 'The investigator connected the folder' )
2026-08-14 19:57:22 +02:00
await expect ( page . locator ( '.story-strip' )). toContainText ( 'UNDATED' )
await expect ( page . locator ( '.timeline .marker' )). toHaveCount ( 1 )
2026-08-14 14:31:52 +02:00
await expect ( page . locator ( '.event-support-lines line' )). toHaveCount ( 1 )
2026-08-14 14:17:54 +02:00
page . once ( 'dialog' , dialog => dialog . accept ( 'Browser Template' ))
const templateSaved = page . waitForResponse ( candidate => candidate . request (). method () === 'POST' && candidate . url (). includes ( '/templates?edit=1' ) && candidate . ok ())
2026-08-15 09:29:08 +02:00
await page . getByRole ( 'button' , { name : 'ADMIN' , exact : true }). click ()
await page . getByRole ( 'menuitem' , { name : 'SAVE AS TEMPLATE' , exact : true }). click ()
2026-08-14 14:17:54 +02:00
await templateSaved
await expect ( page . locator ( '.terminal-status' )). toContainText ( 'VERSION 1' )
let promptIndex = 0
const prompts = [ 'browser-template' , 'Browser Template Clone' ]
const promptHandler = ( dialog : { accept ( promptText? : string ) : Promise < void > }) => dialog . accept ( prompts [ promptIndex ++ ])
page . on ( 'dialog' , promptHandler )
2026-08-15 09:29:08 +02:00
await page . getByRole ( 'button' , { name : 'ADMIN' , exact : true }). click ()
await page . getByRole ( 'menuitem' , { name : 'NEW FROM TEMPLATE' , exact : true }). click ()
2026-08-14 14:17:54 +02:00
await page . waitForURL ( url => url . searchParams . get ( 'level' ) ? . startsWith ( 'browser-template-' ) === true )
page . off ( 'dialog' , promptHandler )
await expect ( page . getByRole ( 'heading' , { name : 'Browser Template Clone' })). toBeVisible ()
await expect ( page . getByRole ( 'button' , { name : 'Reset' })). toBeEnabled ()
2026-08-14 18:35:54 +02:00
await page . setViewportSize ({ width : 390 , height : 844 })
const portraitToolbar = await page . locator ( '.board-actions' ). boundingBox ()
if ( ! portraitToolbar ) throw new Error ( 'Portrait toolbar is not visible' )
expect ( portraitToolbar . height ). toBeGreaterThan ( portraitToolbar . width * 2 )
expect ( portraitToolbar . x ). toBeGreaterThan ( 280 )
const portraitMove = await page . getByRole ( 'button' , { name : 'MOVE' , exact : true }). boundingBox ()
const portraitParty = await page . getByRole ( 'button' , { name : 'NEW PARTY' , exact : true }). boundingBox ()
expect ( portraitMove ). not . toBeNull ()
expect ( portraitParty ). not . toBeNull ()
expect ( portraitParty ! . y ). toBeGreaterThan ( portraitMove ! . y )
await page . setViewportSize ({ width : 844 , height : 390 })
const landscapeToolbar = await page . locator ( '.board-actions' ). boundingBox ()
if ( ! landscapeToolbar ) throw new Error ( 'Landscape toolbar is not visible' )
expect ( landscapeToolbar . width ). toBeGreaterThan ( landscapeToolbar . height * 2 )
2026-08-14 13:43:37 +02:00
})
2026-08-15 09:29:08 +02:00
test ( 'hides the admin menu without a verified admin JWT' , async ({ browser }) => {
const context = await browser . newContext ({ baseURL : 'http://127.0.0.1:18788' , viewport : { width : 1280 , height : 720 }, extraHTTPHeaders : { Cookie : 'anonymous_session=1' } })
const page = await context . newPage ()
await page . goto ( '/?level=e2e-level&edit=1' )
await expect ( page . getByRole ( 'heading' , { name : 'Browser Safety Test' })). toBeVisible ()
await expect ( page . getByRole ( 'button' , { name : 'ADMIN' , exact : true })). toHaveCount ( 0 )
expect (( await page . locator ( '.menubar nav > button' ). allTextContents ()). map ( label => label . replace ( /\d+$/ , '' ))). toEqual ([ 'EVIDENCE' , 'CASE BRIEF' , 'TIMELINE' , 'HELP' ])
await context . close ()
})