docs(help): the trip screens' guides for the new layout, with new pictures
The guides of the map, bookings, costs, files, collab, lists, road trip and transports now describe the desktop as it is after the redesign: the bars with their icon buttons and filter popovers, the dialogs with the title in the head band and status and type as pills, the three booking views and the booking detail, the compact note cards, the Costs table. Two guides are new: the booking views (cards, list, timeline, the Trip and Day zoom and the detail) and the Costs table view. Every changed text is translated into every locale, and the Plan tab texts call the bookings tab by the label it shows, Book. Every picture of the thirteen trip screens is taken again against the new layout, on one picture day. Scripts follow the new markup; the Dawarich fixture uploads its recording again when the stored one belongs to another run, and the spare poll gets its vote from a member so its winner shows green. server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so several recordings on their own port pairs can share one dist.
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
// ── 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<number[]> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<APIRequestContext | undefined> {
|
||||
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<void
|
||||
if (!spareRes.ok()) throw new Error(`could not create the spare poll: ${spareRes.status()} ${await spareRes.text()}`)
|
||||
const { poll: spare } = (await spareRes.json()) as { poll: { id: number } }
|
||||
// One vote on it, so the poll the close-poll guide closes has a winner to tint.
|
||||
const voted = await api.post(`${collab}/polls/${spare.id}/vote`, { data: { option_index: 0 } })
|
||||
// Cast by another member: a winner the viewer picked wears the accent tint of
|
||||
// their own vote instead of the green the guide's text describes. A login that
|
||||
// fails falls back to the admin's vote rather than to no winner at all.
|
||||
const voter = await collabMember()
|
||||
const voted = await (voter ?? api).post(`${collab}/polls/${spare.id}/vote`, { data: { option_index: 0 } })
|
||||
await voter?.dispose()
|
||||
if (!voted.ok()) throw new Error(`could not vote on the spare poll: ${voted.status()} ${await voted.text()}`)
|
||||
|
||||
// What's Next lists a stop on a later day, or one on today whose start has not
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { dotted } from '../dates'
|
||||
import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures'
|
||||
import { openTrip, modal, dialog, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
|
||||
import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
|
||||
import { requireExtractor, allowEmlUploads } from './external'
|
||||
import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
@@ -26,39 +26,70 @@ const guide = (id: string): HelpGuide => {
|
||||
/** 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<void> {
|
||||
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 `<label>` (Who paid?). */
|
||||
const section = (page: Page, name: string) =>
|
||||
modal(page).locator('section').filter({ has: page.getByText(name, { exact: true }) }).first()
|
||||
|
||||
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
|
||||
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
|
||||
|
||||
/** The title is typed into the head band; the field keeps the old placeholder. */
|
||||
const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')
|
||||
const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345')
|
||||
const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00')
|
||||
const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true })
|
||||
|
||||
/** Type chips and section headers both carry their count inside the accessible name. */
|
||||
const counted = (page: Page, name: string) => page.getByRole('button', { name: new RegExp(`^${name} \\d+$`) })
|
||||
/** The toolbar's traveller filter: a labelled row of round avatar buttons. */
|
||||
const travellers = (page: Page) => page.locator('[aria-label="Travelers"]')
|
||||
|
||||
/** The card's delete question is its own portal, with no backdrop class to find it by. */
|
||||
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
|
||||
|
||||
/** The Booking Type is a pill in the head of the form; its name reads the type after the label. */
|
||||
/** The Booking Type is a pill in the head band; its name reads the type after the label. */
|
||||
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
|
||||
/** The pill's list is its own portal, named after the pill's label. */
|
||||
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
|
||||
@@ -76,6 +107,12 @@ async function pickType(page: Page, type: RegExp): Promise<void> {
|
||||
await expect(typeMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function openManualBooking(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(page).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** The Travelers field opens its members as a list under it, inside the form. */
|
||||
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
|
||||
|
||||
@@ -131,15 +168,19 @@ async function restore(page: Page, ...titles: string[]): Promise<void> {
|
||||
await ensureBookingsFixtures(page.request)
|
||||
}
|
||||
|
||||
/** The filters live for the browser session, the two sections for the trip. */
|
||||
/**
|
||||
* What the tab remembers: the filters per trip for the browser tab, the view,
|
||||
* its grouping, sorting and timeline zoom, and the folded sections in the
|
||||
* browser. Every test has a context of its own, so this is only tidiness for a
|
||||
* guide that runs another after it in the same page.
|
||||
*/
|
||||
async function clearPanelState(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
await page.evaluate(id => {
|
||||
try {
|
||||
sessionStorage.removeItem(`trek-reservation-filters-${id}`)
|
||||
sessionStorage.removeItem(`trek-reservation-filters-${id}-travelers`)
|
||||
localStorage.removeItem(`trek:bookings-pending-open:${id}`)
|
||||
localStorage.removeItem(`trek:bookings-confirmed-open:${id}`)
|
||||
sessionStorage.removeItem(`trek-bookings-filters-bookings-${id}`)
|
||||
for (const name of ['view', 'group', 'sort', 'timeline', 'transitApart']) localStorage.removeItem(`trek:bookings-bookings-${name}`)
|
||||
localStorage.removeItem(`trek:bookings-bookings-collapsed:${id}`)
|
||||
localStorage.removeItem('trek.bg-import-tasks')
|
||||
} catch {
|
||||
/* a browser that refuses storage has nothing to clear */
|
||||
@@ -157,17 +198,63 @@ const closeModal = async (page: Page): Promise<void> => {
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'booking-views': {
|
||||
guide: guide('booking-views'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
// The three icons are the subject; the pointer rests on List so its name shows.
|
||||
target: viewSwitch,
|
||||
hover: async p => { await viewButton(p, 'List').hover() },
|
||||
act: async p => {
|
||||
await viewButton(p, 'List').click()
|
||||
await expect(viewButton(p, 'List')).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(p.getByRole('button', { name: VIEWED.title, exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => viewButton(p, 'Timeline'),
|
||||
act: async p => {
|
||||
await viewButton(p, 'Timeline').click()
|
||||
await expect(zoom(p)).toBeVisible()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Trip', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => dayHeading(p, VIEWED.day),
|
||||
act: async p => {
|
||||
await dayHeading(p, VIEWED.day).click()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Day', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(timelineBar(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(zoom),
|
||||
{
|
||||
// Hovered for the picture: the bar's card of facts stands beside it.
|
||||
target: p => timelineBar(p, VIEWED.title),
|
||||
act: async p => {
|
||||
await timelineBar(p, VIEWED.title).click()
|
||||
await expect(detail(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => detail(p, VIEWED.title)),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await clearPanelState(p)
|
||||
},
|
||||
},
|
||||
'create-booking': {
|
||||
guide: guide('create-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Manual Booking' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
act: openManualBooking,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -229,8 +316,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await openManualBooking(p)
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: typeMenu,
|
||||
@@ -283,12 +369,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }),
|
||||
act: async p => {
|
||||
await bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => cardAction(p, 'teamLab Planets timed entry', 'Edit'),
|
||||
act: p => editFromCard(p, 'teamLab Planets timed entry'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -314,7 +396,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Link to day assignment')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Linked to', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -327,9 +409,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingCard(p, 'Fushimi Inari night walk').getByRole('button', { name: 'Edit' }).click()
|
||||
await editFromCard(p, 'Fushimi Inari night walk')
|
||||
await expect(label(p, /^Travelers$/)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, /^Travelers$/),
|
||||
act: settle,
|
||||
@@ -342,9 +423,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
target: travelerList,
|
||||
act: async p => {
|
||||
const row = travelerList(p).getByRole('button', { name: /jonas/ })
|
||||
await row.click()
|
||||
await expect(row).toHaveAttribute('aria-pressed', 'true')
|
||||
const person = travelerList(p).getByRole('button', { name: /jonas/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
// A click beside the field folds the list away; Escape would close the whole form.
|
||||
await label(p, /^Travelers$/).click()
|
||||
await expect(travelerList(p)).toHaveCount(0)
|
||||
@@ -356,15 +437,24 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => travellers(p).getByTitle('jonas'),
|
||||
prepare: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The people are a row of chips under the panel's Travelers caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: /jonas$/ }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await travellers(p).getByTitle('jonas').click()
|
||||
await expect(travellers(p).getByTitle('jonas')).toHaveAttribute('aria-pressed', 'true')
|
||||
const person = filterMenu(p).getByRole('button', { name: /jonas$/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -380,9 +470,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingCard(p, 'teamLab Planets timed entry').getByRole('button', { name: 'Edit' }).click()
|
||||
await editFromCard(p, 'teamLab Planets timed entry')
|
||||
await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Attach file' }),
|
||||
act: async p => {
|
||||
@@ -436,9 +525,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click()
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, /^Costs$/),
|
||||
act: settle,
|
||||
@@ -453,24 +541,24 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByPlaceholder('0.00').fill('12000')
|
||||
await block(p, /^Total amount$/).locator('input').first().fill('12000')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, /^Total amount$/),
|
||||
target: p => row(p, /^Total amount$/),
|
||||
act: settle,
|
||||
},
|
||||
only(p => block(p, /^Who paid\?$/).locator('xpath=..')),
|
||||
only(p => section(p, 'Who paid?')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }),
|
||||
act: async p => {
|
||||
const save = modal(p).getByRole('button', { name: 'Add expense', exact: true })
|
||||
// What was it for? comes prefilled from the booking; without it the button is dead.
|
||||
// The name comes prefilled from the booking; without it the button is dead.
|
||||
await expect(save).toBeEnabled()
|
||||
await save.click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
// The block only reads Linked expenses once the booking is opened again.
|
||||
await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click()
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
@@ -487,35 +575,55 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('filter-bookings'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
only(p => counted(p, 'All').locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await counted(p, 'Tour').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => counted(p, 'Tour'),
|
||||
act: async p => {
|
||||
await counted(p, 'Event').click()
|
||||
await expect(bookingCard(p, 'Lunch at Nishiki')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => counted(p, 'All'),
|
||||
act: async p => {
|
||||
await counted(p, 'All').click()
|
||||
await expect(bookingCard(p, 'Lunch at Nishiki')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The gesture belongs to booking-travelers; here the row is only pointed at.
|
||||
only(travellers),
|
||||
{
|
||||
target: p => counted(p, 'Pending'),
|
||||
act: async p => {
|
||||
await counted(p, 'Pending').click()
|
||||
await typeInto(p, searchBox(p), 'Kyoto')
|
||||
await expect(resultsChip(p)).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await counted(p, 'Pending').click()
|
||||
await settle(p)
|
||||
},
|
||||
// The box around the field, with its magnifier.
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await searchBox(p).press('Escape')
|
||||
await expect(searchBox(p)).toHaveValue('')
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: filterButton,
|
||||
act: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The three choices sit on one grey track under the Status caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => filterMenu(p).getByRole('button', { name: /^Tour\b/ }),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: /^Tour\b/ }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: resultsChip,
|
||||
act: async p => {
|
||||
await resultsChip(p).click()
|
||||
await expect(resultsChip(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -555,7 +663,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dialog,
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
@@ -580,12 +688,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }),
|
||||
act: async p => {
|
||||
await bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => cardAction(p, 'Kyoto Cycling Tour', 'Edit'),
|
||||
act: p => editFromCard(p, 'Kyoto Cycling Tour'),
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') },
|
||||
@@ -607,7 +711,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByText('Confirmed', { exact: true }).first()).toBeVisible({ timeout: 20_000 })
|
||||
// A confirmed card's status dot offers the way back to Pending.
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Set to Pending' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -619,18 +724,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => bookingCard(p, 'Haneda Airport P4').getByRole('button', { name: 'Delete' }),
|
||||
target: p => cardAction(p, 'Haneda Airport P4', 'Delete'),
|
||||
act: async p => {
|
||||
await bookingCard(p, 'Haneda Airport P4').getByRole('button', { name: 'Delete' }).click()
|
||||
await cardAction(p, 'Haneda Airport P4', 'Delete').click()
|
||||
await expect(deleteAsk(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(deleteAsk),
|
||||
{
|
||||
target: p => deleteAsk(p).getByRole('button', { name: 'Confirm' }),
|
||||
target: p => deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async p => {
|
||||
await deleteAsk(p).getByRole('button', { name: 'Confirm' }).click()
|
||||
await deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
@@ -53,12 +53,18 @@ const CHAT = {
|
||||
|
||||
// ── Locators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Each of the four right-hand panels is a card; find it from what only it holds. */
|
||||
const CARD = 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"bg-surface-card")][1]'
|
||||
const notesPanel = (page: Page) => page.getByRole('button', { name: 'New Note' }).locator(CARD)
|
||||
const pollsPanel = (page: Page) => page.getByRole('button', { name: 'New Poll' }).locator(CARD)
|
||||
const linksPanel = (page: Page) => page.getByRole('button', { name: 'Add link' }).locator(CARD)
|
||||
const nextPanel = (page: Page) => page.getByText("What's Next", { exact: true }).locator(CARD)
|
||||
/**
|
||||
* One of the five cards of the tab, found from the name in its head band. The
|
||||
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
|
||||
* the match ignores case; the card is the nearest rounded ancestor (the cards
|
||||
* inside a panel, notes and polls, are never an ancestor of that heading).
|
||||
*/
|
||||
const panel = (page: Page, name: RegExp) =>
|
||||
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
||||
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
|
||||
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
|
||||
const linksPanel = (page: Page) => panel(page, /^Links$/i)
|
||||
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
|
||||
|
||||
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
|
||||
/** The row that holds the emoji button, the image button, the box and the arrow. */
|
||||
@@ -93,17 +99,22 @@ const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (pa
|
||||
await bubble(page, text).getByRole('button', { name }).hover()
|
||||
}
|
||||
|
||||
/** The note form is a `form[role="presentation"]` in its own portal, not a Modal. */
|
||||
const noteForm = (page: Page) => page.locator('form[role="presentation"]')
|
||||
/** The poll and link forms sit inside a full-screen overlay; ring the form, not it. */
|
||||
const pollForm = (page: Page) => page.locator('form').filter({ has: page.getByPlaceholder('What should we do?') })
|
||||
const linkForm = (page: Page) => page.locator('form').filter({ has: page.locator('#collab-link-url') })
|
||||
/**
|
||||
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
|
||||
* field only each of them has. The whole panel is the dialog: the head band
|
||||
* with the title typed into it, the body, and the footer with the buttons.
|
||||
*/
|
||||
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
|
||||
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
|
||||
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
|
||||
/** A field of an editor with the eyebrow label over it: the box's parent. */
|
||||
const field = (box: Locator) => box.locator('xpath=..')
|
||||
|
||||
const noteCard = (page: Page, title: string) =>
|
||||
notesPanel(page).getByText(title, { exact: true }).locator('xpath=ancestor::div[2]')
|
||||
/** A poll card, from its question: the question is markdown, so it is four up. */
|
||||
/** A note card is an article named by the note's title. */
|
||||
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
||||
/** A poll card is an article too, without a name: found from its question. */
|
||||
const pollCard = (page: Page, question: string) =>
|
||||
page.getByText(question, { exact: true }).locator('xpath=ancestor::div[4]')
|
||||
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
|
||||
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
|
||||
/** A What's Next row, from the place name in it: name → details → row. */
|
||||
const nextRow = (page: Page, place: string) =>
|
||||
@@ -114,7 +125,7 @@ const nextRow = (page: Page, place: string) =>
|
||||
const start = async (page: Page): Promise<void> => {
|
||||
await openTrip(page, { tab: 'collab' })
|
||||
// The Collab panel is a lazy chunk: everything below needs it mounted.
|
||||
await expect(page.getByRole('button', { name: 'New Poll' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
@@ -167,38 +178,42 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => notesPanel(page).getByRole('button', { name: 'New Note' }),
|
||||
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
|
||||
act: async page => {
|
||||
await notesPanel(page).getByRole('button', { name: 'New Note' }).click()
|
||||
await expect(noteForm(page)).toBeVisible()
|
||||
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
|
||||
await expect(noteDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteForm(page).getByPlaceholder('Note title'), NOTE.title),
|
||||
target: page => noteForm(page).getByPlaceholder('Note title'),
|
||||
// The title is typed into the dialog's head band, under the New Note eyebrow.
|
||||
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
|
||||
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteForm(page).getByPlaceholder('Write something...'), NOTE.body),
|
||||
target: page => noteForm(page).getByPlaceholder('Write something...'),
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
|
||||
// The Content eyebrow and the box under it.
|
||||
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
|
||||
},
|
||||
{
|
||||
// The pills are the categories that already exist; ring the row, then pick.
|
||||
target: page => noteForm(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=..'),
|
||||
act: async page => {
|
||||
await noteForm(page).getByRole('button', { name: NOTE.category, exact: true }).click()
|
||||
// Picked before the shot, so the pill and the head band already wear its colour.
|
||||
prepare: async page => {
|
||||
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
|
||||
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await beat(page, 300)
|
||||
},
|
||||
// The Category eyebrow and the row of pills: pill, row, field.
|
||||
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteForm(page).getByPlaceholder('https://...'), NOTE.website),
|
||||
target: page => noteForm(page).getByPlaceholder('https://...'),
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
|
||||
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
|
||||
},
|
||||
{
|
||||
target: page => noteForm(page).getByRole('button', { name: 'Create', exact: true }),
|
||||
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
|
||||
act: async page => {
|
||||
await noteForm(page).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(noteForm(page)).toHaveCount(0)
|
||||
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
@@ -211,23 +226,24 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => linksPanel(page).getByRole('button', { name: 'Add link' }),
|
||||
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
|
||||
act: async page => {
|
||||
await linksPanel(page).getByRole('button', { name: 'Add link' }).click()
|
||||
await expect(linkForm(page)).toBeVisible()
|
||||
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, linkForm(page).locator('#collab-link-title'), LINK.title)
|
||||
await typeInto(page, linkForm(page).locator('#collab-link-url'), LINK.url)
|
||||
// The title sits in the head band, the address in the Link field under it.
|
||||
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
|
||||
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: linkForm,
|
||||
target: linkDialog,
|
||||
act: async page => {
|
||||
await linkForm(page).getByRole('button', { name: 'Save link' }).click()
|
||||
await expect(linkForm(page)).toHaveCount(0)
|
||||
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
@@ -255,40 +271,42 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll' }),
|
||||
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollsPanel(page).getByRole('button', { name: 'New Poll' }).click()
|
||||
await expect(pollForm(page)).toBeVisible()
|
||||
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, pollForm(page).getByPlaceholder('What should we do?'), POLL.question),
|
||||
// The label, the box and the Markdown supported hint under it.
|
||||
target: page => pollForm(page).getByPlaceholder('What should we do?').locator('xpath=..'),
|
||||
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
|
||||
// The Question eyebrow, the box and the Markdown supported hint under it.
|
||||
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, pollForm(page).getByPlaceholder('Option 1'), POLL.options[0])
|
||||
await typeInto(page, pollForm(page).getByPlaceholder('Option 2'), POLL.options[1])
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
|
||||
},
|
||||
target: page => pollForm(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[2]'),
|
||||
// The Options field: box, its row, the column of rows, the field with its eyebrow.
|
||||
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
|
||||
},
|
||||
{
|
||||
target: page => pollForm(page).getByRole('button', { name: '+ Add option' }),
|
||||
// The label drops the string's own "+"; the button draws a plus icon instead.
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
|
||||
act: async page => {
|
||||
await pollForm(page).getByRole('button', { name: '+ Add option' }).click()
|
||||
await typeInto(page, pollForm(page).getByPlaceholder('Option 3'), POLL.options[2])
|
||||
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
|
||||
await beat(page, 300)
|
||||
},
|
||||
},
|
||||
// The switch alone is 36 px wide; ring the label it sits in.
|
||||
only(page => pollForm(page).getByRole('switch', { name: 'Multiple choice' }).locator('xpath=..')),
|
||||
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
|
||||
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
|
||||
{
|
||||
target: page => pollForm(page).getByRole('button', { name: 'Create Poll' }),
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollForm(page).getByRole('button', { name: 'Create Poll' }).click()
|
||||
await expect(pollForm(page)).toHaveCount(0)
|
||||
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
@@ -319,7 +337,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
// The counter is hardcoded English in the component: "1 vote" / "3 votes".
|
||||
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
|
||||
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
|
||||
],
|
||||
cleanup: page => resetVote(page, SEEDED_POLL, 0),
|
||||
@@ -329,18 +347,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-lock)'),
|
||||
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-lock)').click()
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
|
||||
{
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-trash2)'),
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).locator('button:has(svg.lucide-trash2)').click()
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
@@ -354,9 +373,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start,
|
||||
steps: [
|
||||
only(nextPanel),
|
||||
// The time column is the first child of a row, the chips the last of its details.
|
||||
// The time column is the first child of a row; the name, the address and the chips are its last.
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last().locator('> div').last()),
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
|
||||
],
|
||||
// Read-only panel: the guide writes nothing.
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { day, long, short } from '../dates'
|
||||
import { seededTrip, ensureCostsFixtures } from './fixtures'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { openTrip, modal } from './trip-shared'
|
||||
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
@@ -27,9 +27,16 @@ const UNPAID = 'Airport transfer (Narita Express)'
|
||||
const SEEDED_EUR = 'Flights FRA → HND'
|
||||
/** The seeded flight booking, on the Transports tab because its type is flight. */
|
||||
const BOOKING = 'LH716 FRA → HND'
|
||||
/** A seeded expense costs-table gives its days to. */
|
||||
const JR_PASS = 'JR Pass (14 days)'
|
||||
const DINNER = 'Kaiseki dinner in Gion'
|
||||
const UPGRADE = 'Shinkansen seat upgrade'
|
||||
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: day(-4) }
|
||||
/** The row costs-table adds to Food & drink; the table names a new row this until it is renamed. */
|
||||
const MATCHA = 'Matcha in Uji'
|
||||
const NEW_ROW = 'New Entry'
|
||||
/** Where the tab keeps the list/table choice (CostsPanel's COSTS_VIEW_KEY). */
|
||||
const VIEW_KEY = 'trek:costs-view'
|
||||
|
||||
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
|
||||
const openCosts = async (p: Page): Promise<void> => {
|
||||
@@ -46,48 +53,103 @@ const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ h
|
||||
* so a locator scoped to the row itself would never find it.
|
||||
*/
|
||||
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
|
||||
/** A recorded transfer in the ledger, told apart by who paid it. */
|
||||
const payRow = (page: Page, who: string) =>
|
||||
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: who }).first()
|
||||
/** A block of the expense editor, found by its own label. */
|
||||
const panel = (page: Page, label: string) =>
|
||||
modal(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
||||
/** A card of the right-hand column, by the label it opens with. */
|
||||
const card = (page: Page, label: RegExp) =>
|
||||
page.locator('.costs-grid > div:nth-child(2) > div').filter({ hasText: label }).first()
|
||||
/**
|
||||
* The transfer settle-up records for jonas, in the display currency. The seed
|
||||
* already holds one from jonas, in euros, which carries the converted line;
|
||||
* leaving that one out keeps the guide off the seed's own payment.
|
||||
*/
|
||||
const newPayRow = (page: Page) =>
|
||||
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: 'jonas' }).filter({ hasNotText: '€' }).first()
|
||||
/**
|
||||
* A block of the expense editor, found by its own label: the <section> of
|
||||
* Who paid? and Split, the grey group that holds Total amount, Currency and Day.
|
||||
*/
|
||||
const block = (page: Page, label: string) =>
|
||||
modal(page)
|
||||
.getByText(label, { exact: true })
|
||||
.first()
|
||||
.locator('xpath=ancestor::*[self::section or contains(@class,"rounded-[16px]")][1]')
|
||||
/** A card of the right-hand column: each is a <section> named by its heading. */
|
||||
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
|
||||
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
|
||||
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
|
||||
|
||||
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
|
||||
/** The name, typed into the head of the expense editor. */
|
||||
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
|
||||
/** The header button; the editor's own save carries the same words, so it is scoped to the modal. */
|
||||
/** The category pill under the name, and the list it opens (a portal named after it). */
|
||||
const categoryPill = (page: Page) => modal(page).getByRole('button', { name: /^Category:/ })
|
||||
const categoryList = (page: Page) => page.getByRole('group', { name: 'Category', exact: true })
|
||||
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
|
||||
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
|
||||
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
|
||||
const settleButtons = (page: Page) => card(page, /^Settle up · /).getByRole('button', { name: 'Settle' })
|
||||
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
|
||||
/**
|
||||
* One suggested transfer in the Settle up card, by the title its pair of avatars
|
||||
* carries ("jonas → you"); the row itself is that pair's parent. The card lists
|
||||
* the flows in whatever order the settlement endpoint returns them, so taking
|
||||
* the first Settle button would settle whichever transfer happened to come
|
||||
* first, and the ledger steps below look for a named one.
|
||||
* One suggested transfer in the Settle up card, by the initial on its first
|
||||
* avatar ("J" for jonas; the names are only in the tooltip). The card lists the
|
||||
* flows in whatever order the settlement endpoint returns them, so taking the
|
||||
* first Settle button would settle whichever transfer happened to come first.
|
||||
*/
|
||||
const settleRow = (page: Page, from: string) =>
|
||||
card(page, /^Settle up · /).locator(`div[title^="${from} "]`).locator('xpath=..')
|
||||
const settleRow = (page: Page, initial: string) =>
|
||||
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
|
||||
|
||||
// ── The bar's filters ────────────────────────────────────────────────────────
|
||||
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
|
||||
const filterButton = (page: Page) => page.getByRole('button', { name: 'Filter', exact: true })
|
||||
/** The popover the funnel opens: the owner switch, the categories and the days. */
|
||||
const filterMenu = (page: Page) => page.getByRole('menu')
|
||||
async function openFilter(page: Page): Promise<void> {
|
||||
if (!(await filterMenu(page).isVisible().catch(() => false))) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
async function closeFilter(page: Page): Promise<void> {
|
||||
if (await filterMenu(page).isVisible().catch(() => false)) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
// ── The table view ───────────────────────────────────────────────────────────
|
||||
const table = (page: Page) => page.getByRole('table')
|
||||
/** A category's group of the table: a <tbody> opened by a button with its name and count. */
|
||||
const group = (page: Page, category: string) =>
|
||||
table(page)
|
||||
.locator('tbody')
|
||||
.filter({ has: page.getByRole('button', { name: new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\d+$`) }) })
|
||||
const tableRow = (page: Page, name: string) => table(page).getByRole('row').filter({ hasText: name }).first()
|
||||
const summary = (page: Page) => page.getByRole('region', { name: 'Summary', exact: true })
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* A dialog that closes hands the focus back to the button that opened it, and
|
||||
* a focused button wears its outline and its tooltip, the tooltip at wherever
|
||||
* the button stood before the next picture scrolled: both would stand in that
|
||||
* picture as if they were part of it.
|
||||
*/
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
|
||||
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)
|
||||
await dropFocus(page)
|
||||
}
|
||||
}
|
||||
|
||||
type BudgetRow = { id: number; name: string; days?: number | null }
|
||||
|
||||
/** What the JR Pass's Days held before costs-table typed into it; cleanup puts it back. */
|
||||
let passDaysBefore: number | null = null
|
||||
|
||||
async function budgetItems(page: Page): Promise<BudgetRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget`)
|
||||
return ((await res.json()) as { items: BudgetRow[] }).items
|
||||
}
|
||||
|
||||
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget`)
|
||||
const { items } = (await res.json()) as { items: { id: number; name: string }[] }
|
||||
for (const item of items.filter(i => names.includes(i.name))) {
|
||||
for (const item of (await budgetItems(page)).filter(i => names.includes(i.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
|
||||
}
|
||||
}
|
||||
@@ -139,34 +201,41 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, nameBox(p), DINNER)
|
||||
await settle(p)
|
||||
},
|
||||
target: nameBox,
|
||||
},
|
||||
{
|
||||
// The list open under the pill, so the picture shows what there is to pick.
|
||||
prepare: async p => {
|
||||
await categoryPill(p).click()
|
||||
await expect(categoryList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: categoryPill,
|
||||
act: async p => {
|
||||
await categoryList(p).getByRole('button', { name: 'Food & drink' }).click()
|
||||
await expect(categoryList(p)).toHaveCount(0)
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Food & drink/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await amounts(p).first().fill('18600')
|
||||
await expect(saveExpense(p)).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
// The left block carries the name, the amount, the currency and the day.
|
||||
target: p => panel(p, 'What was it for?'),
|
||||
// The grey group: Total amount, then Currency and Day beside it.
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
// The two-column row inside that block, so the step about them is not the
|
||||
// same picture as the step about the name.
|
||||
only(p => modal(p).getByText('Currency', { exact: true }).locator('xpath=../..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Food & drink' }).click()
|
||||
// The chosen pill is marked by its border alone, not by a class.
|
||||
await expect(modal(p).getByRole('button', { name: 'Food & drink' })).toHaveAttribute(
|
||||
'style',
|
||||
/border-color: var\(--text-primary\)/,
|
||||
)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => panel(p, 'Category'),
|
||||
},
|
||||
only(p => panel(p, 'Who paid?')),
|
||||
only(p => block(p, 'Who paid?')),
|
||||
{
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -182,42 +251,34 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(panel(p, 'Who paid?')).toBeVisible()
|
||||
await expect(block(p, 'Who paid?')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => panel(p, 'Who paid?'),
|
||||
target: p => block(p, 'Who paid?'),
|
||||
},
|
||||
// Ringed, not clicked: the Ryokan keeps its payer.
|
||||
only(p => modal(p).getByRole('radio', { name: 'No one paid yet' })),
|
||||
{
|
||||
prepare: async p => {
|
||||
await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click()
|
||||
await beat(p, 300)
|
||||
await expect(p.getByRole('button', { name: 'No one paid yet' })).toBeVisible()
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'No one paid yet' }),
|
||||
// The list closes on a second click of its trigger, not on Escape; nothing is picked.
|
||||
act: async p => {
|
||||
await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click()
|
||||
await expect(p.getByRole('button', { name: 'No one paid yet' })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Multiple people paid' }).click()
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'Multiple people paid' }).click()
|
||||
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
|
||||
await modal(p).getByTestId('payer-toggle').nth(1).click()
|
||||
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => panel(p, 'Who paid?'),
|
||||
target: p => block(p, 'Who paid?'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'One person paid' }).click()
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'One person paid' }).click()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => expRow(p, UNPAID),
|
||||
// On the flag, whose tooltip says what it means; the row's centre is the note.
|
||||
hover: async p => {
|
||||
await expRow(p, UNPAID).getByText('Unfinished').hover()
|
||||
},
|
||||
act: async p => {
|
||||
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
|
||||
},
|
||||
@@ -235,60 +296,63 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(panel(p, 'Split')).toBeVisible()
|
||||
await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click()
|
||||
await expect(modal(p).getByText('Excluded')).toBeVisible()
|
||||
await expect(block(p, 'Split')).toBeVisible()
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'false')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => panel(p, 'Split'),
|
||||
target: p => block(p, 'Split'),
|
||||
act: async p => {
|
||||
await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click()
|
||||
await expect(modal(p).getByText('Excluded')).toHaveCount(0)
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText(/^Split \d+ ways · /)),
|
||||
// The badges under the list: how many share it, and what one share is.
|
||||
only(p => block(p, 'Split').getByText(/ per person$/).locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Custom', exact: true }).click()
|
||||
await amounts(p).nth(1).fill('1000')
|
||||
await expect(modal(p).getByText(/^Sum of splits: /)).toBeVisible()
|
||||
await block(p, 'Split').getByRole('button', { name: 'Custom', exact: true }).click()
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(0).fill('1000')
|
||||
await expect(block(p, 'Split').getByText(/^Sum of splits: /)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText(/^Sum of splits: /),
|
||||
target: p => block(p, 'Split').getByText(/^Sum of splits: /),
|
||||
act: async p => {
|
||||
await amounts(p).nth(2).fill('1900')
|
||||
await amounts(p).nth(3).fill('1900')
|
||||
await expect(modal(p).getByText('Split matches total')).toBeVisible()
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(1).fill('1900')
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(2).fill('1900')
|
||||
await expect(block(p, 'Split').getByText('Split matches total')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Ticket', exact: true }).click()
|
||||
await modal(p).getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, modal(p).getByPlaceholder('Item name').first(), 'Tamagoyaki')
|
||||
await amounts(p).nth(1).fill('1200')
|
||||
await modal(p).getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, modal(p).getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
|
||||
await amounts(p).nth(2).fill('900')
|
||||
const split = block(p, 'Split')
|
||||
await split.getByRole('button', { name: 'Ticket', exact: true }).click()
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').first(), 'Tamagoyaki')
|
||||
await split.locator('input[inputmode="decimal"]').nth(0).fill('1200')
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
|
||||
await split.locator('input[inputmode="decimal"]').nth(1).fill('900')
|
||||
// The second line is shared by two of the three travelers.
|
||||
await modal(p)
|
||||
await split
|
||||
.getByPlaceholder('Item name')
|
||||
.nth(1)
|
||||
.locator('xpath=ancestor::div[2]')
|
||||
.getByRole('button', { name: /jonas/ })
|
||||
.getByRole('checkbox', { name: /jonas/ })
|
||||
.click()
|
||||
await expect(amounts(p).first()).toHaveValue('2100.00')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
|
||||
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByText('Individual shares').locator('xpath=..'),
|
||||
target: p => modal(p).getByText('Individual shares', { exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -308,10 +372,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await amounts(p).first().fill('48')
|
||||
// The editor opens on Food & drink; a seat upgrade filed under it would
|
||||
// read wrong on the ledger row this guide's last picture is of.
|
||||
await modal(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await categoryPill(p).click()
|
||||
await categoryList(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Transport/)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => panel(p, 'What was it for?'),
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -340,6 +406,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -361,58 +428,63 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, p.getByPlaceholder('Search expenses…'), 'pass')
|
||||
await typeInto(p, searchBox(p), 'pass')
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByPlaceholder('Search expenses…').locator('xpath=..'),
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await p.getByPlaceholder('Search expenses…').fill('')
|
||||
await searchBox(p).fill('')
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The owner switch at the top of the popover, on Paid by me.
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).click()
|
||||
await expect(filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'All categories' }).first().click()
|
||||
await beat(p, 300)
|
||||
await expect(dropdown(p).getByRole('button', { name: 'Transport', exact: true })).toBeVisible()
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'All categories' }).first(),
|
||||
act: async p => {
|
||||
await dropdown(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await p.getByRole('button', { name: 'Transport', exact: true }).first().click()
|
||||
await beat(p, 300)
|
||||
await dropdown(p).getByRole('button', { name: 'All categories' }).click()
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The category list, with the pick marked and the funnel's count above it.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'All days' }).first().click()
|
||||
await beat(p, 300)
|
||||
await dropdown(p).getByRole('button', { name: short(-9) }).click()
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: short(-9), exact: true }).click()
|
||||
// Folded away, or the popover would sit over the banner it produced.
|
||||
await closeFilter(p)
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByText(new RegExp(`^${long(-9)}$`)).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: short(-9) }).first().click()
|
||||
await beat(p, 300)
|
||||
await dropdown(p).getByRole('button', { name: 'All days' }).click()
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Reset filters' }).click()
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Paid by me' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'All', exact: true }).click()
|
||||
await closeFilter(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -428,41 +500,43 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
transfersBefore = await settlementIds(p)
|
||||
},
|
||||
steps: [
|
||||
only(p => card(p, /^Settle up · /)),
|
||||
only(p => card(p, 'Settle up')),
|
||||
{
|
||||
target: p => settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }),
|
||||
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
|
||||
act: async p => {
|
||||
await settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }).click()
|
||||
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
|
||||
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
|
||||
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => payRow(p, 'jonas')),
|
||||
only(newPayRow),
|
||||
{
|
||||
target: p => payRow(p, 'jonas').locator('xpath=..').locator('.exp-actions'),
|
||||
target: p => newPayRow(p).locator('xpath=..').locator('.exp-actions'),
|
||||
act: async p => {
|
||||
await payRow(p, 'jonas').locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
|
||||
await newPayRow(p).locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Add payment' }).first().click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Add payment' })).toBeVisible()
|
||||
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
|
||||
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
|
||||
await settle(p)
|
||||
},
|
||||
target: dialog,
|
||||
target: p => p.getByRole('dialog', { name: 'Add payment' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Ringed and left alone: one click would record every open transfer and
|
||||
// flatten the balances every later picture is taken against.
|
||||
only(p => p.getByRole('button', { name: 'Settle up' })),
|
||||
only(p => p.getByRole('button', { name: 'Settle up', exact: true })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
@@ -476,22 +550,22 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('final-budget'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
only(p => card(p, /^Balances/)),
|
||||
only(p => card(p, /^Final budget/)),
|
||||
only(p => card(p, 'Balances')),
|
||||
only(p => card(p, 'Final budget')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, /^Final budget/).getByRole('button').first().click()
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(p.getByText('Expenses paid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => card(p, /^Final budget/).locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
|
||||
target: p => card(p, 'Final budget').locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
|
||||
},
|
||||
{
|
||||
// The second "Expenses paid" is the heading of the list the figure is made of.
|
||||
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await card(p, /^Final budget/).getByRole('button').first().click()
|
||||
await expect(card(p, /^Final budget/).locator('[aria-expanded="true"]')).toHaveCount(0)
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(card(p, 'Final budget').locator('[aria-expanded="true"]')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -501,14 +575,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('expense-from-booking'),
|
||||
start: async p => {
|
||||
await openTrip(p, { tab: 'transports' })
|
||||
await expect(p.getByRole('button', { name: 'Edit' }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(p.getByRole('article', { name: BOOKING }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Edit' }).first(),
|
||||
target: p => p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Edit' }).first().click()
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -531,7 +605,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await p.getByRole('button', { name: 'Edit' }).first().click()
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
@@ -544,6 +618,97 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await deleteExpenses(p, BOOKING)
|
||||
},
|
||||
},
|
||||
|
||||
'costs-table': {
|
||||
guide: guide('costs-table'),
|
||||
start: async p => {
|
||||
// What the pass's Days cell holds before the guide types into it.
|
||||
const pass = (await budgetItems(p)).find(i => i.name === JR_PASS)
|
||||
if (!pass) throw new Error(`the seed has no "${JR_PASS}"`)
|
||||
passDaysBefore = pass.days ?? null
|
||||
await openCosts(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Table', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Table', exact: true }).click()
|
||||
await expect(table(p)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(tableRow(p, SEEDED_EUR)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// One category as a whole: its head with the count and the subtotal, its rows, its add button.
|
||||
only(p => group(p, 'Transport')),
|
||||
{
|
||||
// The pass is good for fourteen days, and its Days cell is still empty:
|
||||
// typed in, Per Day and P. p / Day fill in beside it.
|
||||
prepare: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('button', { name: /^Days/ }).click()
|
||||
const field = tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' })
|
||||
await expect(field).toBeFocused()
|
||||
await field.fill('14')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }),
|
||||
act: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }).press('Enter')
|
||||
await expect(tableRow(p, JR_PASS).getByRole('button', { name: 'Days: 14' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hovered for the picture, so the lock's reason stands above it.
|
||||
target: p => tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }),
|
||||
act: async p => {
|
||||
await tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Edit expense' })).toBeVisible()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }),
|
||||
act: async p => {
|
||||
await group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }).click()
|
||||
const name = table(p).getByRole('textbox', { name: 'Name' })
|
||||
await expect(name.or(tableRow(p, NEW_ROW)).first()).toBeVisible({ timeout: 15_000 })
|
||||
if (!(await name.isVisible().catch(() => false))) {
|
||||
await tableRow(p, NEW_ROW).getByRole('button', { name: /^Name/ }).click()
|
||||
}
|
||||
await name.fill(MATCHA)
|
||||
await name.press('Enter')
|
||||
await expect(tableRow(p, MATCHA)).toBeVisible({ timeout: 15_000 })
|
||||
// A total too, the same way, so the row reads like a real one.
|
||||
await tableRow(p, MATCHA).getByRole('button', { name: /^Total/ }).click()
|
||||
const total = tableRow(p, MATCHA).getByRole('textbox', { name: 'Total' })
|
||||
await total.fill('1800')
|
||||
await total.press('Enter')
|
||||
await expect(tableRow(p, MATCHA).getByRole('button', { name: /^Total: .*1,800/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await summary(p).getByRole('button', { name: 'Payer', exact: true }).click()
|
||||
await expect(summary(p).getByText('No payer yet')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: summary,
|
||||
},
|
||||
],
|
||||
// Back to the ledger, so every other guide finds it, and the seed as it was.
|
||||
cleanup: async p => {
|
||||
const list = p.getByRole('button', { name: 'List', exact: true })
|
||||
if (await list.isVisible().catch(() => false)) await list.click()
|
||||
await p.evaluate(key => { try { localStorage.setItem(key, 'list') } catch { /* no storage: nothing to put back */ } }, VIEW_KEY)
|
||||
await deleteExpenses(p, MATCHA, NEW_ROW)
|
||||
const { tripId } = seededTrip()
|
||||
for (const item of (await budgetItems(p)).filter(i => i.name === JR_PASS)) {
|
||||
await p.request.put(`/api/trips/${tripId}/budget/${item.id}`, { data: { days: passDaysBefore } })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -221,11 +221,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
// The options of a day select are portalled to the body, outside the picker.
|
||||
// The two day selects are named Start and End; their options are
|
||||
// portalled to the body, outside the picker.
|
||||
prepare: async p => {
|
||||
await picker(p).getByRole('button', { name: /^Day 6/ }).click()
|
||||
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
|
||||
await p.getByRole('button', { name: /^Day 7/ }).last().click()
|
||||
await expect(picker(p).getByRole('button', { name: /^Day 7/ })).toBeVisible()
|
||||
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
|
||||
await settle(p)
|
||||
},
|
||||
// The row of Start, End and All: the All button's parent.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
|
||||
import { openTrip, portalDialog, modal, dialog } from './trip-shared'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
|
||||
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
@@ -36,11 +36,16 @@ const NOTE = 'Seat 34K, boarding 12:20'
|
||||
|
||||
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
|
||||
|
||||
/** A file row: the only `.group` block the Files panel renders, one per file. */
|
||||
const row = (page: Page, name: string) => page.locator('div.group').filter({ hasText: name }).first()
|
||||
/** A row's action buttons take their accessible name from their `title`. */
|
||||
const action = (page: Page, name: string, title: string) =>
|
||||
row(page, name).getByRole('button', { name: title, exact: true })
|
||||
/**
|
||||
* The file rows, in the order the list shows them. A row is the `.group` card
|
||||
* whose last child holds the row's icon actions (`.file-actions`); matching on
|
||||
* that keeps any other hover group on the trip page out of the count.
|
||||
*/
|
||||
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
|
||||
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
|
||||
/** A row's actions are icons; each takes its accessible name from its tooltip. */
|
||||
const action = (page: Page, name: string, label: string) =>
|
||||
row(page, name).getByRole('button', { name: label, exact: true })
|
||||
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
|
||||
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
|
||||
/** The filter row, found through the one tab that is always there. */
|
||||
@@ -54,10 +59,16 @@ const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: p
|
||||
* fixture adding a file of its own.
|
||||
*/
|
||||
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
|
||||
/** Trash and Empty Trash both contain "Trash", so the toolbar's own button needs `exact`. */
|
||||
/**
|
||||
* The trash icon at the right end of the bar, named by its tooltip. Trash and
|
||||
* Empty Trash both contain "Trash", so it needs `exact`.
|
||||
*/
|
||||
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
|
||||
/** Assign File is a raw portal with no shared backdrop class; `portalDialog` finds its card. */
|
||||
const assignDialog = (page: Page) => portalDialog(page, page.getByText('Assign File', { exact: true }))
|
||||
/**
|
||||
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
|
||||
* its head band (Assign File, over the file's name) is what labels it.
|
||||
*/
|
||||
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
|
||||
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
|
||||
/**
|
||||
* The lightbox, found by the one header button only it and the document preview
|
||||
@@ -67,21 +78,26 @@ const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note.
|
||||
const lightbox = (page: Page) =>
|
||||
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
|
||||
/**
|
||||
* The preview for everything that is not a picture. For a PDF it is the card
|
||||
* around the browser's embedded viewer, found by the one `<object>` only it
|
||||
* renders; the markdown preview (`.collab-note-md`) has no step of its own.
|
||||
* The preview for everything that is not a picture: a DialogShell panel with
|
||||
* the file's name in the head band. For a PDF it holds the browser's embedded
|
||||
* viewer, the one `<object>` only it renders; the markdown preview
|
||||
* (`.collab-note-md`) has no step of its own.
|
||||
*/
|
||||
const pdfPreview = (page: Page) => portalDialog(page, page.locator('object[type="application/pdf"]'))
|
||||
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
|
||||
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
|
||||
/** The round × in a dialog's head band, named Close by its tooltip. */
|
||||
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
|
||||
/**
|
||||
* The × of a portal. The assign dialog and the document preview have no key
|
||||
* handler at all, so it is their only way out; the lightbox does listen for
|
||||
* Escape, which is what its own step uses.
|
||||
* A DialogShell hands the focus back to whatever opened it when it closes,
|
||||
* and a Tooltip opens on focus as well as on hover. The opener of the assign
|
||||
* dialog and of document sync is an icon with a tooltip, so without this the
|
||||
* after-picture shows "Assign" or "Document sync" floating under the pointer
|
||||
* that has long since moved away.
|
||||
*/
|
||||
const closeX = (dialog: Locator) => dialog.locator('button:has(svg.lucide-x)').first()
|
||||
/** The toolbar's way into document sync; there only while a store is on offer (see beforeAll). */
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
|
||||
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
|
||||
/** The dialog's title carries the trip's name under it, so the heading is matched by its first line. */
|
||||
/** The head band's title; the trip's name stands under it as a line of its own. */
|
||||
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
|
||||
/**
|
||||
* A store row in the dialog's sidebar. Its accessible name runs on into the
|
||||
@@ -100,6 +116,8 @@ const folderRow = (page: Page) => dialog(page).locator('li button').filter({ has
|
||||
const bindingCard = (page: Page) => dialog(page).locator('article').first()
|
||||
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
|
||||
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
|
||||
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
|
||||
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
|
||||
|
||||
interface ApiFile {
|
||||
id: number
|
||||
@@ -185,6 +203,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole panel: the head band with the file's name, the note and
|
||||
// the two lists are what the step's text walks through.
|
||||
target: assignDialog,
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
@@ -207,9 +227,6 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, LINKED).hover()
|
||||
},
|
||||
target: p => action(p, LINKED, 'Assign'),
|
||||
act: async p => {
|
||||
await action(p, LINKED, 'Assign').click()
|
||||
@@ -252,7 +269,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
await expect(assignDialog(p)).toHaveCount(0)
|
||||
await expect(row(p, LINKED).getByText(`Transport · ${FLIGHT}`)).toBeVisible({ timeout: 20_000 })
|
||||
await dropFocus(p)
|
||||
// A badge carries the name alone now; what it links to is its tooltip.
|
||||
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -264,9 +284,6 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, STARRED).hover()
|
||||
},
|
||||
target: p => action(p, STARRED, 'Star'),
|
||||
act: async p => {
|
||||
await action(p, STARRED, 'Star').click()
|
||||
@@ -278,17 +295,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// Sorting is the server's (starred first, then newest first), so the
|
||||
// list is only right once the reload has come back.
|
||||
prepare: async p => {
|
||||
await expect(p.locator('div.group').first()).toContainText(STARRED, { timeout: 20_000 })
|
||||
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.locator('div.group').first(),
|
||||
target: p => rows(p).first(),
|
||||
},
|
||||
{
|
||||
target: starTab,
|
||||
act: async p => {
|
||||
const starred = await tabCount(starTab(p))
|
||||
await starTab(p).click()
|
||||
await expect(p.locator('div.group')).toHaveCount(starred)
|
||||
await expect(rows(p)).toHaveCount(starred)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -305,7 +322,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
|
||||
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
|
||||
await expect(p.locator('div.group')).toHaveCount(pdfs)
|
||||
await expect(rows(p)).toHaveCount(pdfs)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -322,7 +339,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
|
||||
await p.getByRole('button', { name: /^All \d+$/ }).click()
|
||||
await expect(p.locator('div.group')).toHaveCount(all)
|
||||
await expect(rows(p)).toHaveCount(all)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -333,9 +350,6 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, PHOTO).hover()
|
||||
},
|
||||
// Two buttons in the row carry the file's name: the thumbnail and the
|
||||
// name itself. The reader is told about the name, which comes second.
|
||||
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
|
||||
@@ -400,9 +414,6 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, STARRED).hover()
|
||||
},
|
||||
target: p => action(p, STARRED, 'Download'),
|
||||
// The button fetches the bytes and clicks a synthetic anchor, so the
|
||||
// browser reports a real download.
|
||||
@@ -420,9 +431,6 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, DOOMED).hover()
|
||||
},
|
||||
target: p => action(p, DOOMED, 'Delete'),
|
||||
act: async p => {
|
||||
await action(p, DOOMED, 'Delete').click()
|
||||
@@ -452,7 +460,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// Empty Trash is there because the fixture leaves one document in the
|
||||
// trash. It asks with the browser's own confirm, which Playwright
|
||||
// dismisses, so clicking it would do nothing at all; the step shows the
|
||||
// button and leaves the trash by the toolbar instead.
|
||||
// button and leaves the trash by the trash icon instead.
|
||||
target: p => p.getByRole('button', { name: 'Empty Trash' }),
|
||||
act: async p => {
|
||||
await trashTab(p).click()
|
||||
@@ -542,18 +550,20 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// now while it is going answers busy and moves nothing, and the card
|
||||
// only learns of that first run from the ping it sends when it is
|
||||
// through. So the click is repeated until the card reports In sync,
|
||||
// which is a dot with a title once the state is good.
|
||||
// which is a dot named by its tooltip once the state is good.
|
||||
prepare: async p => {
|
||||
await expect(async () => {
|
||||
await syncNow(p).click({ timeout: 15_000 })
|
||||
await expect(bindingCard(p).getByTitle('In sync')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
|
||||
}).toPass({ timeout: 120_000, intervals: [3_000] })
|
||||
await settle(p)
|
||||
},
|
||||
target: bindingCard,
|
||||
// Closed with the × in the head band, as the step's text says.
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await closeX(dialog(p)).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await dropFocus(p)
|
||||
// The run's file:created events put the pulled documents at the top of the list.
|
||||
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
@@ -576,9 +586,9 @@ test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureFilesFixtures(request)
|
||||
// A store on offer puts Document sync next to Trash for the trip's owner,
|
||||
// which is how bullet 6 of the screen text describes the toolbar; on for the
|
||||
// whole file, so the hero and all seven guides show the same toolbar.
|
||||
// A store on offer puts Document sync beside the trash icon for the trip's
|
||||
// owner, which is how bullet 6 of the screen text describes the bar; on for
|
||||
// the whole file, so the hero and all seven guides show the same bar.
|
||||
await toggleAddon(request, 'nextcloud', true)
|
||||
})
|
||||
|
||||
|
||||
@@ -78,16 +78,24 @@ const shareMenu = (page: Page): Locator =>
|
||||
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
|
||||
/** Its click-catcher, the only way back out of the menu once a name is ticked. */
|
||||
const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last()
|
||||
/** The bulk-import dialog: its own portal, with none of the shared backdrop classes. */
|
||||
/** The bulk-import dialog, a DialogShell whose head band carries its title. */
|
||||
const importCard = (page: Page): Locator =>
|
||||
page.getByText('Import Packing List', { exact: true }).locator('xpath=..')
|
||||
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
|
||||
/**
|
||||
* The bag picker of a row and the member picker of a bag both hang off the
|
||||
* body now, so they are found by their caption rather than inside the row.
|
||||
*/
|
||||
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
|
||||
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
|
||||
/** The calendar a date field opens, a portal of its own. */
|
||||
const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Date picker' })
|
||||
/** The Export button's menu: print, Markdown and CSV. */
|
||||
const exportMenu = (page: Page): Locator => page.getByRole('menu')
|
||||
/** The printable page inside the preview, a srcdoc frame of its own. */
|
||||
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
|
||||
/** The bags column. Total weight is a span two divs inside it, under the rule. */
|
||||
/** The Bags card beside the lists. Total weight is a span in its foot band. */
|
||||
const bagSidebar = (page: Page): Locator =>
|
||||
page.getByText('Total weight', { exact: true }).locator('xpath=ancestor::div[3]')
|
||||
page.getByText('Total weight', { exact: true }).locator('xpath=ancestor::div[2]')
|
||||
/** The detail pane on the right; its header reads Task. */
|
||||
const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]')
|
||||
/** A task row is a div with role=button whose name starts with the task's own. */
|
||||
@@ -248,6 +256,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: catMenuCard,
|
||||
// The pointer rests on the three dots that opened it, not on an entry.
|
||||
// Moved by coordinates: the menu's click-catcher covers the button.
|
||||
hover: async p => {
|
||||
const b = await catMenu(p, NEW_LIST).boundingBox()
|
||||
if (b) await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2)
|
||||
},
|
||||
act: async p => {
|
||||
// The menu sits over a full-viewport catcher; a click in the corner
|
||||
// closes it without deleting the list the picture was just taken of.
|
||||
@@ -276,8 +290,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The whole progress card: the count, the bar, and the clean-up beside it.
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[4]')),
|
||||
// The count and the percentage, with the bar beside them: the whole card is
|
||||
// wider than any frame and would lose both at its edges.
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, 'Documents').click()
|
||||
@@ -357,6 +372,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
// An icon at the right end of the bar, named Import by its label and tooltip.
|
||||
target: p => p.getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Import', exact: true }).click()
|
||||
@@ -372,17 +388,21 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES)
|
||||
await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 })
|
||||
// Out of the box again, or its dark focus ring reads as a second ring
|
||||
// in the next two pictures.
|
||||
await importCard(p).locator('textarea').blur()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Clicking it would open the operating system's file chooser, which is
|
||||
// not part of the picture; the button is shown, not pressed.
|
||||
// In the dialog's footer, on the left. Clicking it would open the operating
|
||||
// system's file chooser, which is not part of the picture; the button is
|
||||
// shown, not pressed.
|
||||
only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })),
|
||||
{
|
||||
target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }),
|
||||
act: async p => {
|
||||
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
|
||||
await expect(p.getByText('Import Packing List', { exact: true })).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -517,18 +537,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'),
|
||||
act: async p => {
|
||||
await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click()
|
||||
await expect(item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' })).toBeVisible()
|
||||
await expect(bagPicker(p).getByRole('button', { name: 'Add bag' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' }),
|
||||
target: p => bagPicker(p).getByRole('button', { name: 'Add bag' }),
|
||||
act: async p => {
|
||||
await item(p, BAG_ITEM).getByRole('button', { name: 'Add bag' }).click()
|
||||
const field = item(p, BAG_ITEM).getByPlaceholder('Bag name...')
|
||||
await bagPicker(p).getByRole('button', { name: 'Add bag' }).click()
|
||||
const field = bagPicker(p).getByPlaceholder('Bag name...')
|
||||
await typeInto(p, field, BAG)
|
||||
await field.press('Enter')
|
||||
await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(bagPicker(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -551,7 +572,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
|
||||
act: async p => {
|
||||
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
|
||||
await bagSidebar(p).getByRole('button', { name: MEMBER }).click()
|
||||
await bagMemberPicker(p).getByRole('button', { name: MEMBER }).click()
|
||||
// The picker closes on a click anywhere beside it.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
|
||||
@@ -587,8 +608,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK)
|
||||
await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE)
|
||||
// Out of the field, so its dark focus border does not read as a second ring.
|
||||
await modal(p).getByPlaceholder('Description (optional)').blur()
|
||||
await settle(p)
|
||||
},
|
||||
// The name is typed into the dialog's head band, where Task name stands.
|
||||
target: p => modal(p).getByPlaceholder('Task name'),
|
||||
},
|
||||
{
|
||||
@@ -613,12 +637,20 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// Exact: the keyboard button beside it is "Enter date manually", which a
|
||||
// loose name match also finds.
|
||||
await modal(p).getByRole('button', { name: 'Date', exact: true }).click()
|
||||
await expect(p.getByRole('button', { name: DUE_DAY })).toBeVisible()
|
||||
await expect(datePicker(p)).toBeVisible()
|
||||
// The calendar opens on the picture day's month, the last of the trip,
|
||||
// and four days later can already be in the next one.
|
||||
if (!(await datePicker(p).getByRole('button', { name: DUE_DAY }).isVisible())) {
|
||||
await datePicker(p).getByRole('button', { name: 'Next month' }).click()
|
||||
}
|
||||
await expect(datePicker(p).getByRole('button', { name: DUE_DAY })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByRole('dialog').last(),
|
||||
target: datePicker,
|
||||
// The pointer on the day about to be picked, not on whatever the middle is.
|
||||
hover: async p => { await datePicker(p).getByRole('button', { name: DUE_DAY }).hover() },
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: DUE_DAY }).click()
|
||||
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
|
||||
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await p.getByRole('button', { name: ASSIGNEE }).click()
|
||||
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
@@ -32,6 +32,14 @@ const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom'
|
||||
*/
|
||||
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. */
|
||||
@@ -77,8 +85,12 @@ const glCanvas = (page: Page) => page.locator('.maplibregl-canvas')
|
||||
*/
|
||||
const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' })
|
||||
const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
|
||||
/** 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 })
|
||||
/**
|
||||
* 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()
|
||||
/**
|
||||
@@ -91,6 +103,47 @@ const row = (page: Page, name: string) => page.getByRole('option').filter({ hasT
|
||||
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<void> {
|
||||
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<number> {
|
||||
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
|
||||
@@ -121,12 +174,7 @@ async function reachable(locator: Locator, fromEnd = false): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
@@ -134,7 +182,7 @@ async function reachable(locator: Locator, fromEnd = false): Promise<number> {
|
||||
* 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> {
|
||||
async function panMap(page: Page, by: { x: number; y: number } = { x: -176, y: -72 }): 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 => ({
|
||||
@@ -153,7 +201,7 @@ async function panMap(page: Page): Promise<void> {
|
||||
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 })
|
||||
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)
|
||||
}
|
||||
@@ -175,7 +223,7 @@ async function panMap(page: Page): Promise<void> {
|
||||
*/
|
||||
async function openOnTokyo(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, 1)
|
||||
await collapseDayDetails(page).last().click()
|
||||
await foldDayDetails(page)
|
||||
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
}
|
||||
@@ -273,24 +321,32 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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)
|
||||
pin = await reachable(pins(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 pins(p).nth(pin).hover()
|
||||
await markerPin(p).hover()
|
||||
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
|
||||
},
|
||||
target: p => pins(p).nth(pin),
|
||||
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 pins(p).nth(pin).click()
|
||||
await markerPin(p).click()
|
||||
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -306,7 +362,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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 foldDayDetails(p)
|
||||
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -315,11 +371,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
// 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 => {
|
||||
pin = await reachable(pins(p))
|
||||
await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => pins(p).nth(pin),
|
||||
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
|
||||
target: p => badged(p, 2),
|
||||
dropTo: p => dayHeader(p, 3).locator('xpath=..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -341,7 +401,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await panMap(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' }),
|
||||
@@ -353,7 +418,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
pin = await reachable(poiPins(p), true)
|
||||
pin = await reachableInView(p, poiPins(p), true)
|
||||
},
|
||||
target: p => poiPins(p).nth(pin),
|
||||
act: async p => {
|
||||
@@ -491,6 +556,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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
|
||||
@@ -517,16 +599,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => endpoints(p).nth(pin),
|
||||
act: async p => {
|
||||
await endpoints(p).nth(pin).click()
|
||||
await expect(sheetClose(p)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(bookingDetailClose(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.
|
||||
// The whole detail, head band to footer: the text walks through all of it.
|
||||
target: bookingDetail,
|
||||
act: async p => {
|
||||
await sheetClose(p).click()
|
||||
await expect(sheetClose(p)).toHaveCount(0)
|
||||
await bookingDetailClose(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -63,7 +63,8 @@ const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING }
|
||||
/** The strip's lower row: Date, Time and Booking Code beside each other. */
|
||||
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR })
|
||||
// Exact: the picker's own button is named "Track color: #EA580C" once the colour is set.
|
||||
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR, exact: true })
|
||||
/** The seed's day 1: the trip starts nine days before the picture day (`start_date: day(-9)`). */
|
||||
const DAY_ONE = -9
|
||||
/** The weekday the English UI prints for a seeded day, `Saturday` on a run where day 1 is one. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
|
||||
@@ -40,9 +40,17 @@ const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('bu
|
||||
const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' })
|
||||
/** The corridor panel: before a search its heading is the only stable thing in it. */
|
||||
const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]')
|
||||
const kindPicker = (page: Page) => corridor(page).locator('button[aria-haspopup="listbox"]')
|
||||
/**
|
||||
* The two dropdowns of the Looking for line. Both are listbox triggers, so they
|
||||
* are told apart by name: the kinds read "Looking for: Fuel", the day "Day 1".
|
||||
*/
|
||||
const kindPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Looking for/ })
|
||||
const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first()
|
||||
/** The list a dropdown opens; only one is open at a time. */
|
||||
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
|
||||
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
|
||||
/** The drive band of day 4's long leg, the one with more than one road to it. */
|
||||
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
|
||||
/** The alternatives bar floats over the map, inside neither column. */
|
||||
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
|
||||
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
|
||||
@@ -78,13 +86,40 @@ async function waitForTheDrive(page: Page): Promise<void> {
|
||||
* stop frames that stop instead, and the card it opens is closed again at once.
|
||||
*/
|
||||
async function frameOnStop(page: Page, name: string): Promise<void> {
|
||||
await stopRow(page, name).click()
|
||||
await page.waitForTimeout(2500)
|
||||
const close = page.locator('button:has(svg.lucide-x)').last()
|
||||
if (await close.isVisible().catch(() => false)) await close.click()
|
||||
// On the name: the row's centre can land on its Stay badge, which opens its
|
||||
// own dialog instead of selecting the stop.
|
||||
await stopRow(page, name).click({ position: { x: 70, y: 12 } })
|
||||
// The place's card opens over the map; its Close sits in the card's head band.
|
||||
const head = placeCardHead(page, name)
|
||||
await expect(head).toBeVisible({ timeout: 15_000 })
|
||||
await finishMapPan(page)
|
||||
await head.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(head).toHaveCount(0)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the map's pan to a picked place land. Leaflet times its pan from
|
||||
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
|
||||
* the pan starts and never gets anywhere: the map stays where it was. Moving the
|
||||
* pinned clock a few seconds on ends it on the next frame, as a second passing
|
||||
* would; nothing in these pictures reads the time to the second.
|
||||
*/
|
||||
async function finishMapPan(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400)
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
|
||||
await page.waitForTimeout(1200)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of the card a stop opens over the map (the place inspector):
|
||||
* the place's name as a heading, and Close beside it. The rail prints the same
|
||||
* name, but never as a heading.
|
||||
*/
|
||||
const placeCardHead = (page: Page, name: string) =>
|
||||
page.getByRole('heading', { name, exact: true, level: 2 }).locator('xpath=ancestor::header[1]')
|
||||
|
||||
type RoutePoint = { leg: number; x: number; y: number }
|
||||
|
||||
/**
|
||||
@@ -159,6 +194,15 @@ async function clearVias(page: Page): Promise<void> {
|
||||
for (const via of await listVias(page)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`)
|
||||
}
|
||||
// A followed track is recorded per day even when the drive needed no via to
|
||||
// follow it, and deleting vias leaves that record standing: clear it too.
|
||||
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
|
||||
const { tracks = [] } = (await res.json()) as { tracks?: { day_id: number }[] }
|
||||
for (const track of tracks) {
|
||||
await page.request.post(`/api/trips/${tripId}/roadtrip/days/${track.day_id}/vias/batch`, {
|
||||
data: { vias: [], track: null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDayBoundaries(page: Page): Promise<void> {
|
||||
@@ -309,27 +353,31 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: dayPicker,
|
||||
// The line reads "these kinds, along this day", so the kinds come first.
|
||||
prepare: async p => {
|
||||
await kindPicker(p).click()
|
||||
await expect(openList(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: openList,
|
||||
act: async p => {
|
||||
await dayPicker(p).click()
|
||||
// The rows are options named by the day's number and its label, "2 Day 2".
|
||||
await p.getByRole('option', { name: /\bDay 2$/ }).last().click()
|
||||
await expect(dayPicker(p)).toContainText('Day 2')
|
||||
// Fuel is what the panel opens on for a petrol car; Food is ticked beside it.
|
||||
await openList(p).getByRole('option', { name: 'Food' }).click()
|
||||
await expect(openList(p).getByRole('option', { name: 'Food' })).toHaveAttribute('aria-selected', 'true')
|
||||
// The list stays open for a second kind; a second click on its trigger shuts it.
|
||||
await kindPicker(p).click()
|
||||
await expect(openList(p)).toHaveCount(0)
|
||||
await expect(kindPicker(p)).toHaveAccessibleName(/Food/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await kindPicker(p).click()
|
||||
await expect(corridor(p).locator('div[role="listbox"]')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => corridor(p).locator('div[role="listbox"]'),
|
||||
target: dayPicker,
|
||||
act: async p => {
|
||||
await corridor(p).getByRole('option', { name: 'Food' }).click()
|
||||
// The list closes on a second click of its own trigger, not on Escape.
|
||||
await kindPicker(p).click()
|
||||
await expect(kindPicker(p)).toContainText('Food')
|
||||
await dayPicker(p).click()
|
||||
// The rows are options named by the day's number and its label, "2 Day 2".
|
||||
await openList(p).getByRole('option', { name: /\bDay 2$/ }).click()
|
||||
await expect(dayPicker(p)).toContainText('Day 2')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -376,9 +424,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
target: p => stopItem(p, 'Meiji Jingu'),
|
||||
// A stop of day 4, the drive out of Tokyo: the one leg long enough to be
|
||||
// worth bending, and once Hakone is in the middle of the map it runs
|
||||
// east across the open map rather than under the right column.
|
||||
target: p => stopItem(p, 'Hakone Shrine'),
|
||||
act: async p => {
|
||||
await frameOnStop(p, 'Meiji Jingu')
|
||||
await frameOnStop(p, 'Hakone Shrine')
|
||||
await aimAtLeg(p)
|
||||
},
|
||||
},
|
||||
@@ -443,11 +494,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
target: p => dayCard(p, 2).getByRole('button', { name: /Other ways$/ }).last(),
|
||||
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
|
||||
// Shibuya the router knows one sensible way, and the bar says only that.
|
||||
target: altBand,
|
||||
act: async p => {
|
||||
await dayCard(p, 2).getByRole('button', { name: /Other ways$/ }).last().click()
|
||||
await altBand(p).click()
|
||||
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
|
||||
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -512,7 +566,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// Not toggled on purpose: each of these switches sends every leg of the
|
||||
// trip back through the avoidance router, which is minutes of waiting for
|
||||
// one picture of a row of switches.
|
||||
only(p => modal(p).getByRole('button', { name: 'Toll roads' }).locator('xpath=ancestor::section[1]')),
|
||||
// By its title, not by a switch: without an avoidance router the switches
|
||||
// are replaced by a dash and the section is all that is left of it.
|
||||
only(p => modal(p).getByText('Avoid where possible', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await closeModal(p)
|
||||
|
||||
@@ -49,6 +49,8 @@ async function openDayMenu(page: Page, n: number): Promise<void> {
|
||||
}
|
||||
/** 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')
|
||||
@@ -334,7 +336,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(card(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
|
||||
await expect(cardShowing(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -379,6 +381,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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()
|
||||
|
||||
@@ -23,7 +23,10 @@ for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||
}
|
||||
|
||||
// Build once (no watcher) — the resulting process is a single killable node.
|
||||
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
// Several runs on their own port pairs share one dist, so they build it once
|
||||
// beforehand and set E2E_SERVER_PREBUILT: a rebuild under a running server
|
||||
// swaps its files mid-flight.
|
||||
if (!process.env.E2E_SERVER_PREBUILT) execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 74 KiB |