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) { const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok()) await action() await response } test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => { await page.goto('/?level=e2e-level&edit=1') await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() 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) await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/) await expect(page.locator('.brief-panel')).toBeVisible() await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2') 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/) await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click() 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) const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]') const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]') 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) await waitForSave(page, () => folder.getByRole('button', { name: 'OPEN', exact: true }).click()) 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') await expect(page.getByRole('button', { name: 'MOVE', exact: true })).toHaveClass(/\bactive\b/) const viewportBox = await boardViewport.boundingBox() if (!viewportBox) throw new Error('Board viewport is not visible') await waitForSave(page, async () => { 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 }) }) 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) const transformBeforeWheel = await board.getAttribute('style') const browserMetricsBeforeWheel = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio })) 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) await page.mouse.wheel(0, -80) await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel) 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) 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) await page.keyboard.up('Control') 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 }) await page.getByRole('button', { name: 'Case brief', exact: true }).click() await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' }) 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() 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' }) 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() await page.getByLabel('Organization type').selectOption('public_body') await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) 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() await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click() await page.reload() await expect(page.locator('.brief-panel')).toBeHidden() 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']) 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') await expect(page.locator('.evidence-card.event')).toContainText('UNDATED') await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder') await expect(page.locator('.story-strip')).toContainText('UNDATED') await expect(page.locator('.timeline .marker')).toHaveCount(1) await expect(page.locator('.event-support-lines line')).toHaveCount(1) 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()) await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click() 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 }) => dialog.accept(prompts[promptIndex++]) page.on('dialog', promptHandler) await page.getByRole('button', { name: 'NEW FROM TEMPLATE', exact: true }).click() 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() 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) })