Files
TREK/client/e2e/help/trip-roadtrip.guide.ts
T
Maurice 146fe4a063 docs(help): the trip screens' guides for the new layout, with new pictures
The guides of the map, bookings, costs, files, collab, lists, road trip and
transports now describe the desktop as it is after the redesign: the bars
with their icon buttons and filter popovers, the dialogs with the title in
the head band and status and type as pills, the three booking views and
the booking detail, the compact note cards, the Costs table. Two guides
are new: the booking views (cards, list, timeline, the Trip and Day zoom
and the detail) and the Costs table view. Every changed text is translated
into every locale, and the Plan tab texts call the bookings tab by the
label it shows, Book.

Every picture of the thirteen trip screens is taken again against the new
layout, on one picture day. Scripts follow the new markup; the Dawarich
fixture uploads its recording again when the stored one belongs to another
run, and the spare poll gets its vote from a member so its winner shows
green.

server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so
several recordings on their own port pairs can share one dist.
2026-09-28 12:19:30 +02:00

761 lines
32 KiB
TypeScript

import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for road trip mode, keyed by the ids in
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
* guide writes it takes away again in `cleanup`, and `test.afterAll` switches
* the addon off again: it is an instance-wide setting, and while it is on
* every trip's left column grows a switch the other screens never show.
*/
const guide = (id: string): HelpGuide => {
const g = tripRoadtripGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
// ── Where things are ──────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
const dayCard = (page: Page, day: number) =>
rail(page)
.locator('> section')
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
.first()
/**
* One stop's row and everything hanging off it. The kind disc sits inside the
* row button while the stop is a destination and beside it once it is a stop on
* the way, so the list item is the only handle that holds for both.
*/
const stopItem = (page: Page, name: string) => rail(page).getByRole('listitem').filter({ hasText: name }).first()
const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('button').filter({ hasText: name }).first()
const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' })
/** The corridor panel: before a search its heading is the only stable thing in it. */
const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]')
/**
* The two dropdowns of the Looking for line. Both are listbox triggers, so they
* are told apart by name: the kinds read "Looking for: Fuel", the day "Day 1".
*/
const kindPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Looking for/ })
const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first()
/** The list a dropdown opens; only one is open at a time. */
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
/** The drive band of day 4's long leg, the one with more than one road to it. */
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
/** The alternatives bar floats over the map, inside neither column. */
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
const dryBand = (page: Page) => page.getByText('Tank runs out here').first().locator('xpath=ancestor::div[3]')
const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first()
/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */
const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first()
// ── Getting there ─────────────────────────────────────────────────────────────
async function openRoadtrip(page: Page): Promise<void> {
await openTrip(page, { day: null })
await page.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(page)
}
/**
* Every leg of every day is a router round trip, and the rail says nothing at
* all until they land. The totals appear first and the partial line only while
* something is outstanding, so neither of them on its own says the drive is
* there; a drive band on a day that really has one does.
*/
async function waitForTheDrive(page: Page): Promise<void> {
await expect(page.getByText('Driving time', { exact: true })).toBeVisible({ timeout: 120_000 })
await expect(dayCard(page, 2).getByRole('button', { name: /Other ways$/ }).first()).toBeVisible({ timeout: 120_000 })
await expect(page.getByText('Still working out the rest of the drive')).toHaveCount(0, { timeout: 120_000 })
await settle(page)
}
/**
* Put a leg where the pointer can reach it. The map opens fitted to the whole
* trip, which leaves the drawn route under the two floating panels; clicking a
* stop frames that stop instead, and the card it opens is closed again at once.
*/
async function frameOnStop(page: Page, name: string): Promise<void> {
// On the name: the row's centre can land on its Stay badge, which opens its
// own dialog instead of selecting the stop.
await stopRow(page, name).click({ position: { x: 70, y: 12 } })
// The place's card opens over the map; its Close sits in the card's head band.
const head = placeCardHead(page, name)
await expect(head).toBeVisible({ timeout: 15_000 })
await finishMapPan(page)
await head.getByRole('button', { name: 'Close', exact: true }).click()
await expect(head).toHaveCount(0)
await settle(page)
}
/**
* Let the map's pan to a picked place land. Leaflet times its pan from
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
* the pan starts and never gets anywhere: the map stays where it was. Moving the
* pinned clock a few seconds on ends it on the next frame, as a second passing
* would; nothing in these pictures reads the time to the second.
*/
async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400)
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
await page.waitForTimeout(1200)
await settle(page)
}
/**
* The head band of the card a stop opens over the map (the place inspector):
* the place's name as a heading, and Close beside it. The rail prints the same
* name, but never as a heading.
*/
const placeCardHead = (page: Page, name: string) =>
page.getByRole('heading', { name, exact: true, level: 2 }).locator('xpath=ancestor::header[1]')
type RoutePoint = { leg: number; x: number; y: number }
/**
* Points that really lie on a drawn leg, with nothing over them. A bounding-box
* centre is no use here: a route is a curve, its box is mostly empty space, and
* what sits in the middle of it is usually somebody else's pin.
*/
async function routePoints(page: Page): Promise<RoutePoint[]> {
return page.evaluate(() => {
const paths = Array.from(
document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]'),
)
const found: { leg: number; x: number; y: number }[] = []
paths.forEach((path, leg) => {
const length = path.getTotalLength()
const ctm = path.getScreenCTM()
if (!ctm || length < 24) return
for (let f = 0.08; f <= 0.92; f += 0.02) {
const at = path.getPointAtLength(length * f)
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e)
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f)
// The two panels float over the map; a point under one of them is not clickable.
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
}
})
return found
})
}
/** Which leg the via guide aims at, and where; worked out once, used by three steps. */
let aimedLeg = 0
let aimedAt = { x: 0, y: 0 }
/** The day the via landed on, read back between two steps of the same guide. */
let viaDayId = 0
/** Names of what a guide created, read back by its cleanup. */
let addedStop = ''
let refuelStop = ''
/** The leg with the most reachable points: the one worth clicking and worth a picture. */
async function aimAtLeg(page: Page): Promise<void> {
const points = await routePoints(page)
if (points.length === 0) throw new Error('no drawn leg is reachable on the map')
const counts = new Map<number, number>()
for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1)
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
aimedLeg = best ? best[0] : points[0].leg
const own = points.filter(point => point.leg === aimedLeg)
const middle = own[Math.floor(own.length / 2)] ?? points[0]
aimedAt = { x: middle.x, y: middle.y }
}
const aimedLine = (page: Page) => page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').nth(aimedLeg)
/** The pointer goes on the line, not on the centre of the box around it. */
const hoverAimedLine = async (page: Page): Promise<void> => {
await page.mouse.move(aimedAt.x, aimedAt.y)
}
// ── What the guides write, and how it is taken back ───────────────────────────
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
async function listVias(page: Page): Promise<ViaRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const body = (await res.json()) as { vias?: ViaRow[] }
return body.vias ?? []
}
async function clearVias(page: Page): Promise<void> {
const { tripId } = seededTrip()
for (const via of await listVias(page)) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`)
}
// A followed track is recorded per day even when the drive needed no via to
// follow it, and deleting vias leaves that record standing: clear it too.
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const { tracks = [] } = (await res.json()) as { tracks?: { day_id: number }[] }
for (const track of tracks) {
await page.request.post(`/api/trips/${tripId}/roadtrip/days/${track.day_id}/vias/batch`, {
data: { vias: [], track: null },
})
}
}
async function clearDayBoundaries(page: Page): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/day-boundaries`)
const body = (await res.json()) as { boundaries?: { day_number: number }[] }
for (const boundary of body.boundaries ?? []) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/day-boundaries/${boundary.day_number}`)
}
}
async function listPlaces(page: Page): Promise<{ id: number; name: string }[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
return Array.isArray(body) ? body : (body.places ?? [])
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const wanted = names.filter(name => name.length > 0)
if (wanted.length === 0) return
for (const place of (await listPlaces(page)).filter(x => wanted.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/** Put one place back the way the seed had it. */
async function restorePlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
const { tripId } = seededTrip()
const place = (await listPlaces(page)).find(x => x.name === name)
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
}
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'roadtrip-mode': {
guide: guide('roadtrip-mode'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => p.getByRole('tablist', { name: 'Plan view' }),
act: async p => {
await p.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(p)
},
},
only(p => p.getByText('Driving time', { exact: true }).locator('xpath=ancestor::header[1]')),
only(p => dayCard(p, 2).locator('header')),
only(p => dayCard(p, 2).getByRole('list').first()),
{
// Day 2, like the two steps above: the rail leaves out a day with
// nothing to drive, and day 1 is one of them.
target: p => dayCard(p, 2).locator('header'),
act: async p => {
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'false')
await settle(p)
// Folded is not the state to leave the trip in, and the result picture
// is of the whole drive rather than of one card shut.
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'true')
await settle(p)
},
},
],
},
'roadtrip-stops': {
guide: guide('roadtrip-stops'),
start: openRoadtrip,
steps: [
{
// The disc itself, by its own label: the row button around it takes the
// disc's label into its accessible name, so the role query matches both.
target: p => stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]'),
act: async p => {
await stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]').click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: kindDialog,
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Rest area' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(
stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }),
).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => dayCard(p, 2).locator('header')),
{
prepare: async p => {
await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }).click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
target: p => kindDialog(p).getByRole('button', { name: 'Back to a destination' }),
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Back to a destination' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]')).toBeVisible({
timeout: 20_000,
})
await settle(p)
},
},
{
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
act: async p => {
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: '1 h', exact: true }).click()
await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60')
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
},
},
'roadtrip-corridor': {
guide: guide('roadtrip-corridor'),
start: async p => {
addedStop = ''
await openRoadtrip(p)
},
steps: [
{
// The line reads "these kinds, along this day", so the kinds come first.
prepare: async p => {
await kindPicker(p).click()
await expect(openList(p)).toBeVisible()
await settle(p)
},
target: openList,
act: async p => {
// Fuel is what the panel opens on for a petrol car; Food is ticked beside it.
await openList(p).getByRole('option', { name: 'Food' }).click()
await expect(openList(p).getByRole('option', { name: 'Food' })).toHaveAttribute('aria-selected', 'true')
// The list stays open for a second kind; a second click on its trigger shuts it.
await kindPicker(p).click()
await expect(openList(p)).toHaveCount(0)
await expect(kindPicker(p)).toHaveAccessibleName(/Food/)
await settle(p)
},
},
{
target: dayPicker,
act: async p => {
await dayPicker(p).click()
// The rows are options named by the day's number and its label, "2 Day 2".
await openList(p).getByRole('option', { name: /\bDay 2$/ }).click()
await expect(dayPicker(p)).toContainText('Day 2')
await settle(p)
},
},
{
target: p => corridor(p).getByRole('button', { name: '5 km', exact: true }).locator('xpath=..'),
act: async p => {
// The step's sentence tells the reader to choose a width and then search,
// so the width is chosen here even though 5 km is what the panel opens on.
await corridor(p).getByRole('button', { name: '5 km', exact: true }).click()
await expect(corridor(p).getByRole('button', { name: '5 km', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
)
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
await expect(corridor(p).getByText(/on the way$/).first()).toBeVisible({ timeout: 180_000 })
await settle(p)
},
},
only(p => corridor(p).getByRole('listitem').first()),
{
prepare: async p => {
const row = corridor(p).getByRole('listitem').first()
addedStop = (await row.innerText()).split('\n')[0] ?? ''
await row.hover()
await settle(p)
},
target: p => corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible()
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === addedStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, addedStop),
},
'roadtrip-via': {
guide: guide('roadtrip-via'),
start: openRoadtrip,
steps: [
{
// A stop of day 4, the drive out of Tokyo: the one leg long enough to be
// worth bending, and once Hakone is in the middle of the map it runs
// east across the open map rather than under the right column.
target: p => stopItem(p, 'Hakone Shrine'),
act: async p => {
await frameOnStop(p, 'Hakone Shrine')
await aimAtLeg(p)
},
},
{
target: aimedLine,
hover: hoverAimedLine,
act: async p => {
await p.mouse.click(aimedAt.x, aimedAt.y)
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(1)
await expect(viaHandle(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
prepare: async p => {
viaDayId = (await listVias(p))[0]?.day_id ?? 0
await settle(p)
},
// Which day the click landed on is the router's answer, not ours.
target: p => {
const day = seededTrip().dayIds.indexOf(viaDayId) + 1
// Fall back to day 2, not day 1: the rail has no card for a day
// with nothing to drive.
return dayCard(p, day > 0 ? day : 2).locator('header')
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
const before = JSON.stringify((await listVias(p))[0] ?? {})
// A Leaflet drag, not an HTML5 one: the handle listens for the raw
// pointer, so `dragTo` would move nothing.
await p.mouse.move(from.x, from.y)
await p.mouse.down()
await p.mouse.move(from.x + 45, from.y + 40, { steps: 10 })
await p.mouse.move(from.x + 90, from.y + 80, { steps: 10 })
await p.mouse.up()
await expect
.poll(async () => JSON.stringify((await listVias(p))[0] ?? {}), { timeout: 60_000 })
.not.toBe(before)
await settle(p)
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' })
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(0)
await settle(p)
},
},
],
cleanup: clearVias,
},
'roadtrip-alternatives': {
guide: guide('roadtrip-alternatives'),
start: openRoadtrip,
steps: [
{
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
// Shibuya the router knows one sensible way, and the bar says only that.
target: altBand,
act: async p => {
await altBand(p).click()
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
only(altBar),
{
prepare: async p => {
await altBar(p).getByRole('button', { name: /km/ }).last().hover()
await settle(p)
},
target: p => altBar(p).getByRole('button', { name: /km/ }).last(),
},
{
target: p => altBar(p).getByRole('button', { name: 'Close' }),
act: async p => {
await altBar(p).getByRole('button', { name: 'Close' }).click()
await expect(p.getByText('Ways to drive this leg')).toHaveCount(0)
await settle(p)
},
},
],
// Nothing is chosen here, but choosing an entry writes a via, so a misclick
// must not follow the run into the next guide.
cleanup: clearVias,
},
'roadtrip-limits': {
guide: guide('roadtrip-limits'),
start: openRoadtrip,
steps: [
{
target: settingsCard,
act: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-legMinutes').locator('xpath=ancestor::section[1]'),
act: async p => {
await modal(p).getByTestId('limit-legMinutes').fill('60')
await p.keyboard.press('Tab')
await expect(modal(p).getByTestId('limit-legMinutes')).toHaveValue('60')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'What you drive' }),
act: async p => {
await modal(p).getByText('Electric', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'Electric' })).toBeChecked()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-range').locator('xpath=ancestor::label[1]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Work it out from the car' }).click()
await expect(modal(p).getByRole('spinbutton', { name: /^Battery, / })).toBeVisible()
await settle(p)
},
},
// Not toggled on purpose: each of these switches sends every leg of the
// trip back through the avoidance router, which is minutes of waiting for
// one picture of a row of switches.
// By its title, not by a switch: without an avoidance router the switches
// are replaced by a dash and the section is all that is left of it.
only(p => modal(p).getByText('Avoid where possible', { exact: true }).locator('xpath=ancestor::section[1]')),
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
// Both halves: the badge the fixture leaves reads "1 h 30 min", which
// contains "1 h", so the shortened limit only shows in what is NOT there.
await expect(settingsCard(p)).toContainText('1 h', { timeout: 30_000 })
await expect(settingsCard(p)).not.toContainText('1 h 30 min')
await settle(p)
},
},
],
// The fixture writes the whole preference set, so re-running it is the undo.
cleanup: p => ensureRoadtripFixtures(p.request),
},
'roadtrip-day-window': {
guide: guide('roadtrip-day-window'),
start: openRoadtrip,
steps: [
{
prepare: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
target: p => modal(p).getByRole('group', { name: 'Daily travel times' }),
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day start time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day start time' }).fill('09:00')
await expect(modal(p).getByRole('textbox', { name: 'Day start time' })).toHaveValue('09:00')
await settle(p)
},
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day end time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day end time' }).fill('17:30')
await p.keyboard.press('Tab')
await expect(modal(p).getByRole('textbox', { name: 'Day end time' })).toHaveValue('17:30')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'End the day' }),
act: async p => {
await modal(p).getByText('At the last place', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'At the last place' })).toBeChecked()
await settle(p)
},
},
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
await expect(settingsCard(p)).toContainText('09:00', { timeout: 60_000 })
await settle(p)
},
},
],
cleanup: async p => {
await clearDayBoundaries(p)
await ensureRoadtripFixtures(p.request)
},
},
'roadtrip-refuel': {
guide: guide('roadtrip-refuel'),
start: async p => {
refuelStop = ''
await openRoadtrip(p)
},
steps: [
only(dryBand),
{
target: p => p.getByRole('button', { name: 'Find fuel' }).first(),
act: async p => {
await p.getByRole('button', { name: 'Find fuel' }).first().click()
await expect(p.getByText('Looking along the route…')).toHaveCount(0, { timeout: 180_000 })
await expect(addFuelStop(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => dryBand(p).getByRole('list').first()),
{
prepare: async p => {
const label = (await addFuelStop(p).getAttribute('aria-label')) ?? ''
refuelStop = label.replace(/^Add /, '').replace(/ as a fuel stop$/, '')
await addFuelStop(p).hover()
await settle(p)
},
target: addFuelStop,
act: async p => {
await addFuelStop(p).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === refuelStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, refuelStop),
},
'roadtrip-track': {
guide: guide('roadtrip-track'),
start: openRoadtrip,
steps: [
{
target: p => dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }),
act: async p => {
await dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Day 7 follows a track' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('list').first(),
act: async p => {
await modal(p).getByRole('button', { name: /^Arashiyama loop/ }).click()
await expect(modal(p).getByRole('button', { name: 'Follow this track' })).toBeEnabled()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Follow this track' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Follow this track' }).click()
// One router round trip per refinement round, on a route that is
// being pulled onto a track metre by metre.
await expect(modal(p).getByText(/via points placed|already followed this track/)).toBeVisible({
timeout: 240_000,
})
await settle(p)
},
},
{
target: dialog,
act: async p => {
await closeModal(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await clearVias(p)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureRoadtripFixtures(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: left on, every trip's left column grows the
// Days / Road trip switch and every other screen's pictures change. Here
// rather than in the last guide's cleanup, so a failed run puts it back too.
await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } })
})
test('every registered road trip guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripRoadtripContext.guides].sort())
})
test('hero: trip-roadtrip', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripRoadtripContext.id, openRoadtrip)
})
for (const id of tripRoadtripContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}