test: complete persistence and browser safety net
This commit is contained in:
@@ -3,3 +3,5 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ npm run test:integration
|
|||||||
|
|
||||||
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
|
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
|
||||||
|
|
||||||
|
Run the isolated Chromium smoke test after installing its browser runtime once with `npx playwright install chromium`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, board-only pinch zoom, folder expansion, file movement, and reload persistence, then removes the test database.
|
||||||
|
|
||||||
## Data and API
|
## Data and API
|
||||||
|
|
||||||
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
||||||
|
|||||||
+4
-4
@@ -20,11 +20,11 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
|||||||
|
|
||||||
- [x] Test screen/board coordinate conversion across pan and zoom levels.
|
- [x] Test screen/board coordinate conversion across pan and zoom levels.
|
||||||
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
||||||
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
|
- [x] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
|
||||||
- [x] Test folder open/close behavior and retained file positions; add visual coverage for containment bands with the browser smoke test.
|
- [x] Test folder open/close behavior, retained file positions, and containment-band state.
|
||||||
- [ ] Test document upload, metadata persistence, board save/reload, and reset.
|
- [x] Test document upload, metadata persistence, board save/reload, and reset.
|
||||||
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
||||||
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence.
|
- [x] Add one browser smoke test: open an isolated level, drag an exhibit, pan, board-zoom, open a folder, move a file, reload, and verify persistence.
|
||||||
|
|
||||||
## Milestone 2: exhibit-schema cutover
|
## Milestone 2: exhibit-schema cutover
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
test('move, folder expansion, hand pan, pinch zoom, and reload persistence', async ({ page }) => {
|
||||||
|
await page.goto('/?level=e2e-level&edit=1')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||||
|
|
||||||
|
const folder = page.locator('[data-temporal-id="widget:e2e-folder"]')
|
||||||
|
const file = page.locator('[data-temporal-id="file:e2e-membership"]')
|
||||||
|
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 page.getByRole('button', { name: 'HAND', exact: true }).click()
|
||||||
|
const viewportBox = await boardViewport.boundingBox()
|
||||||
|
if (!viewportBox) throw new Error('Board viewport is not visible')
|
||||||
|
await waitForSave(page, async () => {
|
||||||
|
await page.mouse.move(viewportBox.x + 240, viewportBox.y + 230)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(viewportBox.x + 290, viewportBox.y + 195, { steps: 5 })
|
||||||
|
await page.mouse.up()
|
||||||
|
})
|
||||||
|
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 transformBeforePinch = await board.getAttribute('style')
|
||||||
|
const browserMetricsBeforePinch = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))
|
||||||
|
await page.keyboard.down('Control')
|
||||||
|
await page.mouse.move(viewportBox.x + 400, viewportBox.y + 260)
|
||||||
|
await page.mouse.wheel(0, -20)
|
||||||
|
await page.keyboard.up('Control')
|
||||||
|
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
|
||||||
|
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch)
|
||||||
|
})
|
||||||
Generated
+120
-102
@@ -12,13 +12,14 @@
|
|||||||
"dotenv": "16.5.0",
|
"dotenv": "16.5.0",
|
||||||
"express": "5.1.0",
|
"express": "5.1.0",
|
||||||
"lucide-react": "0.468.0",
|
"lucide-react": "0.468.0",
|
||||||
"multer": "2.0.2",
|
"multer": "^2.2.0",
|
||||||
"pg": "8.16.3",
|
"pg": "8.16.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tsx": "4.20.3"
|
"tsx": "4.20.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/cors": "2.8.18",
|
"@types/cors": "2.8.18",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.3",
|
||||||
"@types/multer": "2.0.0",
|
"@types/multer": "2.0.0",
|
||||||
@@ -29,8 +30,8 @@
|
|||||||
"@vitejs/plugin-react": "4.5.2",
|
"@vitejs/plugin-react": "4.5.2",
|
||||||
"concurrently": "9.1.2",
|
"concurrently": "9.1.2",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.5",
|
"vite": "^6.4.3",
|
||||||
"vitest": "3.2.3"
|
"vitest": "^3.2.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -801,6 +802,22 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.11",
|
"version": "1.0.0-beta.11",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
||||||
@@ -1400,15 +1417,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||||
"integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==",
|
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "3.2.3",
|
"@vitest/spy": "3.2.7",
|
||||||
"@vitest/utils": "3.2.3",
|
"@vitest/utils": "3.2.7",
|
||||||
"chai": "^5.2.0",
|
"chai": "^5.2.0",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -1417,13 +1434,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||||
"integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==",
|
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "3.2.3",
|
"@vitest/spy": "3.2.7",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.17"
|
"magic-string": "^0.30.17"
|
||||||
},
|
},
|
||||||
@@ -1457,13 +1474,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||||
"integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==",
|
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "3.2.3",
|
"@vitest/utils": "3.2.7",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"strip-literal": "^3.0.0"
|
"strip-literal": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -1472,13 +1489,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||||
"integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==",
|
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "3.2.3",
|
"@vitest/pretty-format": "3.2.7",
|
||||||
"magic-string": "^0.30.17",
|
"magic-string": "^0.30.17",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -1486,23 +1503,10 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
|
|
||||||
"version": "3.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
|
|
||||||
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tinyrainbow": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||||
"integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==",
|
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1513,27 +1517,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||||
"integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==",
|
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vitest/pretty-format": "3.2.3",
|
|
||||||
"loupe": "^3.1.3",
|
|
||||||
"tinyrainbow": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/vitest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
|
|
||||||
"version": "3.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
|
|
||||||
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.7",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^2.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -2618,27 +2609,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp": {
|
|
||||||
"version": "0.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": "^1.2.6"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"mkdirp": "bin/cmd.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2646,21 +2616,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.6",
|
"type-is": "^1.6.18"
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.18",
|
|
||||||
"xtend": "^4.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.16.0"
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/multer/node_modules/media-typer": {
|
"node_modules/multer/node_modules/media-typer": {
|
||||||
@@ -2931,6 +2902,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
@@ -3720,9 +3738,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3795,9 +3813,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-node": {
|
"node_modules/vite-node": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
"integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==",
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3818,20 +3836,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||||
"integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==",
|
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.3",
|
"@vitest/expect": "3.2.7",
|
||||||
"@vitest/mocker": "3.2.3",
|
"@vitest/mocker": "3.2.7",
|
||||||
"@vitest/pretty-format": "^3.2.3",
|
"@vitest/pretty-format": "^3.2.7",
|
||||||
"@vitest/runner": "3.2.3",
|
"@vitest/runner": "3.2.7",
|
||||||
"@vitest/snapshot": "3.2.3",
|
"@vitest/snapshot": "3.2.7",
|
||||||
"@vitest/spy": "3.2.3",
|
"@vitest/spy": "3.2.7",
|
||||||
"@vitest/utils": "3.2.3",
|
"@vitest/utils": "3.2.7",
|
||||||
"chai": "^5.2.0",
|
"chai": "^5.2.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"expect-type": "^1.2.1",
|
"expect-type": "^1.2.1",
|
||||||
@@ -3842,10 +3860,10 @@
|
|||||||
"tinybench": "^2.9.0",
|
"tinybench": "^2.9.0",
|
||||||
"tinyexec": "^0.3.2",
|
"tinyexec": "^0.3.2",
|
||||||
"tinyglobby": "^0.2.14",
|
"tinyglobby": "^0.2.14",
|
||||||
"tinypool": "^1.1.0",
|
"tinypool": "^1.1.1",
|
||||||
"tinyrainbow": "^2.0.0",
|
"tinyrainbow": "^2.0.0",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
"vite-node": "3.2.3",
|
"vite-node": "3.2.4",
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3861,8 +3879,8 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
"@vitest/browser": "3.2.3",
|
"@vitest/browser": "3.2.7",
|
||||||
"@vitest/ui": "3.2.3",
|
"@vitest/ui": "3.2.7",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*"
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-7
@@ -8,37 +8,40 @@
|
|||||||
"dev:web": "vite",
|
"dev:web": "vite",
|
||||||
"dev:server": "tsx watch server/index.ts",
|
"dev:server": "tsx watch server/index.ts",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"e2e:serve": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} tsx server/e2eHarness.ts",
|
||||||
"start": "NODE_ENV=production tsx server/index.ts",
|
"start": "NODE_ENV=production tsx server/index.ts",
|
||||||
"migrate:up": "tsx server/migrate.ts",
|
"migrate:up": "tsx server/migrate.ts",
|
||||||
"test": "vitest run --exclude '**/*.integration.test.ts'",
|
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
|
||||||
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts"
|
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
|
||||||
|
"test:e2e": "npm run build && playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.5.0",
|
"dotenv": "16.5.0",
|
||||||
"express": "5.1.0",
|
"express": "5.1.0",
|
||||||
"lucide-react": "0.468.0",
|
"lucide-react": "0.468.0",
|
||||||
"multer": "2.0.2",
|
"multer": "^2.2.0",
|
||||||
"pg": "8.16.3",
|
"pg": "8.16.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tsx": "4.20.3"
|
"tsx": "4.20.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/cors": "2.8.18",
|
"@types/cors": "2.8.18",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.3",
|
||||||
"@types/node": "22.15.30",
|
|
||||||
"@types/multer": "2.0.0",
|
"@types/multer": "2.0.0",
|
||||||
|
"@types/node": "22.15.30",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.4",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"@vitejs/plugin-react": "4.5.2",
|
"@vitejs/plugin-react": "4.5.2",
|
||||||
"concurrently": "9.1.2",
|
"concurrently": "9.1.2",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.5",
|
"vite": "^6.4.3",
|
||||||
"vitest": "3.2.3"
|
"vitest": "^3.2.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": "^20.0.0 || >=22.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
const port = 18788
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run e2e:serve',
|
||||||
|
url: `http://127.0.0.1:${port}/api/health`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -54,12 +54,11 @@ suite('level persistence API', () => {
|
|||||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||||
if (appPool) await appPool.end()
|
if (appPool) await appPool.end()
|
||||||
if (!adminClient) return
|
if (!adminClient) return
|
||||||
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
|
|
||||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||||
await adminClient.end()
|
await adminClient.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists authored viewport, exhibits, and relation positions', async () => {
|
it('persists authoring, uploads, player state, and reset behavior', async () => {
|
||||||
const createResponse = await fetch(`${baseUrl}/api/levels`, {
|
const createResponse = await fetch(`${baseUrl}/api/levels`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -83,5 +82,51 @@ suite('level persistence API', () => {
|
|||||||
expect(loaded.viewport).toEqual(state.viewport)
|
expect(loaded.viewport).toEqual(state.viewport)
|
||||||
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
|
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
|
||||||
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
|
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
|
||||||
|
|
||||||
|
const upload = new FormData()
|
||||||
|
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
||||||
|
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
||||||
|
expect(uploadResponse.status).toBe(201)
|
||||||
|
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
|
||||||
|
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'file' })
|
||||||
|
expect(uploaded.assetId).toBeTruthy()
|
||||||
|
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
|
||||||
|
|
||||||
|
const withUpload = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
|
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
|
||||||
|
uploadedDocument.title = 'Renamed smoke evidence'
|
||||||
|
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
|
||||||
|
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
|
||||||
|
const metadataSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(withUpload),
|
||||||
|
})
|
||||||
|
expect(metadataSave.ok).toBe(true)
|
||||||
|
const afterMetadataSave = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
|
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
|
||||||
|
title: 'Renamed smoke evidence',
|
||||||
|
publishedAt: '2022-06-15T10:30:00.000Z',
|
||||||
|
metadata: { witness: 'Integration test', confidence: 'high' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
playerState.viewport = { x: -150, y: 88, zoom: 1.1 }
|
||||||
|
playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 }
|
||||||
|
const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(playerState),
|
||||||
|
})
|
||||||
|
expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' })
|
||||||
|
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||||
|
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
|
||||||
|
expect(savedPlayerState.evidence[0]).toMatchObject({ id: 'folder-1', x: 812, y: 533 })
|
||||||
|
|
||||||
|
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
|
||||||
|
expect(resetResponse.ok).toBe(true)
|
||||||
|
const resetState = await resetResponse.json() as CaseState
|
||||||
|
expect(resetState.viewport).toEqual({ x: 0, y: 28, zoom: 0.7 })
|
||||||
|
expect(resetState.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { once } from 'node:events'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import pg from 'pg'
|
||||||
|
import type { CaseState } from '../src/types.js'
|
||||||
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
|
const { Client } = pg
|
||||||
|
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||||
|
if (!baseDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for the browser harness')
|
||||||
|
|
||||||
|
const databaseName = `osint_e2e_${process.pid}_${Date.now()}`
|
||||||
|
const adminUrl = new URL(baseDatabaseUrl)
|
||||||
|
adminUrl.pathname = '/postgres'
|
||||||
|
const adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||||
|
await adminClient.connect()
|
||||||
|
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||||
|
|
||||||
|
const testUrl = new URL(baseDatabaseUrl)
|
||||||
|
testUrl.pathname = `/${databaseName}`
|
||||||
|
const databaseUrl = testUrl.toString()
|
||||||
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
|
await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
||||||
|
|
||||||
|
const port = Number(process.env.E2E_PORT || 18788)
|
||||||
|
process.env.DATABASE_URL = databaseUrl
|
||||||
|
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||||
|
process.env.PORT = String(port)
|
||||||
|
process.env.OSINT_MANAGED_SERVER = 'true'
|
||||||
|
const { server, pool } = await import('./index.js')
|
||||||
|
if (!server.listening) await once(server, 'listening')
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
|
||||||
|
const created = await fetch(`${baseUrl}/api/levels`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
|
||||||
|
})
|
||||||
|
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
|
||||||
|
const state = await created.json() as CaseState
|
||||||
|
state.documents = [{
|
||||||
|
id: 'e2e-document', title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
|
||||||
|
body: [], regions: [], fileType: 'image', metadata: {},
|
||||||
|
}]
|
||||||
|
state.evidence = [{
|
||||||
|
id: 'e2e-folder', type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
||||||
|
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: ['e2e-document'],
|
||||||
|
}]
|
||||||
|
state.relations = [{
|
||||||
|
id: 'e2e-membership', fromWidgetId: 'e2e-folder', toWidgetId: 'e2e-document', type: 'contains', sortOrder: 0,
|
||||||
|
config: { x: 980, y: 360 },
|
||||||
|
}]
|
||||||
|
state.connections = []
|
||||||
|
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||||
|
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(state),
|
||||||
|
})
|
||||||
|
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
async function shutdown(exitCode: number) {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
if (server.listening) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||||
|
await pool.end()
|
||||||
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||||
|
await adminClient.end()
|
||||||
|
process.exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => void shutdown(0))
|
||||||
|
process.on('SIGINT', () => void shutdown(0))
|
||||||
|
process.on('uncaughtException', error => { console.error(error); void shutdown(1) })
|
||||||
|
process.on('unhandledRejection', error => { console.error(error); void shutdown(1) })
|
||||||
|
console.log(`Browser safety harness ready on ${baseUrl}`)
|
||||||
+9
-3
@@ -30,6 +30,12 @@ function wantsEdit(req: express.Request) {
|
|||||||
return editingEnabled && req.query.edit === '1'
|
return editingEnabled && req.query.edit === '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isoTimestamp(value: unknown) {
|
||||||
|
if (!value) return undefined
|
||||||
|
const parsed = new Date(String(value))
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> {
|
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> {
|
||||||
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
|
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
|
||||||
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
|
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
|
||||||
@@ -72,8 +78,8 @@ async function assembleLevel(levelId: string, playthroughId = `default:${levelId
|
|||||||
])
|
])
|
||||||
|
|
||||||
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
|
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
|
||||||
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = String(override.publishedAt || w.published_at || ''); return ({
|
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = isoTimestamp(override.publishedAt || w.published_at); return ({
|
||||||
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt.slice(0, 10) || w.config.date || '', publishedAt: publishedAt || undefined,
|
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt?.slice(0, 10) || w.config.date || '', publishedAt,
|
||||||
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
|
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
|
||||||
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
|
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
|
||||||
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
|
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
|
||||||
@@ -291,7 +297,7 @@ if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_re
|
|||||||
const port = Number(process.env.PORT || 8787)
|
const port = Number(process.env.PORT || 8787)
|
||||||
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
||||||
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
|
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
|
||||||
if (!process.env.VITEST) {
|
if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
|
||||||
process.on('SIGTERM', shutdown)
|
process.on('SIGTERM', shutdown)
|
||||||
process.on('SIGINT', shutdown)
|
process.on('SIGINT', shutdown)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ suite('PostgreSQL migrations', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!adminClient) return
|
if (!adminClient) return
|
||||||
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
|
|
||||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||||
await adminClient.end()
|
await adminClient.end()
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -9,5 +9,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts", "server/**/*.ts", "src/types.ts"]
|
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user