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, dialog } from './trip-shared' import { ensureDawarichConnection, disconnectDawarich } from './external' 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])') /** A place pin wearing the place's photo rather than its category icon. */ const photoPins = (page: Page) => pins(page).filter({ has: page.locator('img') }) /** * The pin the markers guide works on after its bubble opened, marked when it * was chosen: a photo pin the pointer can reach, or any reachable pin when no * photo has landed. */ const markerPin = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[data-help-pin]') /** 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 round Dawarich button under the whole-trip one; its label is its state. */ const trailPill = (page: Page) => page.getByTestId('dawarich-trail-pill') /** * The recorded route. Leaflet writes the dash pattern onto the path, and "6 5" * is this layer's alone: the pending booking routes use "6, 6", the picked * alternative "2 7", so the dashed days are the one thing on the map with it. */ const trail = (page: Page) => page.locator('#trek-map path[stroke-dasharray="6 5"]') /** * The GL renderer. There is no `#trek-map` on it: `MapViewGL` mounts a bare * `div.w-full.h-full` and MapLibre puts its own canvas inside, so the compass * guide goes by that class. The map itself is reached through * `window.__trek_map`, which `MapViewGL` publishes on build for exactly this. */ const glCanvas = (page: Page) => page.locator('.maplibregl-canvas') /** * The compass button and the frosted pill around it. The planner renders the * pill twice, once in the desktop cluster beside the category row and once for * phones, `md:hidden` at this width; `getByRole` leaves the hidden copy out. */ const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' }) const compassPill = (page: Page) => resetNorth(page).locator('xpath=..') /** * The booking detail an endpoint opens: a DialogShell portal, so `dialog` finds * its panel, and its Close is the round X in the head band. */ const bookingDetail = (page: Page) => dialog(page) const bookingDetailClose = (page: Page) => bookingDetail(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 day card's header in the days column, the drop target of a dragged pin. */ const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }) /** * Fold the day details away and take the focus off the fold button. The * button keeps the focus after the click, and TREK's tooltip shows on focus, * so its Expand would otherwise stand at the foot of every later picture. */ async function foldDayDetails(page: Page): Promise { await collapseDayDetails(page).last().click() await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) } /** * The part of the map between the two floating columns, in page pixels. A * pin under a column can still be hovered at its edge, but a picture of it is * a picture of the column. */ async function openCorridor(page: Page): Promise<{ left: number; right: number }> { const box = await map(page).boundingBox() if (!box) throw new Error('the map has no box') return { left: box.x + 420, right: box.x + box.width - 420 } } /** Like `reachable`, but the pin also has to stand in the open part of the map when one does. */ async function reachableInView(page: Page, locator: Locator, fromEnd = false): Promise { const { left, right } = await openCorridor(page) const total = await locator.count() for (let i = 0; i < total; i++) { const index = fromEnd ? total - 1 - i : i const b = await locator.nth(index).boundingBox() if (!b || b.x < left || b.x + b.width > right || b.y < 260) continue try { await locator.nth(index).hover({ trial: true, timeout: 1500 }) return index } catch { // Covered by another marker. } } return reachable(locator, fromEnd) } /** * 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 { 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}`) } /** * Drag the map the way a reader would, so "Search this area" appears. * * 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, by: { x: number; y: number } = { x: -176, y: -72 }): Promise { 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 + (by.x * i) / 8, from.y + (by.y * i) / 8, { 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 { await openTripOnDay(page, 1) await foldDayDetails(page) await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 }) await settle(page) } /** * Which renderer draws the planner and the journals. On the account, not on * the trip: `MapViewAuto` reads `settings.map_provider`, and `loadSettings` * runs on every page load, so a `page.goto` after this sees the new map. */ async function setMapProvider(page: Page, provider: 'leaflet' | 'maplibre-gl'): Promise { const res = await page.request.post('/api/settings/bulk', { data: { settings: { map_provider: provider } } }) if (!res.ok()) throw new Error(`could not set map_provider=${provider}: ${res.status()} ${await res.text()}`) } /** The slice of the GL map the compass guide reads and moves, on `window.__trek_map`. */ interface TrekGlMap { getBearing: () => number getPitch: () => number loaded: () => boolean areTilesLoaded: () => boolean isMoving: () => boolean easeTo: (o: { bearing: number; pitch: number; duration: number }) => unknown } /** Wait until the GL map has its style, its tiles and a still camera. */ async function glMapIdle(page: Page): Promise { await page.waitForFunction( () => { const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map return !!m && m.loaded() && m.areTilesLoaded() && !m.isMoving() }, undefined, { timeout: 60_000 }, ) } /** * Wait until the raster basemap has actually painted. * * `toBeVisible()` on a tile is not enough: Leaflet puts the in the DOM * with its position and its size before the byte arrives, so a picture taken on * that signal catches the grey underneath. A tile counts only once the browser * has decoded it, and a screenful needs several of them. */ async function rasterTilesPainted(page: Page, atLeast = 6): Promise { await page.waitForFunction( min => { const tiles = Array.from(document.querySelectorAll('#trek-map img.leaflet-tile')) const painted = tiles.filter(t => { const img = t as HTMLImageElement return img.complete && img.naturalWidth > 0 }) return painted.length >= min }, atLeast, { timeout: 60_000 }, ) } async function deleteByName(page: Page, ...names: string[]): Promise { 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 = { 'map-lock': { guide: guide('map-lock'), start: openOnTokyo, steps: [ { target: p => p.getByTestId('map-lock-pill'), act: async p => { await p.getByTestId('map-lock-pill').click() await expect(p.getByRole('button', { name: 'Let the map follow the selection' })).toBeVisible() await settle(p) }, }, ], cleanup: async p => { const unlock = p.getByRole('button', { name: 'Let the map follow the selection' }) if (await unlock.count()) await unlock.click() }, }, '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) // The pin's own photo is a thumb cut after the page has loaded, and // until it lands the pin is a bare icon beside a card that already // shows the photo. Bounded: a place with no photo anywhere stays an icon. await expect.poll(() => photoPins(p).count(), { timeout: 20_000 }).toBeGreaterThan(0).catch(() => {}) await settle(p) // Marked rather than counted: more thumbs keep landing, and an index // among the photo pins would point at another pin a moment later. const photoPin = await reachable(photoPins(p)).catch(() => -1) const chosen = photoPin >= 0 ? photoPins(p).nth(photoPin) : pins(p).nth(await reachable(pins(p))) await chosen.evaluate(el => el.setAttribute('data-help-pin', '')) }, }, { // 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 markerPin(p).hover() await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 }) }, target: markerPin, }, { // 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 markerPin(p).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 foldDayDetails(p) 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. // Day 1's second stop onto day 3, the first empty day: a drop there // plainly plans something new, where one onto a day that already holds // the place would read as a move. prepare: async p => { await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 }) await settle(p) }, target: p => badged(p, 2), dropTo: p => dayHeader(p, 3).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 => { // Towards the right. The search covers the whole map, the part under // the two columns included, and on day 1's frame most of the first // answer lay under the days column; dragged this way, the west of the // view is open map when it is searched again, and the new pins are // where a reader can see them. await panMap(p, { x: 440, y: 96 }) 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 reachableInView(p, 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) }, }, // The whole dialog: the name is typed in its head band, outside the form. { target: p => dialog(p) }, { 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 => dialog(p), }, { 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, and the switch is // done long before they are on screen. await rasterTilesPainted(p) await settle(p) await p.waitForTimeout(1500) }, }, { prepare: p => rasterTilesPainted(p), target: p => pins(p).nth(pin) }, { prepare: p => rasterTilesPainted(p), 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) // The text's second half: the wheel over Tokyo. With the whole trip // shown, the camera stays on all of it after the pick (the fit that // follows takes in every day's line), and there a day of ten // kilometres is a few pixels under a bubble; four notches bring days // 1 and 2 out side by side. Tokyo is the easternmost bubble of the seed. const boxes = await clusters(p).evaluateAll(els => els.map(el => { const r = el.getBoundingClientRect() return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })) const tokyo = boxes.sort((a, b) => b.x - a.x)[0] if (!tokyo) throw new Error('no bubble on the map to zoom in on') await p.mouse.move(tokyo.x, tokyo.y) for (let i = 0; i < 4; i++) { await p.mouse.wheel(0, -100) await p.waitForTimeout(450) } 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(bookingDetailClose(p)).toBeVisible({ timeout: 15_000 }) await settle(p) }, }, { // The whole detail, head band to footer: the text walks through all of it. target: bookingDetail, act: async p => { await bookingDetailClose(p).click() await expect(modal(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) }, }, 'map-dawarich-trail': { guide: guide('map-dawarich-trail'), // Day 1 in Tokyo: at the whole-trip zoom the recording is one dashed line // along the Shinkansen and the walks through the cities vanish under the // pins, so the map is framed on a day before the button is pressed; // nothing fits the map to the trail. start: openOnTokyo, steps: [ { target: trailPill, act: async p => { await trailPill(p).click() // One read of the trip's dates from the Dawarich, thinned to 600 // points a day. The label is the reliable signal: anything but Hide // (nothing recorded, unreachable, offline) is not a picture worth // taking, so the wait fails on it rather than shooting an empty map. await expect(trailPill(p)).toHaveAttribute('aria-label', 'Hide recorded route', { timeout: 120_000 }) await expect(trail(p).first()).toBeAttached({ timeout: 30_000 }) await settle(p) await p.waitForTimeout(600) }, }, { // The whole map: the dashed day under the planned route, with the // pointer parked on an empty spot so no pin's card is in the picture. target: map, 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) }, }, { target: p => p.getByTestId('trip-overview-pill'), act: async p => { await p.getByTestId('trip-overview-pill').click() // One router request per leg, paced; the total carries an … until // every leg has answered, and the result picture waits for that. await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 }) await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 }) await settle(p) }, }, ], cleanup: async p => { const off = p.getByRole('button', { name: 'Hide whole trip' }) if (await off.isVisible().catch(() => false)) await off.click() if ((await trailPill(p).getAttribute('aria-pressed').catch(() => null)) === 'true') await trailPill(p).click() // Per trip and per browser session; the test's context ends here, but // be explicit rather than trust that. const { tripId } = seededTrip() await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId) }, }, 'map-compass': { guide: guide('map-compass'), // The compass only exists on the GL renderers, so the guide switches the // account to MapLibre GL (no token, OpenFreeMap tiles) before opening the // trip, and `cleanup` puts Leaflet back: every other map guide goes by // `#trek-map`, which the GL map does not have. start: async p => { await setMapProvider(p, 'maplibre-gl') await openTrip(p) // MapViewAuto shows Leaflet while the GL chunk loads, then swaps; WebGL // runs on SwiftShader headless at 1920×1080×2, so the style, the vector // tiles from tiles.openfreemap.org and the day-1 framing take their time. await expect(glCanvas(p)).toBeVisible({ timeout: 60_000 }) await expect(resetNorth(p)).toBeVisible({ timeout: 30_000 }) await glMapIdle(p) await settle(p) // `settle` counts `` loads; a WebGL canvas has none, so give the // tiles a moment to paint or the picture catches the blank canvas. await p.waitForTimeout(2000) }, steps: [ { // The turn itself is the map's own camera move rather than a synthetic // right-button drag: a drag has to start on bare canvas, and the pins, // the two floating columns and the explore pill all sit over it. 35° // is enough for the streets to lie visibly askew and the arrow to lean. prepare: async p => { await p.evaluate(() => { const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map if (!m) throw new Error('no GL map on the page') m.easeTo({ bearing: 35, pitch: 0, duration: 0 }) }) await p.waitForFunction(() => { const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map return !!m && Math.abs(m.getBearing() - 35) < 0.5 && !m.isMoving() }) await settle(p) }, target: compassPill, }, { target: compassPill, act: async p => { await resetNorth(p).click() // `easeTo` takes 300 ms; the compass is only upright once it has landed. await p.waitForFunction(() => { const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map return !!m && Math.abs(m.getBearing()) < 0.5 && Math.abs(m.getPitch()) < 0.5 && !m.isMoving() }) await settle(p) }, }, ], // The renderer is kept on the account, not on the trip: left on MapLibre it // would be the map of every picture taken after this guide, and every // `#trek-map` locator in the other guides would find nothing. cleanup: p => setMapProvider(p, 'leaflet'), }, } // ── Run ─────────────────────────────────────────────────────────────────────── test.describe.configure({ mode: 'serial' }) test.beforeAll(async ({ request }) => { await ensureMapFixtures(request) // The recorded route: the addon on, the signed-in user connected to the // Dawarich from media.env, the synthetic recording of the trip uploaded. await ensureDawarichConnection(request) }) test.afterAll(async ({ request }) => { // Instance-wide, not trip-scoped: while the addon is on, every trip map // carries the round Dawarich button in its bottom-right corner. This file's // pictures show it on purpose; the other trip screens' do not, so it goes // off again here, whatever happened in between. The renderer likewise: the // compass guide puts Leaflet back in its cleanup, but a guide that fails // never reaches its cleanup, and every `#trek-map` locator in the files that // run after this one would find a GL canvas instead. await disconnectDawarich(request) await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } }) }) 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]) }) }