From 64b190862e8fd56318172907f76ab22c1dee5080 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 01:31:50 +0000 Subject: [PATCH] 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. --- client/e2e/help/fixtures.ts | 19 ++++++-------- client/e2e/help/guide.ts | 2 +- client/e2e/help/trip-bookings.guide.ts | 6 ++--- client/e2e/help/trip-days.guide.ts | 4 ++- client/e2e/help/trip-files.guide.ts | 8 +++--- client/e2e/help/trip-map.guide.ts | 36 ++++++++++++++++++++------ client/e2e/help/trip-shared.ts | 3 ++- client/e2e/help/trip.guide.ts | 6 ++--- 8 files changed, 53 insertions(+), 31 deletions(-) diff --git a/client/e2e/help/fixtures.ts b/client/e2e/help/fixtures.ts index 9669e54b8..86c3f9caa 100644 --- a/client/e2e/help/fixtures.ts +++ b/client/e2e/help/fixtures.ts @@ -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 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()}`) } diff --git a/client/e2e/help/guide.ts b/client/e2e/help/guide.ts index fbefb59a8..a525f1509 100644 --- a/client/e2e/help/guide.ts +++ b/client/e2e/help/guide.ts @@ -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 { diff --git a/client/e2e/help/trip-bookings.guide.ts b/client/e2e/help/trip-bookings.guide.ts index 5c82bf0c9..8e619cf26 100644 --- a/client/e2e/help/trip-bookings.guide.ts +++ b/client/e2e/help/trip-bookings.guide.ts @@ -341,10 +341,10 @@ const SCRIPTS: Record = { }, }, { - 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) }, }, diff --git a/client/e2e/help/trip-days.guide.ts b/client/e2e/help/trip-days.guide.ts index 1b85b1b83..8a575a841 100644 --- a/client/e2e/help/trip-days.guide.ts +++ b/client/e2e/help/trip-days.guide.ts @@ -404,7 +404,9 @@ const SCRIPTS: Record = { 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'), diff --git a/client/e2e/help/trip-files.guide.ts b/client/e2e/help/trip-files.guide.ts index 58c511687..5e187923a 100644 --- a/client/e2e/help/trip-files.guide.ts +++ b/client/e2e/help/trip-files.guide.ts @@ -206,10 +206,12 @@ const SCRIPTS: Record = { }, }, { - 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 }) }, }, { diff --git a/client/e2e/help/trip-map.guide.ts b/client/e2e/help/trip-map.guide.ts index 311e40825..730200d5c 100644 --- a/client/e2e/help/trip-map.guide.ts +++ b/client/e2e/help/trip-map.guide.ts @@ -102,16 +102,36 @@ async function reachable(locator: Locator, fromEnd = false): Promise { * 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 { - 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) } diff --git a/client/e2e/help/trip-shared.ts b/client/e2e/help/trip-shared.ts index b95c0628b..72de46d88 100644 --- a/client/e2e/help/trip-shared.ts +++ b/client/e2e/help/trip-shared.ts @@ -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 diff --git a/client/e2e/help/trip.guide.ts b/client/e2e/help/trip.guide.ts index 053ccd503..5302eb2b6 100644 --- a/client/e2e/help/trip.guide.ts +++ b/client/e2e/help/trip.guide.ts @@ -27,7 +27,7 @@ const leftToggle = (page: Page) => page.locator('button[title="Collapse"], butto async function openShare(page: Page): Promise { 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 = { 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) },