Files
TREK/client/e2e/help/trip-transports.guide.ts
Maurice 960520c6d9 test(e2e): the iPad case checks what broke #1432, the guides read today's texts
The iPad case could no longer fail on its own regression: a draggable
row behind the long-press bridge is what tablets get by design since
#1616, and nothing in the case swiped. What turned swipes into drags in
#1432 was the drag-drop-touch polyfill, so the case now asserts that a
tablet never loads it.

Two help-media guides waited for "1 files" and "2 flight(s) imported",
which now read "1 file" and "2 flights imported".
2026-10-08 00:01:43 +00:00

702 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureTransportFixtures, TRANSIT_JOURNEY, flightEmlFixture, FLIGHT_EML, FLIGHT_TO_IMPORT } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
import { ensureAirtrailConnection, disconnectAirtrail, AIRTRAIL_FLIGHTS, requireExtractor, allowEmlUploads } from './external'
import { tripTransportsContext, tripTransportsGuides } from '../../src/help/contexts/tripTransports'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Transports tab of a trip, keyed by the ids in
* `src/help/contexts/tripTransports.ts`. They run on the seeded "Autumn in
* Japan" with the transports `ensureTransportFixtures` adds up front, so the
* tab has a type chip per group and a planned connection to open; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripTransportsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
/**
* The connection the fixtures store on day 6, so no guide has to search for one
* first. Its card is named by its title, the two ends with an arrow between them.
*/
const JOURNEY = { title: TRANSIT_JOURNEY }
/** What plan-transit plans on day 1; the title the panel builds from the two ends. */
const PLANNED = 'Senso-ji Temple → teamLab Planets'
/** What add-transport creates. Both ends are searched, so the map has something to draw. */
const TAXI = {
title: 'Taxi to teamLab Planets',
from: { query: 'Senso-ji Temple Tokyo', match: /^Sens/ },
to: { query: 'teamLab Planets', match: /^teamLab/ },
}
/** A day card's head: number, weather, "Day n", date, its pills and the "+" with the chevron. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The "+" in a day's head; Add transport and Public transit are in the menu it opens. */
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
/** That menu: a popover of plain buttons, told apart from a tooltip by its role. */
const dayMenu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
async function openDayMenu(page: Page, n: number): Promise<void> {
await addToDay(page, n).click()
await expect(dayMenu(page).getByRole('button', { name: 'Add transport' })).toBeVisible()
await beat(page, 300)
}
/** A booking card in the tab: an <article> named by the booking's title. */
const card = (page: Page, title: string) => page.getByRole('article', { name: title }).first()
/** A card found by something it shows rather than its title, such as a booking code. */
const cardShowing = (page: Page, text: string) => page.getByRole('article').filter({ hasText: text }).first()
/** The two popovers of the tab's toolbar, each opened by its own button. */
const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getByRole('button', { name, exact: true })
const toolbarMenu = (page: Page, name: 'Filter' | 'View options') => toolbarButton(page, name).locator('xpath=../..').getByRole('menu')
/** A transport's row in the day plan; the whole row is the button that opens it. */
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
/** The booking a click on a row or a card opens, named by the booking's title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The Edit at the foot of that booking, which leads on to its form. */
const detailEdit = (page: Page, title: string) => bookingDetail(page, title).getByRole('button', { name: 'Edit', exact: true })
/** A block of the open dialog by its heading: every block is a <section> that opens with it. */
const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).first().locator('xpath=ancestor::section[1]')
/** The head band of the open dialog, where a booking's title is typed and its pills sit. */
const headBand = (page: Page) => modal(page).locator('header').first()
/** The Booking Type pill in that band, and the list it opens (a portal named after it). */
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */
const legMenu = (page: Page) =>
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last()
/** The travel-time connector between two stops of the open day. */
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
/** The transit search's stop boxes, and the transport form's location boxes. */
const stopBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search stop or station…').nth(n)
const locationBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search station, port, address…').nth(n)
/**
* Both pickers are an input in its own relative wrapper with the result list as
* the wrapper's second child, so one expression finds either one's suggestions.
*/
const suggestions = (box: Locator) => box.locator('xpath=../..').locator('> div').nth(1)
/** The transit stop picker's list is portaled onto the page, fixed under its input. */
const stopSuggestions = (page: Page) => page.locator('xpath=/html/body/div[contains(@class,"--z-toast")]').last()
/** A result of the transit search: the card's own toggle carries the times as its name. */
const itinerary = (page: Page, n: number) =>
modal(page).getByRole('button', { name: /^\d{1,2}:\d{2} – \d{1,2}:\d{2} / }).nth(n)
async function searchLocation(page: Page, box: Locator, query: string, match: RegExp): Promise<void> {
await box.click()
await typeInto(page, box, query)
const options = suggestions(box).locator('button')
await expect(options.first()).toBeVisible({ timeout: 25_000 })
// The suggestion that answers the query, not whatever came back first: this
// list is a live geocoder and its top hit for a landmark is as often a shop
// down the road, which then travels into the picture as the wrong address.
const wanted = options.filter({ hasText: match }).first()
await expect(wanted, `no suggestion for "${query}" matching ${match}`).toBeVisible({ timeout: 25_000 })
await wanted.click()
await expect(box).toHaveValue(match, { timeout: 15_000 })
}
/** The transit search offers the day's own stops as soon as the box has focus. */
async function pickStop(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
const option = stopSuggestions(page).getByRole('button', { name })
await expect(option).toBeVisible({ timeout: 20_000 })
await option.click()
await expect(box).toHaveValue(name, { timeout: 10_000 })
}
type ReservationRow = { id: number; title: string; confirmation_number: string | null }
async function deleteWhere(page: Page, matches: (r: ReservationRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
const list = Array.isArray(body) ? body : (body.reservations ?? [])
for (const r of list.filter(matches)) {
await page.request.delete(`/api/trips/${tripId}/reservations/${r.id}`)
}
}
const deleteByTitle = (page: Page, ...titles: string[]) => deleteWhere(page, r => titles.includes(r.title))
/** The imported flight's title is the extractor's; its booking code is the fixture's. */
const deleteByCode = (page: Page, code: string) => deleteWhere(page, r => r.confirmation_number === code)
/**
* What import-transport-file reads out of its e-ticket. The title is the
* mapper's (airline and number joined), so the form and the card are checked
* by what the fixture owns: the code, and the two airports the built-in table
* resolves from their IATA codes.
*/
const IMPORTED = { code: FLIGHT_TO_IMPORT.code, from: /\(HND\)$/, to: /\(FRA\)$/ }
/** The two AirportSelect boxes of a flight's form, departure first. */
const airportBoxes = (page: Page) => modal(page).getByPlaceholder('Airport code or city (e.g. FRA)')
/**
* What the AirTrail account holds (external.ts): two flights home on the last
* day that connect in Tokyo, which the picker offers to join, and the Lisbon
* weekend in spring for its Other flights. The joined booking's title is the
* mapper's route, the airport codes joined by arrows (airtrail.mapper.ts).
*/
const [OUT_LEG, HOME_LEG, , OTHER] = AIRTRAIL_FLIGHTS
const JOINED = { title: `${OUT_LEG.from} → ${OUT_LEG.to} → ${HOME_LEG.to}`, layover: OUT_LEG.to }
/** The toolbar's AirTrail button: an icon named like the picker it opens. */
const airtrailButton = (page: Page) => page.getByRole('button', { name: 'Import from AirTrail', exact: true })
/** The flight picker is a bare portal like the booking import; its title tells it apart. */
const airtrailPicker = (page: Page) => portalDialog(page, page.getByText('Import from AirTrail', { exact: true }))
/** A flight's row in the picker: a button named by the airline and the flight number. */
const flightRow = (page: Page, flightNumber: string) =>
airtrailPicker(page).getByRole('button', { name: new RegExp(`\\b${flightNumber}\\b`) })
/** The join tick under a connection's legs, and the framed group it belongs to. */
const joinToggle = (page: Page) =>
airtrailPicker(page).getByRole('button', { name: /^Import as one flight with a layover in / })
const connectionGroup = (page: Page) => joinToggle(page).locator('xpath=..')
const importCta = (page: Page) => airtrailPicker(page).getByRole('button', { name: /^Import \d+$/ })
/** Give a day's legs back to the day's own mode, whichever stop carries the one that was set. */
async function resetLegModes(page: Page, dayIndex: number): Promise<void> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: { id: number }[] }
for (const a of body.assignments ?? []) {
await page.request.put(`/api/trips/${tripId}/assignments/${a.id}/transport`, { data: { transport_mode: null } })
}
}
/** The day route toggle stays on until it is clicked again; it is only there while a day is open. */
async function switchRouteOff(page: Page): Promise<void> {
const route = page.getByRole('button', { name: 'Route', exact: true }).first()
if (await route.isVisible().catch(() => false)) {
await route.click()
await settle(page)
}
}
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> = {
'transports-list': {
guide: guide('transports-list'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
only(p => p.getByRole('button', { name: 'Transports', exact: true })),
{
// The menu with Train ticked, so the picture shows a filter in force.
prepare: async p => {
await toolbarButton(p, 'Filter').click()
await expect(toolbarMenu(p, 'Filter')).toBeVisible()
await toolbarMenu(p, 'Filter').getByRole('button', { name: /^Train\b/ }).click()
await expect(card(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
target: p => toolbarMenu(p, 'Filter'),
act: async p => {
await toolbarMenu(p, 'Filter').getByRole('button', { name: 'Reset filters' }).click()
await expect(card(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toBeVisible()
await settle(p)
},
target: p => toolbarMenu(p, 'View options'),
act: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toHaveCount(0)
await settle(p)
},
},
only(p => card(p, FLIGHT.title)),
{
prepare: async p => {
await card(p, FLIGHT.title).getByRole('button', { name: 'Delete' }).click()
await expect(p.getByText('Delete booking?')).toBeVisible()
await beat(p, 300)
},
target: p => portalDialog(p, p.getByText('Delete booking?')),
// Cancel, not Confirm: the flight has to survive for the next guide.
act: async p => {
await portalDialog(p, p.getByText('Delete booking?')).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByText('Delete booking?')).toHaveCount(0)
await expect(card(p, FLIGHT.title)).toBeVisible()
},
},
],
cleanup: async p => {
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
'add-transport': {
guide: guide('add-transport'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Add transport' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Add transport' }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typePill(p).click()
await expect(typeMenu(p)).toBeVisible()
await beat(p, 300)
},
target: typeMenu,
act: async p => {
await typeMenu(p).getByRole('button', { name: /^Taxi$/ }).first().click()
await expect(typeMenu(p)).toHaveCount(0)
await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), TAXI.title)
await settle(p)
},
target: headBand,
},
{
prepare: async p => {
await searchLocation(p, locationBox(p, 0), TAXI.from.query, TAXI.from.match)
await searchLocation(p, locationBox(p, 1), TAXI.to.query, TAXI.to.match)
await settle(p)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
},
{
prepare: async p => {
// Between the two stops of day 1, which the fixtures give 09:30 and 13:00.
await modal(p).getByPlaceholder('00:00').nth(0).fill('12:00')
await modal(p).getByPlaceholder('00:00').nth(1).fill('12:30')
await settle(p)
},
target: p => modal(p).getByText('Date', { exact: true }).locator('xpath=../..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(transportRow(p, new RegExp(TAXI.title))).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, TAXI.title),
},
'import-transport-file': {
guide: guide('import-transport-file'),
// See import-booking-file: the extractor and no addon, and the mail has to
// be an allowed file type for the review to keep it under Files.
start: async p => {
await requireExtractor(p.request)
await allowEmlUploads(p.request, true)
await openTrip(p, { tab: 'transports' })
},
steps: [
...importSteps(flightEmlFixture, FLIGHT_EML),
{
prepare: async p => {
await expect(importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
await settle(p)
},
target: p => importTask(p, FLIGHT_EML),
act: async p => {
await importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible({ timeout: 20_000 })
await expect(modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')).toHaveValue(/203/)
// Both airports came out of the built-in table by their codes: the
// airport boxes are the two with an airport code in brackets.
await expect(airportBoxes(p).nth(0)).toHaveValue(IMPORTED.from)
await expect(airportBoxes(p).nth(1)).toHaveValue(IMPORTED.to)
await expect(modal(p).getByPlaceholder('e.g. ABC12345')).toHaveValue(IMPORTED.code)
await expect(modal(p).getByText(FLIGHT_EML)).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await expect(cardShowing(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await dismissImportTask(p, FLIGHT_EML)
await allowEmlUploads(p.request, false)
await p.evaluate(() => {
try { localStorage.removeItem('trek.bg-import-tasks') } catch { /* nothing was stored */ }
})
await deleteByCode(p, IMPORTED.code)
await deleteTripFiles(p, FLIGHT_EML)
},
},
'plan-transit': {
guide: guide('plan-transit'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Public transit' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Public transit' }).click()
await expect(modal(p).getByRole('heading', { name: 'Public transit' })).toBeVisible()
await settle(p)
},
},
{
// The quick picks open on focus, so the day's own stops are in the picture.
prepare: async p => {
await stopBox(p, 0).click()
await expect(stopSuggestions(p).getByRole('button', { name: 'Senso-ji Temple' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
act: async p => {
await pickStop(p, stopBox(p, 0), 'Senso-ji Temple')
await pickStop(p, stopBox(p, 1), 'teamLab Planets')
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeEnabled()
await settle(p)
},
},
{
// Leave when Senso-ji ends. The search opens on 09:00, and a connection
// from there would land in the day before the stop it starts from.
prepare: async p => {
const time = modal(p).getByPlaceholder('00:00')
await time.fill('11:00')
await time.blur()
await expect(time).toHaveValue('11:00')
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Depart' }).locator('xpath=ancestor::div[3]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Fewer transfers' }).click()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Tram' }).locator('xpath=ancestor::div[2]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
prepare: async p => {
await itinerary(p, 0).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => itinerary(p, 0).locator('xpath=..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect(transportRow(p, /Senso-ji Temple.*teamLab Planets/)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, PLANNED),
},
'change-transit-route': {
guide: guide('change-transit-route'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
target: p => card(p, JOURNEY.title),
act: async p => {
await card(p, JOURNEY.title).click()
await expect(bookingDetail(p, JOURNEY.title)).toBeVisible()
await settle(p)
},
},
only(p => bookingDetail(p, JOURNEY.title).getByText('Itinerary', { exact: true }).locator('xpath=ancestor::section[1]')),
{
target: p => bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }),
act: async p => {
await bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }).click()
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await itinerary(p, 1).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
// The connection now carries another itinerary: put the canned one back.
cleanup: async p => {
await deleteByTitle(p, JOURNEY.title)
await ensureTransportFixtures(p.request)
},
},
'leg-travel-mode': {
guide: guide('leg-travel-mode'),
// The route tools only render for the day that is open, and closing its
// details panel closes the day again, so day 1 stays open here.
start: p => openTripOnDay(p, 1),
steps: [
{
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(connector),
{
prepare: async p => {
await connector(p).click()
await expect(legMenu(p)).toBeVisible()
await beat(p, 300)
},
target: legMenu,
},
{
target: p => legMenu(p).getByRole('button', { name: 'Walking' }),
act: async p => {
await legMenu(p).getByRole('button', { name: 'Walking' }).click()
// The menu closing is the change landing. Not the icon on the first
// leg: the day re-routes when a mode changes, and which leg comes
// first is not fixed while it does.
await expect(legMenu(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
await resetLegModes(p, 0)
},
},
'edit-transport': {
guide: guide('edit-transport'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row),
act: async p => {
await transportRow(p, FLIGHT.row).click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
target: p => detailEdit(p, FLIGHT.title),
act: async p => {
await detailEdit(p, FLIGHT.title).click()
await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible()
await settle(p)
},
},
only(p => block(p, 'Route')),
{
// Escape rather than Update: nothing about the seeded flight may change.
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: closeModal,
},
],
},
'transport-on-map': {
guide: guide('transport-on-map'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }),
act: async p => {
await transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }).click()
await expect(transportRow(p, FLIGHT.row).getByRole('button', { name: 'Hide booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
// The map is on Tokyo, so the Frankfurt end is off screen and the Haneda
// one, drawn second, is the marker the reader can actually see.
only(p => p.locator('.trek-endpoint-marker').last()),
{
prepare: async p => {
await p.locator('.trek-endpoint-marker').last().click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, FLIGHT.title),
act: async p => {
await p.keyboard.press('Escape')
await expect(bookingDetail(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Show all booking routes' }),
act: async p => {
await p.getByRole('button', { name: 'Show all booking routes' }).click()
await expect(p.getByRole('button', { name: 'Hide all booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
{
// Day 6 carries the stored connection, and it has the two located stops
// the route tools need, so its Route toggle is there to switch on.
prepare: async p => {
await selectDay(p, 6)
await expect(p.getByRole('button', { name: 'Route', exact: true }).first()).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await settle(p)
await p.waitForTimeout(1500)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
const { tripId } = seededTrip()
await p.evaluate(id => {
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
}, tripId)
},
},
'airtrail-import': {
guide: guide('airtrail-import'),
// The connection is made here and taken away in cleanup, so the hero and
// the guides before this one keep a toolbar without the AirTrail button.
start: async p => {
await ensureAirtrailConnection(p.request)
await openTrip(p, { tab: 'transports' })
// The addon list and the connection probe are read once, when the app
// loads. One more load now that the connection exists, and the button
// is waited for, so a missing connection fails here and not at step 1.
await p.reload()
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
steps: [
{
target: airtrailButton,
act: async p => {
await airtrailButton(p).click()
await expect(airtrailPicker(p)).toBeVisible()
// The rows come from the AirTrail instance itself, over the LAN.
await expect(flightRow(p, HOME_LEG.flightNumber)).toBeVisible({ timeout: 30_000 })
await expect(flightRow(p, OTHER.flightNumber)).toBeVisible()
await settle(p)
},
},
// The whole picker: During this trip with its ticked rows, Other flights below.
only(airtrailPicker),
// A row under Other flights, unticked, as the text says those are. Not
// clicked: a spring flight imported into an autumn trip would be clamped
// onto day 1 and spoil the result picture.
only(p => flightRow(p, OTHER.flightNumber)),
{
// Joined by default: the tick under the two legs is on before anyone touches it.
prepare: async p => {
await expect(joinToggle(p)).toContainText(`Import as one flight with a layover in ${JOINED.layover}`)
await expect(joinToggle(p).locator('svg.lucide-check')).toBeVisible()
},
target: connectionGroup,
},
{
// The two flights home and nothing else are ticked.
prepare: async p => {
await expect(importCta(p)).toHaveText('Import 2')
},
target: importCta,
act: async p => {
await importCta(p).click()
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
// The toast counts flight ids, so the joined pair reports two.
const toast = p.getByText('2 flights imported', { exact: true })
await expect(toast).toBeVisible({ timeout: 15_000 })
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
// The toast fades after three seconds; the card's picture and the
// result are taken without a half-faded message in the corner.
await expect(toast).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
{
prepare: async p => {
await expect(card(p, JOINED.title).getByText('AirTrail', { exact: true })).toBeVisible()
},
target: p => card(p, JOINED.title),
},
],
cleanup: async p => {
// By every title the import can produce: the joined route, or one
// flight number per card when the join was off.
await deleteByTitle(p, JOINED.title, ...AIRTRAIL_FLIGHTS.map(f => f.flightNumber))
await disconnectAirtrail(p.request)
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTransportFixtures(request)
})
test('every registered transports guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTransportsContext.guides].sort())
})
test('hero: trip-transports', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripTransportsContext.id, p => openTrip(p, { tab: 'transports' }))
})
for (const id of tripTransportsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}