mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
64b190862e
Every one of these passed when its own file was run alone and failed when all of them ran against one seeded trip, because another screen's fixture had put something in the way. - The Bookings fixture names a booking after a place, so the Assign File dialog offered two entries opening with the same words. - The Lists tab has a Shared button, which the loose name of the navbar's Share also matches, so opening a trip on that tab never settled. - A traveller's avatar is named by the initial it draws, not by the username; the title carries the name. - Departure and Arrival are what a booking gets when it crosses days, so the guide about them points at the flight, not at the train that starts and ends on one day. - The What's Next stop was timed from the wall clock of whoever runs this, while the browser's clock is pinned to the day the pictures are taken on. Both now come from the same constant. - The map was panned with the arrow keys, which Leaflet only reads while its container has focus, and focusing it from here did not take. It is dragged now, from a point checked to be free of markers, because a press on a marker drags the place onto a day instead.
368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
import { expect, type Locator, type Page } from '@playwright/test'
|
||
import { mkdirSync } from 'node:fs'
|
||
import path from 'node:path'
|
||
import type { HelpGuide } from '../../src/help/types'
|
||
|
||
/**
|
||
* Runner for the help-center media (`npm run help:media`).
|
||
*
|
||
* A guide is defined once, in `src/help/contexts/<screen>.ts`; the file next to
|
||
* this one gives every step of it an action against the real app. From that
|
||
* single script this runner produces everything the panel shows:
|
||
*
|
||
* - one framed picture per step, with the element the reader is told to use
|
||
* ringed and numbered (`step-<n>.png`),
|
||
* - the state after the last step (`result.png`).
|
||
*
|
||
* And because every step has to succeed for the picture to exist, the same run
|
||
* is the test that the guide still matches the UI.
|
||
*
|
||
* Output lands in a staging directory; `promote.mjs` converts and moves it into
|
||
* `public/help-media/`, so a bad run never clobbers good artwork.
|
||
*/
|
||
|
||
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
|
||
/**
|
||
* A common desktop. Smaller frames squeeze the trip cards until their badges
|
||
* overlap the action buttons, which is not what a reader on a normal screen
|
||
* sees; the wiki shots use 1440×900 for the docs' narrower column.
|
||
*/
|
||
export const VIEWPORT = { width: 1920, height: 1080 }
|
||
|
||
/**
|
||
* The day every picture is taken on.
|
||
*
|
||
* The seeded trip runs to 2026-09-21, and a picture of it has to look the same
|
||
* whoever runs this and whenever: a day later the trip is over, My Trips moves
|
||
* it out of the boarding pass and into the grid, What's Next has nothing left
|
||
* to list, and the forecast for a day in the past is not a forecast. Only the
|
||
* browser's `Date` is pinned, so timers keep running and nothing that waits
|
||
* stops waiting; the server keeps its own clock, which none of the pictures
|
||
* read.
|
||
*/
|
||
export const PICTURE_DAY = new Date('2026-09-21T09:00:00.000Z')
|
||
|
||
/** Put the page on the day the pictures are taken on. Call before navigating. */
|
||
async function pinClock(page: Page): Promise<void> {
|
||
await page.clock.setFixedTime(PICTURE_DAY)
|
||
}
|
||
|
||
export interface StepAction {
|
||
/** Bring the screen to where the step starts (runs after the previous step's `act`). */
|
||
prepare?: (page: Page) => Promise<void>
|
||
/** The element the reader is told to use; ringed and framed in the picture. */
|
||
target?: (page: Page) => Locator
|
||
/**
|
||
* Where to put the pointer for the picture when the target's centre is not on
|
||
* it (an SVG region whose bounding-box centre lies in a neighbour). Default:
|
||
* hover the target.
|
||
*/
|
||
hover?: (page: Page) => Promise<void>
|
||
/**
|
||
* A drag: the target is what the reader picks up, this is where it goes.
|
||
* Both are ringed and an arrow joins them, so one still shows the gesture.
|
||
*/
|
||
dropTo?: (page: Page) => Locator
|
||
/** What the step does. */
|
||
act?: (page: Page) => Promise<void>
|
||
}
|
||
|
||
export interface GuideScript {
|
||
guide: HelpGuide
|
||
/** Where the guide starts: navigate, dismiss notices, wait for data. */
|
||
start: (page: Page) => Promise<void>
|
||
/** Exactly one entry per registered step. */
|
||
steps: StepAction[]
|
||
/** Undo what the guide changed, so the next one starts from the seed again. */
|
||
cleanup?: (page: Page) => Promise<void>
|
||
}
|
||
|
||
/**
|
||
* 'still' takes the pictures at full speed. The mode stays as a switch so a
|
||
* recorder that plays the same scripts at a watchable pace can be added
|
||
* without touching them; nothing sets 'paced' today.
|
||
*/
|
||
let mode: 'still' | 'paced' = 'still'
|
||
|
||
/** A pause a person would make; nothing in still mode. */
|
||
export async function beat(page: Page, ms = 700): Promise<void> {
|
||
if (mode === 'paced') await page.waitForTimeout(ms)
|
||
}
|
||
|
||
/** Fill instantly for the pictures; type like a person when paced. */
|
||
export async function typeInto(page: Page, locator: Locator, text: string): Promise<void> {
|
||
if (mode === 'paced') {
|
||
await locator.click()
|
||
await locator.pressSequentially(text, { delay: 55 })
|
||
} else {
|
||
await locator.fill(text)
|
||
}
|
||
}
|
||
|
||
// ── Stills ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */
|
||
export async function captureGuide(page: Page, script: GuideScript): Promise<void> {
|
||
mode = 'still'
|
||
await pinClock(page)
|
||
const { guide } = script
|
||
if (script.steps.length !== guide.steps) {
|
||
throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`)
|
||
}
|
||
const dir = path.join(OUT_DIR, guide.id)
|
||
mkdirSync(dir, { recursive: true })
|
||
|
||
await script.start(page)
|
||
await settle(page)
|
||
|
||
for (let i = 0; i < script.steps.length; i++) {
|
||
const n = i + 1
|
||
const step = script.steps[i]
|
||
await step.prepare?.(page)
|
||
if (step.target && guide.media.steps) {
|
||
const target = step.target(page)
|
||
await expect(target, `guide "${guide.id}" step ${n}: target`).toBeVisible()
|
||
await target.scrollIntoViewIfNeeded()
|
||
let box = await target.boundingBox()
|
||
if (!box) throw new Error(`guide "${guide.id}" step ${n}: target has no box`)
|
||
if (box.height > VIEWPORT.height - 2 * PAD) {
|
||
// A block taller than the frame (a whole settings section): show its top
|
||
// rather than its middle, which is where the heading is.
|
||
await target.evaluate(el => el.scrollIntoView({ block: 'start' }))
|
||
await page.evaluate(() => {
|
||
const nav = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64
|
||
window.scrollBy(0, -nav - 24)
|
||
})
|
||
} else {
|
||
// Playwright scrolls only as far as it must, which leaves a target at the
|
||
// bottom edge and the frame full of whatever sits above it. Centre it so
|
||
// the picture shows the element in its surroundings. Works on whichever
|
||
// ancestor scrolls (the dashboard has its own pane, not the document).
|
||
await target.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
||
}
|
||
if (step.hover) await step.hover(page)
|
||
else await target.hover()
|
||
await settle(page)
|
||
box = (await target.boundingBox()) ?? box
|
||
if (step.dropTo) {
|
||
const dest = step.dropTo(page)
|
||
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
|
||
const to = await dest.boundingBox()
|
||
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
|
||
await ringDrag(page, box, to, n)
|
||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
|
||
} else {
|
||
await ring(page, box, n)
|
||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(box) })
|
||
}
|
||
await unring(page)
|
||
}
|
||
await step.act?.(page)
|
||
}
|
||
|
||
if (guide.media.result) {
|
||
await settle(page)
|
||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) // nothing hovered in the after-picture
|
||
await page.waitForTimeout(250)
|
||
await page.screenshot({ path: path.join(dir, 'result.png') })
|
||
}
|
||
await script.cleanup?.(page)
|
||
}
|
||
|
||
/** The screen overview picture for a context. */
|
||
export async function captureHero(page: Page, contextId: string, start: (page: Page) => Promise<void>): Promise<void> {
|
||
mode = 'still'
|
||
await pinClock(page)
|
||
const dir = path.join(OUT_DIR, 'ctx')
|
||
mkdirSync(dir, { recursive: true })
|
||
await start(page)
|
||
await settle(page)
|
||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4)
|
||
await page.waitForTimeout(250)
|
||
await page.screenshot({ path: path.join(dir, `${contextId}.png`) })
|
||
}
|
||
|
||
// ── Framing ──────────────────────────────────────────────────────────────────
|
||
|
||
const PAD = 64
|
||
const MIN_W = 960
|
||
const RATIO = 16 / 10
|
||
|
||
/**
|
||
* A 16:10 window around the target: at least MIN_W wide, big enough to hold the
|
||
* element with breathing room, kept inside the viewport. A target too large for
|
||
* the largest 16:10 frame that fits gets that frame, anchored at its top, so a
|
||
* long settings section shows its heading rather than its middle.
|
||
*/
|
||
export function frameFor(box: { x: number; y: number; width: number; height: number }) {
|
||
const { width: vw, height: vh } = VIEWPORT
|
||
const maxH = Math.min(vh, vw / RATIO)
|
||
const maxW = maxH * RATIO
|
||
let w = Math.max(MIN_W, box.width + 2 * PAD)
|
||
let h = Math.max(w / RATIO, box.height + 2 * PAD)
|
||
w = Math.max(w, h * RATIO)
|
||
const tall = h > maxH
|
||
if (w > maxW || tall) {
|
||
w = maxW
|
||
h = maxH
|
||
}
|
||
const x = clamp(box.x + box.width / 2 - w / 2, 0, vw - w)
|
||
const y = tall ? clamp(box.y - PAD / 2, 0, vh - h) : clamp(box.y + box.height / 2 - h / 2, 0, vh - h)
|
||
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) }
|
||
}
|
||
|
||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
|
||
|
||
/** Ring the target in the app's accent, dim everything else, number it. */
|
||
async function ring(page: Page, box: { x: number; y: number; width: number; height: number }, n: number): Promise<void> {
|
||
await page.evaluate(
|
||
({ box, n }) => {
|
||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||
const pad = 6
|
||
const el = document.createElement('div')
|
||
el.id = 'trek-help-ring'
|
||
Object.assign(el.style, {
|
||
position: 'fixed',
|
||
left: `${box.x - pad}px`,
|
||
top: `${box.y - pad}px`,
|
||
width: `${box.width + 2 * pad}px`,
|
||
height: `${box.height + 2 * pad}px`,
|
||
borderRadius: '12px',
|
||
boxShadow: `0 0 0 3px ${accent}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
|
||
pointerEvents: 'none',
|
||
zIndex: '2147483647',
|
||
})
|
||
const badge = document.createElement('div')
|
||
Object.assign(badge.style, {
|
||
position: 'absolute',
|
||
left: '-14px',
|
||
top: '-14px',
|
||
width: '28px',
|
||
height: '28px',
|
||
borderRadius: '999px',
|
||
background: accent,
|
||
color: accentText,
|
||
font: '700 13px/28px Poppins, system-ui, sans-serif',
|
||
textAlign: 'center',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
|
||
})
|
||
badge.textContent = String(n)
|
||
el.appendChild(badge)
|
||
document.body.appendChild(el)
|
||
},
|
||
{ box, n },
|
||
)
|
||
}
|
||
|
||
type Box = { x: number; y: number; width: number; height: number }
|
||
|
||
/** The smallest box around two. */
|
||
function union(a: Box, b: Box): Box {
|
||
const x = Math.min(a.x, b.x)
|
||
const y = Math.min(a.y, b.y)
|
||
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }
|
||
}
|
||
|
||
/**
|
||
* The drag picture: a dim over everything but the two boxes, the dragged one
|
||
* ringed and numbered, the destination ringed in a dashed line, and an arrow
|
||
* from the one to the other, bowed a little so it reads as a movement.
|
||
*/
|
||
async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void> {
|
||
await page.evaluate(
|
||
({ from, to, n }) => {
|
||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||
const pad = 6
|
||
const W = window.innerWidth
|
||
const H = window.innerHeight
|
||
const ns = 'http://www.w3.org/2000/svg'
|
||
const el = (tag: string, attrs: Record<string, string | number>) => {
|
||
const node = document.createElementNS(ns, tag)
|
||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v))
|
||
return node
|
||
}
|
||
const svg = el('svg', { id: 'trek-help-ring', width: W, height: H, viewBox: `0 0 ${W} ${H}` })
|
||
Object.assign(svg.style, { position: 'fixed', left: '0', top: '0', pointerEvents: 'none', zIndex: '2147483647' })
|
||
const grow = (b: typeof from) => ({ x: b.x - pad, y: b.y - pad, w: b.width + 2 * pad, h: b.height + 2 * pad })
|
||
const a = grow(from)
|
||
const b = grow(to)
|
||
const defs = el('defs', {})
|
||
const mask = el('mask', { id: 'trek-help-mask' })
|
||
mask.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'white' }))
|
||
mask.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'black' }))
|
||
mask.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'black' }))
|
||
defs.appendChild(mask)
|
||
const marker = el('marker', { id: 'trek-help-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' })
|
||
marker.appendChild(el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: accent }))
|
||
defs.appendChild(marker)
|
||
svg.appendChild(defs)
|
||
svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'rgba(0,0,0,0.30)', mask: 'url(#trek-help-mask)' }))
|
||
svg.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3 }))
|
||
svg.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3, 'stroke-dasharray': '10 7' }))
|
||
// From the edge of the dragged box that faces the destination to the facing edge there.
|
||
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }
|
||
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 }
|
||
const horizontal = Math.abs(bc.x - ac.x) > Math.abs(bc.y - ac.y)
|
||
const start = horizontal ? { x: bc.x > ac.x ? a.x + a.w : a.x, y: ac.y } : { x: ac.x, y: bc.y > ac.y ? a.y + a.h : a.y }
|
||
const end = horizontal ? { x: bc.x > ac.x ? b.x : b.x + b.w, y: bc.y } : { x: bc.x, y: bc.y > ac.y ? b.y : b.y + b.h }
|
||
const dx = end.x - start.x
|
||
const dy = end.y - start.y
|
||
const len = Math.hypot(dx, dy) || 1
|
||
const bow = Math.min(90, len * 0.18)
|
||
const ctrl = { x: (start.x + end.x) / 2 - (dy / len) * bow, y: (start.y + end.y) / 2 + (dx / len) * bow }
|
||
svg.appendChild(el('path', {
|
||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||
fill: 'none', stroke: 'rgba(255,255,255,0.9)', 'stroke-width': 8, 'stroke-linecap': 'round',
|
||
}))
|
||
svg.appendChild(el('path', {
|
||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||
fill: 'none', stroke: accent, 'stroke-width': 4, 'stroke-linecap': 'round', 'marker-end': 'url(#trek-help-arrow)',
|
||
}))
|
||
const badge = el('g', {})
|
||
badge.appendChild(el('circle', { cx: a.x, cy: a.y, r: 14, fill: accent }))
|
||
const text = el('text', { x: a.x, y: a.y + 5, 'text-anchor': 'middle', fill: accentText, 'font-family': 'Poppins, system-ui, sans-serif', 'font-size': 13, 'font-weight': 700 })
|
||
text.textContent = String(n)
|
||
badge.appendChild(text)
|
||
svg.appendChild(badge)
|
||
document.body.appendChild(svg)
|
||
},
|
||
{ from, to, n },
|
||
)
|
||
}
|
||
|
||
async function unring(page: Page): Promise<void> {
|
||
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
|
||
}
|
||
|
||
/**
|
||
* The release-notes modal greets every user once per version and its overlay
|
||
* swallows every click underneath. clearNotices handles the plain notices; this
|
||
* one has its own close button.
|
||
*/
|
||
export async function dismissReleaseNotice(page: Page): Promise<void> {
|
||
const close = page.locator('.rn-close')
|
||
await close.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {})
|
||
for (let i = 0; i < 3 && (await close.isVisible().catch(() => false)); i++) {
|
||
await close.click()
|
||
await page.waitForTimeout(500)
|
||
}
|
||
await expect(page.locator('.rn-overlay')).toHaveCount(0)
|
||
}
|
||
|
||
/** Fonts loaded, images decoded, transitions landed, network quiet (bounded: /ws never idles). */
|
||
export async function settle(page: Page): Promise<void> {
|
||
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
|
||
await page.evaluate(async () => { await document.fonts.ready })
|
||
// Bounded: a picture whose request never answers (a tile, a CDN flag) must
|
||
// not hold the run; three seconds is longer than anything that does load.
|
||
await page.evaluate(async () => {
|
||
const pending = Array.from(document.images)
|
||
.filter(img => !img.complete)
|
||
.map(img => new Promise(res => { img.onload = img.onerror = res }))
|
||
await Promise.race([Promise.all(pending), new Promise(res => setTimeout(res, 3000))])
|
||
})
|
||
await page.waitForTimeout(450)
|
||
}
|