mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
64b190862e
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.
502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
import { test, expect, type Locator, type Page } from '@playwright/test'
|
|
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
|
|
import { seededTrip, ensureMapFixtures } from './fixtures'
|
|
import { openTrip, openTripOnDay, selectDay, modal } from './trip-shared'
|
|
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
|
|
import type { HelpGuide } from '../../src/help/types'
|
|
|
|
/**
|
|
* Actions for the map in the middle of a trip's plan, keyed by the ids in
|
|
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
|
|
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
|
|
* toggle has a second route to draw inside the frame; what a guide changes it
|
|
* puts back in `cleanup`, so the next one starts the same.
|
|
*/
|
|
|
|
const guide = (id: string): HelpGuide => {
|
|
const g = tripMapGuides.find(x => x.id === id)
|
|
if (!g) throw new Error(`no registered guide "${id}"`)
|
|
return g
|
|
}
|
|
|
|
/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */
|
|
const map = (page: Page) => page.locator('#trek-map')
|
|
/** A cluster bubble. Its only text is the number of places folded into it. */
|
|
const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom')
|
|
/**
|
|
* A place pin. Cluster bubbles and the explore results are markers too, so both
|
|
* are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin
|
|
* carries an `aria-label` (a place marker is a bare divIcon with no name at all,
|
|
* which is why a specific place can only be found through its order badge).
|
|
*/
|
|
const pins = (page: Page) =>
|
|
page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
|
|
/** The pin of the stop that is number `n` in the open day. */
|
|
const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first()
|
|
/** An explore result: the one kind of marker with an accessible name. */
|
|
const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]')
|
|
/** The end of a drawn booking route. */
|
|
const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker')
|
|
/** The card that follows the pointer over a pin. */
|
|
const hoverCard = (page: Page) => page.getByTestId('tooltip')
|
|
/**
|
|
* A category of the explore pill. The phone portal renders a second copy of the
|
|
* whole pill, hidden at this width, so this goes by role rather than by
|
|
* `aria-label`, which would match both.
|
|
*/
|
|
const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true })
|
|
/** The pill itself: the nearest block holding the first category and the last. */
|
|
const categoryRow = (page: Page) =>
|
|
category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]')
|
|
/** The route icon on a booking's row, and the one in the toolbar above the days. */
|
|
const connection = (page: Page, shown: boolean) =>
|
|
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`)
|
|
const allConnections = (page: Page, shown: boolean) =>
|
|
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`)
|
|
const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel')
|
|
/** The sheet a booking's endpoint opens. Its own portal, and the only Close on the plan. */
|
|
const sheetClose = (page: Page) => page.getByRole('button', { name: 'Close', exact: true })
|
|
/** A row of the places column. */
|
|
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
|
|
/**
|
|
* The card that opens under the map when a place is selected. By its own test
|
|
* id, not by a button in it: the collection button reads Save to Collection or
|
|
* Saved depending on whether that place is already in a list, and the seed puts
|
|
* the three Kyoto places in one, so a locator on the label matches for some
|
|
* pins and for others not.
|
|
*/
|
|
const inspector = (page: Page) => page.getByTestId('inspector-scroll')
|
|
/** The details panel's collapse: it keeps the day selected, where its close would drop it. */
|
|
const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)')
|
|
|
|
/**
|
|
* A spot on the map with nothing on it: right of the days column, left of the
|
|
* explore pill, and clear of everything the plan floats over the lower half of
|
|
* the map. Map-relative, so it is passed as a click `position`.
|
|
*/
|
|
const EMPTY = { x: 470, y: 80 }
|
|
|
|
/**
|
|
* The first marker of a set the pointer can actually reach. Leaflet stacks
|
|
* markers that overlap and the two columns float over the edges of the map, so
|
|
* `.first()` is regularly one that nothing can hover, let alone click. Explore
|
|
* results are searched from the end because Leaflet draws the southernmost
|
|
* marker on top.
|
|
*/
|
|
async function reachable(locator: Locator, fromEnd = false): Promise<number> {
|
|
const total = await locator.count()
|
|
for (let i = 0; i < total; i++) {
|
|
const index = fromEnd ? total - 1 - i : i
|
|
try {
|
|
await locator.nth(index).hover({ trial: true, timeout: 1500 })
|
|
return index
|
|
} catch {
|
|
// Covered by another marker or by one of the floating columns.
|
|
}
|
|
}
|
|
throw new Error(`no reachable marker among ${total}`)
|
|
}
|
|
|
|
/**
|
|
* Move the map, so "Search this area" appears. Leaflet's own keyboard panning
|
|
* 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> {
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Open the trip framed on day 1's two Tokyo stops.
|
|
*
|
|
* The frame is what the three guides starting here have in common: one
|
|
* right-clicks a fixed point on the map and needs a street under it, one asks
|
|
* the map what it can see for cafes, one puts imagery under the pins. The
|
|
* whole-trip fit spans Japan, where that fixed point is the Sea of Japan as
|
|
* often as it is a street and the address look-up comes back empty, and zooming
|
|
* into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by
|
|
* chance. Day 1 is the one frame the seed guarantees is over a city.
|
|
*
|
|
* The details panel is folded rather than closed: closing it deselects the day
|
|
* (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to
|
|
* the whole trip.
|
|
*/
|
|
async function openOnTokyo(page: Page): Promise<void> {
|
|
await openTripOnDay(page, 1)
|
|
await collapseDayDetails(page).last().click()
|
|
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
|
|
await settle(page)
|
|
}
|
|
|
|
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
|
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 }[]
|
|
const places = Array.isArray(body) ? body : (body.places ?? [])
|
|
for (const place of places.filter(x => names.includes(x.name))) {
|
|
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The markers the running guide works on. Which one is reachable is decided
|
|
* once, by `reachable`, and the steps that follow reuse the answer; `pin` holds
|
|
* a place pin, an explore result or a route endpoint, whichever the guide is
|
|
* about. `found` is the name of the explore result a guide took into the trip,
|
|
* so `cleanup` can take exactly that one out again.
|
|
*/
|
|
let bubble = 0
|
|
let pin = 0
|
|
let found = ''
|
|
|
|
const NEW_PLACE = 'Lunch stop'
|
|
|
|
const SCRIPTS: Record<string, GuideScript> = {
|
|
'map-markers': {
|
|
guide: guide('map-markers'),
|
|
start: async p => {
|
|
await openTrip(p)
|
|
await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 })
|
|
await settle(p)
|
|
bubble = await reachable(clusters(p))
|
|
},
|
|
steps: [
|
|
{
|
|
target: p => clusters(p).nth(bubble),
|
|
act: async p => {
|
|
await clusters(p).nth(bubble).click()
|
|
await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2)
|
|
await settle(p)
|
|
pin = await reachable(pins(p))
|
|
},
|
|
},
|
|
{
|
|
// captureGuide hovers the target itself; this only makes sure the card
|
|
// is up before the shot rather than a frame after it.
|
|
prepare: async p => {
|
|
await pins(p).nth(pin).hover()
|
|
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
|
|
},
|
|
target: p => pins(p).nth(pin),
|
|
},
|
|
{
|
|
// The picture worth having is the panel the click opens, not the pin
|
|
// again, so the click is the preparation and the panel is the target.
|
|
prepare: async p => {
|
|
await pins(p).nth(pin).click()
|
|
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
target: p => inspector(p).locator('xpath=..'),
|
|
act: async p => {
|
|
await map(p).click({ position: EMPTY })
|
|
await expect(inspector(p)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await selectDay(p, 1)
|
|
// The panel IS the selection, so it is folded away rather than closed:
|
|
// closing it would take the numbers off the pins again.
|
|
await collapseDayDetails(p).last().click()
|
|
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
target: p => badged(p, 1),
|
|
},
|
|
{
|
|
// Picture only, like the other drags in this family: HTML5 drag is what
|
|
// the map hands the day plan, and a synthesised one does not start it.
|
|
prepare: async p => {
|
|
pin = await reachable(pins(p))
|
|
},
|
|
target: p => pins(p).nth(pin),
|
|
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
|
|
},
|
|
],
|
|
},
|
|
'map-nearby-places': {
|
|
guide: guide('map-nearby-places'),
|
|
// A search covers what is on screen, and day 1's fit is the two Tokyo stops
|
|
// with the city between them: enough of a district for the sixty hits to
|
|
// spread over the frame instead of piling up in the middle of it.
|
|
start: openOnTokyo,
|
|
steps: [
|
|
{ target: categoryRow },
|
|
{
|
|
target: p => category(p, 'Cafés'),
|
|
act: async p => {
|
|
await category(p, 'Cafés').click()
|
|
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await panMap(p)
|
|
await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 })
|
|
},
|
|
target: p => p.getByRole('button', { name: 'Search this area' }),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Search this area' }).click()
|
|
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
pin = await reachable(poiPins(p), true)
|
|
},
|
|
target: p => poiPins(p).nth(pin),
|
|
act: async p => {
|
|
await poiPins(p).nth(pin).click()
|
|
const name = modal(p).getByPlaceholder('e.g. Eiffel Tower')
|
|
await expect(name).not.toHaveValue('', { timeout: 20_000 })
|
|
// Which places OpenStreetMap answers with is never the same twice, so
|
|
// the name is read off the form rather than assumed, and `cleanup`
|
|
// deletes exactly what was created.
|
|
found = await name.inputValue()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{ target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]') },
|
|
{
|
|
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(row(p, found)).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: p => deleteByName(p, found),
|
|
},
|
|
'map-add-place': {
|
|
guide: guide('map-add-place'),
|
|
start: openOnTokyo,
|
|
steps: [
|
|
{
|
|
target: map,
|
|
// The map fills the frame, so the ring would sit nowhere in particular:
|
|
// park the pointer on the spot the right-click is about to land on.
|
|
hover: async p => {
|
|
const box = await map(p).boundingBox()
|
|
if (!box) throw new Error('the map has no box')
|
|
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
|
|
},
|
|
act: async p => {
|
|
await map(p).click({ button: 'right', position: EMPTY })
|
|
await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
// The address is a look-up of the coordinates and lands after the form.
|
|
await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('')
|
|
await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 })
|
|
await settle(p)
|
|
},
|
|
target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'),
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
|
|
await settle(p)
|
|
},
|
|
target: p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::form[1]'),
|
|
},
|
|
{
|
|
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(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: p => deleteByName(p, NEW_PLACE),
|
|
},
|
|
'map-satellite': {
|
|
guide: guide('map-satellite'),
|
|
start: async p => {
|
|
await openOnTokyo(p)
|
|
pin = await reachable(pins(p))
|
|
},
|
|
steps: [
|
|
{
|
|
target: p => p.getByRole('button', { name: 'Switch to satellite view' }),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Switch to satellite view' }).click()
|
|
await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible()
|
|
// The imagery is raster tiles from a third party; give them a moment
|
|
// or the picture catches the grey underneath.
|
|
await expect(p.locator('#trek-map img.leaflet-tile').first()).toBeVisible({ timeout: 30_000 })
|
|
await settle(p)
|
|
await p.waitForTimeout(1500)
|
|
},
|
|
},
|
|
{ target: p => pins(p).nth(pin) },
|
|
{
|
|
target: p => p.getByRole('button', { name: 'Switch to map view' }),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Switch to map view' }).click()
|
|
await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
// The layer is kept on the account, not on the trip: left on satellite it
|
|
// would be the basemap of every picture taken after this guide.
|
|
cleanup: async p => {
|
|
await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } })
|
|
},
|
|
},
|
|
'map-whole-trip': {
|
|
guide: guide('map-whole-trip'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{
|
|
target: p => p.getByTestId('trip-overview-pill'),
|
|
act: async p => {
|
|
await p.getByTestId('trip-overview-pill').click()
|
|
// One router request per leg, paced, so the card takes its time.
|
|
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
// The total carries an … until every leg has answered; a picture taken
|
|
// before that shows a partial sum.
|
|
prepare: async p => {
|
|
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
|
|
await settle(p)
|
|
},
|
|
target: overviewPanel,
|
|
},
|
|
{
|
|
target: p => overviewPanel(p).getByRole('button').first(),
|
|
act: async p => {
|
|
await overviewPanel(p).getByRole('button').first().click()
|
|
await settle(p)
|
|
},
|
|
},
|
|
// No act: the guide ends with the whole trip on the map, which is what
|
|
// the result picture is of. `cleanup` presses the button.
|
|
{ target: p => p.getByRole('button', { name: 'Hide whole trip' }) },
|
|
],
|
|
cleanup: async p => {
|
|
const off = p.getByRole('button', { name: 'Hide whole trip' })
|
|
if (await off.isVisible().catch(() => false)) await off.click()
|
|
},
|
|
},
|
|
'map-booking-routes': {
|
|
guide: guide('map-booking-routes'),
|
|
start: p => openTrip(p),
|
|
steps: [
|
|
{ target: p => connection(p, false).first() },
|
|
{
|
|
prepare: async p => {
|
|
await connection(p, false).first().click()
|
|
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1)
|
|
await settle(p)
|
|
pin = await reachable(endpoints(p))
|
|
},
|
|
target: p => endpoints(p).nth(pin),
|
|
act: async p => {
|
|
await endpoints(p).nth(pin).click()
|
|
await expect(sheetClose(p)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => sheetClose(p).locator('xpath=ancestor::div[2]'),
|
|
// The booking sheet is its own portal and ignores Escape.
|
|
act: async p => {
|
|
await sheetClose(p).click()
|
|
await expect(sheetClose(p)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => allConnections(p, false),
|
|
act: async p => {
|
|
await allConnections(p, false).click()
|
|
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => allConnections(p, true),
|
|
act: async p => {
|
|
await allConnections(p, true).click()
|
|
await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
// The per-trip choice lives in this browser, not on the server, so it would
|
|
// otherwise carry into every guide that runs after it in the same context.
|
|
cleanup: async p => {
|
|
const { tripId } = seededTrip()
|
|
await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId)
|
|
},
|
|
},
|
|
}
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await ensureMapFixtures(request)
|
|
})
|
|
|
|
test('every registered map guide has a script, and only those', async () => {
|
|
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort())
|
|
})
|
|
|
|
test('hero: trip-map', async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureHero(page, tripMapContext.id, p => openTrip(p))
|
|
})
|
|
|
|
for (const id of tripMapContext.guides) {
|
|
test(`pictures: ${id}`, async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureGuide(page, SCRIPTS[id])
|
|
})
|
|
}
|