Files
Maurice af9710c618 docs(wiki): the pictures that were missing, and the follow-ups of the rewrite
The wiki pictures of the trip are taken again with the fixtures the help
guides use, so the Bookings tab shows a booking of every kind, Files has
documents and What's Next has something ahead. New pictures: the map's
hover card, the Subscribe to calendar dialog, the PDF preview, the To-Do
view, and the Transports tab's own timeline and booking detail. Day
selection in the screenshot run clicks the day's number, now that the
middle of a day header is the booked night.

Home, Real-Time Collaboration, Road Trip, Map Features and the bookings
page are brought in line with the rewritten pages, and sixteen assets no
page uses any more (mostly old GIFs, 22 MB) are removed.
2026-09-28 12:19:49 +02:00

148 lines
6.1 KiB
TypeScript

import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { E2E_SEED_FILE } from '../../playwright.config'
/**
* Shared plumbing for the documentation screenshot run (`npm run shots`).
*
* These are not assertions about behaviour — they drive the app to a known
* state and capture it for the wiki. They live behind their own Playwright
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
* never pays for them.
*
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
*/
// Playwright runs from the client workspace root, matching how
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
export const VIEWPORT = { width: 1440, height: 900 }
export const test = base.extend<{ shot: Shot }>({
// Overriding `page` (rather than doing this inside the `shot` fixture) is
// deliberate: fixtures initialise lazily, so a route registered in `shot`
// lands AFTER any beforeEach hook has already navigated — too late to
// intercept the config request.
page: async ({ page }, use) => {
await page.setViewportSize(VIEWPORT)
await hideDevOnlyUi(page)
await use(page)
},
shot: async ({ page }, use) => {
mkdirSync(OUT_DIR, { recursive: true })
await use(new Shot(page))
},
})
/**
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
* "Dev: Notifications" tab that no real deployment ever shows.
*
* Rewriting the response is the surgical fix. Flipping the server to
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
* HSTS header on localhost would upgrade the run to https and break it.
*/
async function hideDevOnlyUi(page: Page): Promise<void> {
await page.route('**/api/auth/app-config', async route => {
const res = await route.fetch()
const body = await res.json()
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
})
}
export { expect }
interface SeedIds { tripId: number; collectionId?: number | null; journeyId?: number | null }
let seedCache: SeedIds | null = null
/**
* The ids the seed project wrote for this run's database. Read on first use, not
* at import: Playwright loads every test file before the seed project has run,
* and the file is named after the API port, so a run on its own ports reads its own.
*/
export const seed: SeedIds = new Proxy({} as SeedIds, {
get: (_t, key: string) => {
seedCache ??= JSON.parse(readFileSync(path.join(process.cwd(), E2E_SEED_FILE), 'utf8')) as SeedIds
return seedCache[key as keyof SeedIds]
},
})
export class Shot {
constructor(private readonly page: Page) {}
/**
* Capture the full viewport. `name` is the target filename in wiki/assets/
* (without extension) so the mapping from screenshot to doc page is literal.
*/
async page_(name: string): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/** Capture a region of the page, for a detail with no element of its own (a card over the map). */
async region(name: string, clip: { x: number; y: number; width: number; height: number }): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`), clip })
}
/** Capture one element — preferred for dialogs, panels and cards. */
async element(name: string, target: Locator): Promise<void> {
await this.settle()
await expect(target).toBeVisible()
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/**
* Quiet the page before capturing: fonts loaded, images decoded, animations
* finished, no pending network. Without this, screenshots catch skeleton
* loaders and half-faded modals, which is exactly how the current wiki
* assets ended up inconsistent.
*/
private async settle(): Promise<void> {
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
// fully idle and an unbounded wait would burn the whole test timeout.
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
await this.page.evaluate(async () => { await document.fonts.ready })
await this.page.evaluate(async () => {
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res })),
)
})
// Let CSS transitions land (modal fade-in, sidebar slide).
await this.page.waitForTimeout(400)
}
}
/**
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
* but tolerant: on a seeded DB the notice may already be cleared.
*/
export async function clearNotices(page: Page): Promise<void> {
// The "what's new" card of a release lays an overlay over the whole page that
// takes every click; it may land a moment after the page, so give it that moment.
const releaseClose = page.locator('.rn-close')
if (await releaseClose.waitFor({ state: 'visible', timeout: 2_500 }).then(() => true, () => false)) {
await releaseClose.click().catch(() => {})
await page.locator('.rn-overlay').waitFor({ state: 'detached', timeout: 3_000 }).catch(() => {})
}
const next = page.getByRole('button', { name: /next/i })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}