fix(help): the locators the whole suite in one database showed up

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.
This commit is contained in:
Maurice
2026-09-22 01:31:50 +00:00
parent e9393e9e6c
commit 64b190862e
8 changed files with 53 additions and 31 deletions
+8 -11
View File
@@ -4,7 +4,7 @@ import path from 'node:path'
import { gzipSync } from 'node:zlib'
import { request as apiRequest, type APIRequestContext, type Page } from '@playwright/test'
import { E2E_BASE_URL } from '../../playwright.config'
import { OUT_DIR } from './guide'
import { OUT_DIR, PICTURE_DAY } from './guide'
/**
* What the dashboard guides act on beyond the seeded trip.
@@ -1414,17 +1414,14 @@ export async function ensureCollabFixtures(api: APIRequestContext): Promise<void
const pad = (n: number): string => String(n).padStart(2, '0')
const clock = (d: Date): string => `${pad(d.getHours())}:${pad(d.getMinutes())}`
const now = new Date()
const from = new Date(now.getTime() + 45 * 60_000)
const until = new Date(now.getTime() + 165 * 60_000)
// A start that has already passed drops the stop out of the panel, and a time
// after midnight belongs to a day the trip does not have. Both cases end in no
// time at all: an untimed stop on today is always listed, and reads TBD.
const stillToday = until.getDate() === now.getDate()
// Written on every run, not only on the first: a time left over from an older
// run would be in the past by now.
// Timed against the day the pictures are taken on, not against the wall clock
// of whoever is running this. What's Next only lists a stop whose start is
// still ahead, and the browser's clock is pinned, so a time worked out from
// the real hour is in the panel's past as often as not.
const from = new Date(PICTURE_DAY.getTime() + 45 * 60_000)
const until = new Date(PICTURE_DAY.getTime() + 165 * 60_000)
const timed = await api.put(`/api/trips/${tripId}/assignments/${timedId}/time`, {
data: stillToday ? { place_time: clock(from), end_time: clock(until) } : { place_time: null, end_time: null },
data: { place_time: clock(from), end_time: clock(until) },
})
if (!timed.ok()) throw new Error(`could not time the What's Next stop: ${timed.status()} ${await timed.text()}`)
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const VIEWPORT = { width: 1920, height: 1080 }
* stops waiting; the server keeps its own clock, which none of the pictures
* read.
*/
const PICTURE_DAY = new Date('2026-09-21T09:00:00.000Z')
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> {
+3 -3
View File
@@ -341,10 +341,10 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => travellers(p).getByRole('button', { name: 'jonas' }),
target: p => travellers(p).getByTitle('jonas'),
act: async p => {
await travellers(p).getByRole('button', { name: 'jonas' }).click()
await expect(travellers(p).getByRole('button', { name: 'jonas' })).toHaveAttribute('aria-pressed', 'true')
await travellers(p).getByTitle('jonas').click()
await expect(travellers(p).getByTitle('jonas')).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
+3 -1
View File
@@ -404,7 +404,9 @@ const SCRIPTS: Record<string, GuideScript> = {
await openTrip(p, { day: 5 })
},
steps: [
only(p => bookingRow(p, /Departure.*Nozomi 21/)),
// The flight, not the train: Departure and Arrival are what a booking
// gets when it crosses days, and the train starts and ends on one.
only(p => bookingRow(p, /^Departure LH716/)),
{
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
target: p => stop(p, 'Nishiki Market'),
+5 -3
View File
@@ -206,10 +206,12 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => assignDialog(p).getByRole('button', { name: PLACE }),
// Exact: the bookings fixture names a booking after this place, and the
// dialog lists both under the same opening words.
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
act: async p => {
await assignDialog(p).getByRole('button', { name: PLACE }).click()
await expect(assignDialog(p).getByRole('button', { name: PLACE }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
},
},
{
+28 -8
View File
@@ -102,16 +102,36 @@ async function reachable(locator: Locator, fromEnd = false): Promise<number> {
* rather than a mouse drag: a drag has to start on bare map, and after a search
* there are sixty result pins that each swallow the press it starts with.
*/
/**
* Drag the map the way a reader would.
*
* Not the arrow keys: Leaflet only listens for them while its container has
* focus, and focusing it from here did not take, so the map never moved and the
* button that only exists once it has never appeared. The drag starts from a
* point checked to be empty, because a press on a marker drags the place onto a
* day instead of moving the map.
*/
async function panMap(page: Page): Promise<void> {
await map(page).focus()
for (let i = 0; i < 4; i++) {
await page.keyboard.press('ArrowLeft')
await page.waitForTimeout(250)
}
for (let i = 0; i < 2; i++) {
await page.keyboard.press('ArrowUp')
await page.waitForTimeout(250)
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box to drag')
const candidates = [0.3, 0.7, 0.5].flatMap(fx => [0.75, 0.25].map(fy => ({
x: Math.round(box.x + box.width * fx),
y: Math.round(box.y + box.height * fy),
})))
let from: { x: number; y: number } | undefined
for (const point of candidates) {
const clear = await page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y)
return !!el && !el.closest('.leaflet-marker-icon, button, [role="button"], .leaflet-control')
}, point)
if (clear) { from = point; break }
}
if (!from) throw new Error('found nowhere on the map to take hold of')
await page.mouse.move(from.x, from.y)
await page.mouse.down()
// In steps, so Leaflet reads a drag rather than a click.
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x - i * 22, from.y - i * 9, { steps: 2 })
await page.mouse.up()
await settle(page)
}
+2 -1
View File
@@ -33,7 +33,8 @@ export async function openTrip(page: Page, opts: { tab?: string; day?: number |
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible({ timeout: 30_000 })
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
if (!tab && day) {
// The trip is running, so the plan opens on today, an empty day at the
+3 -3
View File
@@ -27,7 +27,7 @@ const leftToggle = (page: Page) => page.locator('button[title="Collapse"], butto
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Share' }).click()
await page.getByRole('button', { name: 'Share', exact: true }).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(page)
@@ -46,9 +46,9 @@ const SCRIPTS: Record<string, GuideScript> = {
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Share' }),
target: p => p.getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Share' }).click()
await p.getByRole('button', { name: 'Share', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(p)
},