Files
gupi-osint-board/e2e/mystery.acceptance.spec.ts
T

185 lines
13 KiB
TypeScript
Raw Normal View History

2026-08-14 15:02:00 +02:00
import { expect, test, type Page } from '@playwright/test'
2026-08-14 18:26:20 +02:00
async function dragBy(page: Page, locator: ReturnType<Page['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 + box.width / 2, y: box.y + Math.min(18, box.height / 4) }
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 8 })
await page.mouse.up()
}
2026-08-14 15:02:00 +02:00
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
}
async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) {
if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click()
2026-08-14 15:02:00 +02:00
const concept = page.locator('.brief-concepts section').filter({ hasText: name })
await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click())
await expect(page.locator('.brief-panel')).toBeVisible()
await expect(concept).toHaveClass(/\bjust-resolved\b/)
await concept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
2026-08-14 15:02:00 +02:00
await page.getByLabel('Party summary').fill(summary)
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
}
async function createEvent(page: Page, title: string, narrative: string, occurredAt: string, sources: string[]) {
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
await page.getByLabel('Event title').fill(title)
await page.getByLabel('Event narrative').fill(narrative)
await page.getByLabel('Occurred at').fill(occurredAt)
for (const source of sources) {
await page.locator('.event-support-list .folder-member').filter({ hasText: source }).locator('input[type="checkbox"]').check()
}
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
}
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
2026-08-14 17:57:00 +02:00
test.setTimeout(90_000)
await expect.poll(async () => {
const seededLevels = await (await request.get('/api/levels')).json() as { id: string }[]
return seededLevels.some(level => level.id.startsWith('glass-harbor-case-'))
}).toBe(true)
2026-08-14 15:02:00 +02:00
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
await page.goto(`/?level=${playable.id}`)
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
2026-08-14 17:57:00 +02:00
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
await page.locator('.open-files').click()
await page.getByRole('searchbox', { name: 'Search inside documents', exact: true }).fill('Elias')
await expect(page.locator('.doc-row')).toHaveCount(1)
await expect(page.locator('.doc-row')).toContainText('Carrier Dispatch Manifest · H&F 14')
await page.getByRole('button', { name: 'Clear document search', exact: true }).click()
await expect(page.locator('.doc-row')).toHaveCount(8)
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
2026-08-14 15:02:00 +02:00
await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
await expect(page.locator('.timeline .marker')).toHaveCount(8)
2026-08-14 15:40:54 +02:00
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox()
const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox()
expect(dispatchMarker).not.toBeNull()
expect(auctionMarker).not.toBeNull()
expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100)
await page.getByRole('button', { name: 'TIMELINE', exact: true }).click()
await page.getByLabel('Timeline start date').fill('1987-10-10')
await page.getByLabel('Timeline end date').fill('1987-10-26')
await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click())
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26')
2026-08-14 15:02:00 +02:00
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
await classify(page, 'Greyhaven Preservation Society', 'ORGANIZATION', 'Commissioned the North Quay restoration and owned the missing lens.')
await classify(page, 'Calder Optical Works', 'ORGANIZATION', 'Supplier that released sealed crate CO-771 to the approved carrier.')
await classify(page, 'Harbor & Fell Logistics', 'ORGANIZATION', 'Approved carrier whose vehicle delivered the crate to the wrong address.')
await classify(page, 'Voss Antiquities Ltd', 'ORGANIZATION', 'Mara Vosss company, based at Warehouse 3 and named as auction consignor.')
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
2026-08-14 15:02:00 +02:00
await createEvent(page, 'CO-771 left Calder for North Quay', 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.', '1987-10-16T16:40', ['Carrier Dispatch Manifest'])
await createEvent(page, 'CO-771 was diverted to Warehouse 3', 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.', '1987-10-17T22:08', ['Old Glass Harbor Gate Ledger', 'Warehouse 3 Security Photograph', 'Delivery Redirection Memorandum'])
await createEvent(page, 'Voss Antiquities offered the lens for sale', 'The auction description and seller reference identify the missing assembly and the company that stood to profit.', '1987-10-24T12:00', ['Company Register Extract', 'Meridian Maritime Auction'])
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
2026-08-14 16:37:50 +02:00
const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) })
2026-08-14 15:02:00 +02:00
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
2026-08-14 16:37:50 +02:00
await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click())
2026-08-22 16:02:10 +02:00
await waitForSave(page, () => movement.dblclick())
2026-08-14 16:37:50 +02:00
const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' })
2026-08-14 18:30:31 +02:00
await expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS')
await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0)
const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })
await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14'))
2026-08-14 16:37:50 +02:00
await elias.click()
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
const boardBox = await page.locator('.board-viewport').boundingBox()
if (!boardBox) throw new Error('Board is not visible')
await page.mouse.move(boardBox.x + boardBox.width / 2, boardBox.y + boardBox.height / 2)
await expect(page.locator('.connections .thread-preview path')).toBeVisible()
const manifestBox = await manifest.boundingBox()
if (!manifestBox) throw new Error('Carrier Dispatch Manifest is not visible')
await page.mouse.move(manifestBox.x + manifestBox.width / 2, manifestBox.y + manifestBox.height / 2)
await manifest.click()
const threadEditor = page.locator('.thread-editor')
await expect(threadEditor.getByText('Elias Vale', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Carrier Dispatch Manifest · H&F 14', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proof Elias is the driver')
await page.getByRole('slider', { name: 'Thread tightness', exact: true }).fill('85')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
await expect(page.locator('.connections g.tightening path')).toBeVisible()
2026-08-14 15:02:00 +02:00
const procurement = page.locator('.evidence-card.folder').filter({ hasText: 'PROCUREMENT & OWNERSHIP' })
2026-08-22 16:02:10 +02:00
await waitForSave(page, () => procurement.dblclick())
const companyRegister = page.locator('.source-file-widget.open').filter({ hasText: 'Company Register Extract' })
const memorandum = page.locator('.source-file-widget.open').filter({ hasText: 'Delivery Redirection Memorandum' })
const photograph = page.locator('.source-file-widget.open').filter({ hasText: 'Warehouse 3 Security Photograph' })
await companyRegister.click()
await expect(companyRegister).toHaveClass(/\bselected\b/)
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
await expect(companyRegister).toHaveClass(/\blinking\b/)
await memorandum.click()
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Delivery Redirection Memorandum', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
2026-08-22 16:02:10 +02:00
await companyRegister.click()
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
await photograph.click()
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
await expect(threadEditor.getByText('Warehouse 3 Security Photograph', { exact: true })).toBeVisible()
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
2026-08-14 15:02:00 +02:00
await page.reload()
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
await expect(page.locator('.evidence-card.event')).toHaveCount(3)
await expect(page.locator('.evidence-card.note')).toHaveCount(1)
2026-08-14 18:30:31 +02:00
await expect(page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14')
2026-08-14 15:02:00 +02:00
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
await expect(page.locator('.connections path')).toHaveCount(3)
const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' })
await expect(relationTag).toContainText('Proof Elias is the driver')
2026-08-14 18:26:20 +02:00
const tagBefore = await relationTag.boundingBox()
await waitForSave(page, () => dragBy(page, relationTag, 75, -18))
const tagAfter = await relationTag.boundingBox()
expect(tagBefore).not.toBeNull()
expect(tagAfter).not.toBeNull()
expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20)
await relationTag.click()
await expect(relationTag).toHaveClass(/\bexpanded\b/)
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
2026-08-14 18:26:20 +02:00
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
await page.locator('.tag-style-picker input[value="compact"]').check()
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE THREAD', exact: true }).click())
await expect(relationTag).toHaveClass(/\bcompact\b/)
await expect(relationTag).not.toHaveClass(/\bluggage-tag\b/)
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
2026-08-14 16:37:50 +02:00
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
2026-08-14 18:26:20 +02:00
const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver')
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) }))
expect(eliasThread.tagPosition).not.toBe(50)
expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19)
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
2026-08-14 15:02:00 +02:00
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
const authoringLevels = levels.filter(level => level.id.startsWith('glass-harbor-authoring-'))
expect(authoringLevels).toHaveLength(1)
const untouched = await (await request.get(`/api/levels/${authoringLevels[0].id}?edit=1`)).json()
2026-08-22 16:02:10 +02:00
expect(untouched.exhibits.filter((item: { type: string }) => item.type === 'party')).toHaveLength(0)
expect(untouched.exhibits.filter((item: { type: string }) => item.type === 'event')).toHaveLength(0)
2026-08-14 15:02:00 +02:00
expect(untouched.brief.concepts.every((concept: { resolvedPartyExhibitId?: string }) => !concept.resolvedPartyExhibitId)).toBe(true)
})