diff --git a/client/e2e/help/README.md b/client/e2e/help/README.md index 5716b2c69..992efd8f5 100644 --- a/client/e2e/help/README.md +++ b/client/e2e/help/README.md @@ -112,6 +112,11 @@ so look at three or four pictures from a subset run before starting it. the dev server if it happened. - Each run boots a fresh server (~2 min); batch fixes, and dump the accessibility tree once (`page.locator('body').ariaSnapshot()`) instead of guessing locators. +- Several runs at once, each on its own `E2E_WEB_PORT`/`E2E_API_PORT`, share + `server/dist`: build it once and set `E2E_SERVER_PREBUILT=1` on every run, or + each run rebuilds it under the servers of the others. Give each run its own + `TREK_PLUGINS_DIR` and `TREK_PLUGINS_DATA_DIR` as well (the seed installs a + plugin), and its own Vite `cacheDir` when it starts a Vite of its own. - A plugin installed by a run lives in `server/data/plugins/`; the dev server imports it on its next restart. Guides that install one uninstall it in `cleanup`. - The trip's last day is the run day by construction (`e2e/dates.ts`: every diff --git a/client/e2e/help/external.ts b/client/e2e/help/external.ts index 1af591813..ee54a80ec 100644 --- a/client/e2e/help/external.ts +++ b/client/e2e/help/external.ts @@ -1,6 +1,6 @@ import type { APIRequestContext } from '@playwright/test' import { day, PICTURE_DAY_ISO } from '../dates' -import { dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track' +import { DAWARICH_DEVICE, dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track' /** * The services outside TREK that a few guides need: an AirTrail with flights, @@ -111,19 +111,61 @@ export async function disconnectAirtrail(api: APIRequestContext): Promise // ── Dawarich ──────────────────────────────────────────────────────────────── -/** Uploads the synthetic recording, one part at a time, unless its window already holds points. */ +/** The ids of the fixture's own points between two instants (ISO), every page of them. */ +async function fixturePointIds(base: string, key: string, from: string, to: string): Promise { + const ids: number[] = [] + for (let page = 1; ; page++) { + const res = await expectOk( + await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1000&page=${page}`, { headers: { Authorization: `Bearer ${key}` } }), + `Dawarich points ${from}..${to}`, + ) + const points = (await res.json()) as { id: number; tracker_id?: string | null }[] + // Only what this fixture uploaded: whatever else the account holds is not ours to touch. + ids.push(...points.filter(p => p.tracker_id === DAWARICH_DEVICE).map(p => p.id)) + if (points.length === 0 || page >= Number(res.headers.get('x-total-pages') ?? 1)) return ids + } +} + +const shiftIso = (iso: string, days: number) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10) +const aroundInstant = (iso: string, seconds: number) => new Date(Date.parse(iso) + seconds * 1000).toISOString() + +/** + * Uploads the synthetic recording, one part at a time, unless this run's copy + * of it is already there. + * + * The recording moves with the picture day, so the copy an earlier run left + * sits a few days off this run's and overlaps its window. Asking whether the + * window held any point at all found that copy and uploaded nothing, and the + * trip map then drew the earlier run's last days, in Kyoto, on this run's + * first days, which are in Tokyo. So the probe asks for this run's first and + * last point, and a part that lacks either is cleared of the fixture's own + * points, a month either side of its window, before it goes up again. + */ export async function ensureDawarichRecording(): Promise { const base = env('HELP_MEDIA_DAWARICH_URL') const key = env('HELP_MEDIA_DAWARICH_API_KEY') const recording = dawarichRecording(PICTURE_DAY_ISO) for (const part of ['world', 'trip'] as const) { - const { from, to } = recordingWindow(PICTURE_DAY_ISO, part) - const probe = await expectOk( - await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1`, { headers: { Authorization: `Bearer ${key}` } }), - `Dawarich points ${part}`, - ) - if (Number(probe.headers.get('x-total-pages') ?? 0) > 0) continue const features = recording[part] + const ends = [features[0], features[features.length - 1]].map(f => f.properties.timestamp) + let present = true + for (const at of ends) { + if ((await fixturePointIds(base, key, aroundInstant(at, -1), aroundInstant(at, 1))).length === 0) present = false + } + if (present) continue + + const { from, to } = recordingWindow(PICTURE_DAY_ISO, part) + const stale = await fixturePointIds(base, key, shiftIso(from, -30), shiftIso(to, 30)) + for (let i = 0; i < stale.length; i += 1000) { + await expectOk( + await fetch(`${base}/api/v1/points/bulk_destroy`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ point_ids: stale.slice(i, i + 1000) }), + }), + `Dawarich clear ${part}`, + ) + } for (let i = 0; i < features.length; i += 500) { const batch: TrackFeature[] = features.slice(i, i + 500) await expectOk( diff --git a/client/e2e/help/fixtures.ts b/client/e2e/help/fixtures.ts index 17972e882..824c9bca2 100644 --- a/client/e2e/help/fixtures.ts +++ b/client/e2e/help/fixtures.ts @@ -1938,9 +1938,10 @@ export const COLLAB_LINKS = [ /** * A poll to spend. Closing one cannot be undone (there is no reopen route), so * the guide about closing must not be pointed at either of the seeded polls. - * `ensureCollabFixtures` also casts one vote on it: a closed poll nobody voted - * on has no winning option, and `isWinner` in `CollabPolls.tsx` needs a count - * above zero before it tints one green, which is what the guide's text says. + * `ensureCollabFixtures` also has a member cast one vote on it: a closed poll + * nobody voted on has no winning option, and `isWinner` in `CollabPolls.tsx` + * needs a count above zero before it tints one green, which is what the guide's + * text says. */ export const COLLAB_SPARE_POLL = { question: 'Rent a car for the Hakone leg?', @@ -1957,6 +1958,27 @@ export const COLLAB_SPARE_POLL = { */ export const WHATS_NEXT_STOPS = { timed: 'Nishiki Market', untimed: 'Fushimi Inari Taisha' } +/** The seeded member who votes on the spare poll. */ +const COLLAB_VOTER = { email: 'mira@example.com', password: 'DemoSeed12345!' } + +/** + * A request context acting as COLLAB_VOTER, or undefined when the login fails. + * Its own context with a bearer token: a login on the shared one would set the + * session cookie there, and every later write would be the member's. + */ +async function collabMember(): Promise { + const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined }) + const login = await anon.post('/api/auth/login', { data: COLLAB_VOTER }) + const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined + await anon.dispose() + if (!token) return undefined + return apiRequest.newContext({ + baseURL: E2E_BASE_URL, + storageState: undefined, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }) +} + /** * What the Collab guides need beyond the seed, which already holds the three * notes, the two polls and the seven message conversation. Safe to run again: @@ -1987,7 +2009,12 @@ export async function ensureCollabFixtures(api: APIRequestContext): Promise { /** The tab id in the address is the legacy German one; its label is Bookings. */ const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' }) -/** - * A booking's card in the list, by its title. - * - * `.last()` because a card holds cards of its own (a traveller pill carries the - * same class), and the filter keeps only the one that names the booking. The - * delete question's card names it too, in its body text, and is portalled to the - * end of the body, so this must not be used while that question stands. - */ -const bookingCard = (page: Page, title: string) => page.locator('.bg-surface-card').filter({ hasText: title }).last() +/** A booking's card in the Cards view: an article named after the booking. */ +const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true }) +/** The pencil and the bin in a card's head band, named by their tooltips. */ +const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') => + bookingCard(page, title).getByRole('button', { name, exact: true }) + +/** Open a booking's editor from its card, the way the texts tell the reader to. */ +async function editFromCard(page: Page, title: string): Promise { + await cardAction(page, title, 'Edit').click() + await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible() + await settle(page) +} + +// ── The bar ─────────────────────────────────────────────────────────────────── + +/** The tab's one bar: its heading, then search, filter, the views and the add buttons. */ +const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..') +const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true }) +/** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */ +const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ }) +const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true }) +/** The filter panel under the funnel: Status, Type and Travelers. */ +const filterMenu = (page: Page) => page.getByRole('menu') +const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true }) +const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true }) + +// ── Timeline and detail ─────────────────────────────────────────────────────── + +/** Trip | Day above the chart. */ +const zoom = (page: Page) => page.getByRole('group', { name: 'Zoom', exact: true }) +/** A day's heading on the Trip zoom; a click opens that day by the hour. */ +const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) }) +/** A bar of the timeline, named after its booking. */ +const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true }) +/** The booking's detail popup, labelled by its title. */ +const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title }) + +/** The booking the views guide opens: a tour on day 5, with a place and travellers. */ +const VIEWED = { title: 'Fushimi Inari night walk', day: 5 } + +// ── The editor ──────────────────────────────────────────────────────────────── /** A field of the booking form, by its own label; the block around it is the label's parent. */ const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first() const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..') /** The two-column or three-column row a field shares with its neighbours. */ const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..') +/** A labelled block of a dialog's body whose label is not a `