Files
TREK/client/e2e/help/trip.guide.ts
T
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00

364 lines
14 KiB
TypeScript

import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureUsers, seededTrip, ADMIN_USERS, JOURNEY_TITLE } from './fixtures'
import { openTrip, openTripOnDay, modal } from './trip-shared'
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
* the trip's Settings (people, sharing, the linked journal), the columns and
* the undo. They run on the seeded "Autumn in Japan"; what a guide creates (a
* member, a guest, a link, a linked journal) it leaves for the next one or
* takes away again in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/**
* A part of the Settings dialog by the words it opens with: a DialogSection
* (its label) or a LinkCard (its title). The areas around them are sections
* too, but they open with the area's own name, so they never match.
*/
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
const inviteBlock = (page: Page) => block(page, /^Invite User/)
const accessBlock = (page: Page) => block(page, /^Access\s*\d/)
/** An area in the dialog's list on the left; each carries the area's name as its label. */
const area = (page: Page, name: 'People' | 'Sharing' | 'Journal') => modal(page).getByRole('button', { name, exact: true })
/** The Trip settings pill beside the trip's name. */
const settingsPill = (page: Page) => page.getByRole('button', { name: 'Trip settings', exact: true })
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
* column is open and Plan once it is folded, and both names are taken
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
*/
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
const leftDivider = (page: Page) => page.getByRole('separator', { name: 'Panel width' }).first()
async function clickSettings(page: Page): Promise<void> {
await settingsPill(page).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Trip settings' })).toBeVisible()
await settle(page)
}
async function openSettings(page: Page): Promise<void> {
await openTrip(page)
await clickSettings(page)
}
async function pickArea(page: Page, name: 'Sharing' | 'Journal'): Promise<void> {
await area(page, name).click()
await expect(area(page, name)).toHaveAttribute('aria-current', 'page')
await settle(page)
}
const closeModal = async (page: Page): Promise<void> => {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
/** Take the journal off the trip again; a trip without one answers that too, which is fine. */
async function unlinkJournal(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.request.delete(`/api/trips/${tripId}/journal`)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'add-member': {
guide: guide('add-member'),
start: p => openTrip(p),
steps: [
{
target: settingsPill,
act: clickSettings,
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Select user…' }).click()
// The select's list is rendered outside the dialog.
await p.getByRole('button', { name: 'mara', exact: true }).last().click()
await beat(p, 300)
},
target: inviteBlock,
act: async p => {
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(accessBlock(p).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(accessBlock),
],
cleanup: closeModal,
},
'trip-invite-link': {
guide: guide('trip-invite-link'),
start: async p => {
await openSettings(p)
await pickArea(p, 'Sharing')
},
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create invite link' }).click()
await expect(modal(p).getByRole('button', { name: 'Regenerate' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Trip invite link/)),
// The row with Regenerate and Disable.
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
],
cleanup: async p => {
await modal(p).getByRole('button', { name: 'Disable' }).click()
await expect(modal(p).getByRole('button', { name: 'Create invite link' })).toBeVisible({ timeout: 15_000 })
await closeModal(p)
},
},
'add-guest': {
guide: guide('add-guest'),
start: openSettings,
steps: [
only(p => block(p, /^Guests/)),
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Guest name'), 'Aunt Lise')
await beat(p, 300)
},
target: p => modal(p).getByPlaceholder('Guest name').locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Add guest' }).click()
await expect(block(p, /^Guests/).getByText('Aunt Lise', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: closeModal,
},
'public-link': {
guide: guide('public-link'),
start: openSettings,
steps: [
{
target: p => area(p, 'Sharing'),
act: p => pickArea(p, 'Sharing'),
},
{
// The first row of chips: Map & Plan, Bookings, Packing, Costs, Chat.
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Packing', exact: true }).click()
await expect(modal(p).getByRole('button', { name: 'Packing', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
// Options with its two chips; the pointer on the first shows what it leaves out.
target: p => modal(p).getByRole('button', { name: 'Travel & stays only' }).locator('xpath=../..'),
hover: async p => {
await modal(p).getByRole('button', { name: 'Travel & stays only' }).hover()
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create link', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create link', exact: true }).click()
await expect(modal(p).getByRole('button', { name: 'Delete link' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => block(p, /^Public Link/),
act: async p => {
await modal(p).getByRole('button', { name: 'Delete link' }).click()
await expect(modal(p).getByRole('button', { name: 'Create link', exact: true })).toBeVisible({ timeout: 15_000 })
},
},
],
// The chips live in the dialog until a link is saved, and the link is gone again.
cleanup: closeModal,
},
'transfer-ownership': {
guide: guide('transfer-ownership'),
start: openSettings,
steps: [
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
],
cleanup: closeModal,
},
'link-journal': {
guide: guide('link-journal'),
start: async p => {
await unlinkJournal(p)
await openSettings(p)
},
steps: [
{
target: p => area(p, 'Journal'),
act: async p => {
await pickArea(p, 'Journal')
await expect(modal(p).getByRole('button', { name: 'Link a journal' })).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link a journal' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link a journal' }).click()
await modal(p).getByRole('button', { name: 'Choose a journal' }).click()
// The select's list is rendered outside the dialog.
await p.getByRole('button', { name: JOURNEY_TITLE, exact: true }).last().click()
await expect(modal(p).getByText('Access for trip members', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The heading row (Everyone can read / Nobody) and a row per person.
target: p => modal(p).getByText('Access for trip members', { exact: true }).locator('xpath=../..'),
act: async p => {
const edit = modal(p).getByRole('radio', { name: 'Edit' }).first()
await edit.click()
await expect(edit).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link journal', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link journal', exact: true }).click()
await expect(modal(p).getByText('Linked', { exact: true })).toBeVisible({ timeout: 15_000 })
await closeModal(p)
await expect(p.getByRole('button', { name: 'Journal', exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: unlinkJournal,
},
'collapse-columns': {
guide: guide('collapse-columns'),
start: p => openTrip(p),
steps: [
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(leftToggle(p)).toHaveAttribute('aria-label', 'Plan')
await settle(p)
},
},
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(p.getByRole('button', { name: 'Export' })).toBeVisible()
await settle(p)
},
},
{
// The divider is invisible until hovered; the hover shows it.
target: leftDivider,
// Out to a wider column and back again, so the loop ends where it began.
clip: async (p, frame) => {
const box = await leftDivider(p).boundingBox()
if (!box) throw new Error('the days column has no divider')
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await p.mouse.move(x, y)
for (let i = 0; i < 3; i++) await frame()
await p.mouse.down()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + (140 * i) / 16, y, { steps: 2 })
await frame()
}
for (let i = 0; i < 4; i++) await frame()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + 140 - (140 * i) / 16, y, { steps: 2 })
await frame()
}
await p.mouse.up()
await p.waitForTimeout(300)
for (let i = 0; i < 3; i++) await frame()
},
},
],
},
'undo-change': {
guide: guide('undo-change'),
start: async p => {
const { tripId } = seededTrip()
const created = await p.request.post(`/api/trips/${tripId}/places`, {
data: { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' },
})
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
// A change to undo: the spare place onto day 1, from the places column.
// The day has to stay open for that: the + at the end of a row belongs to
// the open day, and closing the day's details panel closes the day with it.
await openTripOnDay(p, 1)
const row = p.getByRole('option', { name: /^Tsukiji Outer Market/ })
await row.hover()
const add = row.getByRole('button', { name: '+ Day' })
await expect(add, 'the + that puts a place on the open day').toBeVisible({ timeout: 15_000 })
await add.click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Undo' }),
act: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(p.getByText(/^Undone: /).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => x.name === 'Tsukiji Outer Market')) {
await p.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
// The seed's members already have access; the invite needs someone who has not.
await ensureUsers(request, ADMIN_USERS)
})
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
test('every registered trip guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripContext.guides].sort())
})
test('hero: trip', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripContext.id, p => openTrip(p))
})
for (const id of tripContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}