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) },