feat(help): in-app help center with per-screen guides and pictures

A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
This commit is contained in:
Maurice
2026-09-21 23:20:27 +02:00
parent 151e32107f
commit 4346d7f2c1
483 changed files with 47654 additions and 33 deletions
+57
View File
@@ -0,0 +1,57 @@
# Help-center media
Every picture the help center shows is made here, by Playwright, against the real
app. Nothing is drawn or cropped by hand, so a new screen gets pictures in exactly
the same style by following the same path.
## The style, in one place
Everything visual is fixed in `guide.ts` and `promote.mjs`; change it there, never
in a script.
| What | Where | Value |
|---|---|---|
| Viewport | `guide.ts` `VIEWPORT` | 1920 × 1080, device scale 2 (`playwright.config.ts`, project `help-media`) |
| Step picture frame | `guide.ts` `frameFor` | 16:10, at least 960 px wide, 64 px padding around the target, centred on it; a target taller than the frame is shown from its top |
| Ring around the target | `guide.ts` `ring` | 3 px in the app's `--accent`, 12 px radius, 6 px outside the element, the rest dimmed 30 %; a numbered badge (step number) at the top-left corner |
| Drag & drop | `guide.ts` `ringDrag` (a step with `dropTo`) | source ringed and numbered, destination ringed dashed, a bowed arrow from the one to the other, both undimmed |
| Hover | `captureGuide` | the target is hovered before the shot (tooltips, hover-only buttons show); `hover` overrides where the centre is wrong |
| Result picture | `guide.media.result` | the full frame after the last step, pointer parked in the corner |
| Hero (screen overview) | `captureHero` | the full frame of the screen as the reader finds it |
| Output | `promote.mjs` | PNG → WebP quality 84; steps 1440 px wide, heroes and results 1920 px; into `public/help-media/<guide-id>/step-<n>.webp`, `result.webp`, `ctx/<screen-id>.webp` |
| No videos | | stills only, by decision |
The app's own appearance is the default theme, English, light mode, the seeded
demo data (`e2e/screenshots/seed.ts`) plus the fixtures in `fixtures.ts`. Do not
change the theme, the language or the zoom for a picture.
## How a screen gets its pictures
1. Register the screen and its guides in `src/help/contexts/<screen>.ts` and
`src/help/registry.ts`; write the English texts as a block in
`shared/src/i18n/en/help.ts` (`// ── Screen: <id>`). Guide ids are global: one id,
one picture directory, so never reuse an id from another screen
(`registry.test.ts` refuses duplicates).
2. Write `e2e/help/<screen>.guide.ts`: one `GuideScript` per guide, exactly one
`StepAction` per registered step, `start` that opens the screen, `cleanup` that
puts the seed back. A step's `target` is the element the text tells the reader
to use; `prepare` gets there, `act` does what the text says.
3. Run `npm run help:media -- <screen>.guide.ts` (own ports 5183/3011, fresh
database, the seed, then the file). Fix until every guide passes; a failing
step is a text that no longer matches the UI.
4. Look at the pictures in `e2e/.tmp/help-media/`, then `node e2e/help/promote.mjs`.
5. Translate the block into all 22 locales (`i18n:parity:strict` is the gate) and
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
## Rules learned the hard way
- Never edit `shared/` while a media run is going: the watcher rebuilds `dist`
mid-run and the pictures show raw i18n keys.
- Never `npm run build --workspace=shared` while `npm run dev` is running; restart
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.
- 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 is running on the seed's dates: the plan opens on today's (empty) day;
`openTrip` in `trip-shared.ts` selects day 1 and closes its details panel.
+578
View File
@@ -0,0 +1,578 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureAdminFixtures, pluginPackage } from './fixtures'
import { adminContext, adminTabContexts, adminGuides } from '../../src/help/contexts/admin'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the admin screens, keyed by the ids in `src/help/contexts/admin.ts`.
* One route, one tab per screen. The guides that create something (a user, an
* invite link, a backup, an installed plugin) either leave a harmless row behind
* or take it away again in `cleanup`, so the tabs after them look like the seed.
*/
const TAB_OF: Record<string, string> = {
'admin-users': 'Users',
'admin-defaults': 'User Defaults',
'admin-config': 'Personalization',
'admin-settings': 'Settings',
'admin-addons': 'Addons',
'admin-plugins': 'Plugins',
'admin-storage': 'Storage',
'admin-notifications': 'Notifications',
'admin-mcp-tokens': 'MCP Access',
'admin-github': 'GitHub',
'admin-backup': 'Backup',
'admin-audit': 'Audit',
}
const guide = (id: string): HelpGuide => {
const g = adminGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'User Defaults' }) }).first()
/** The nearest card around an element: the admin cards are rounded-xl or rounded-2xl boxes, or a <section>. */
const cardOf = (el: Locator) => el.locator('xpath=ancestor::*[contains(@class,"rounded-xl") or contains(@class,"rounded-2xl") or self::section][1]')
/** An admin card by the text of its h2. */
const section = (page: Page, title: string | RegExp) => cardOf(page.locator('h2').filter({ hasText: title }).first())
/** A provider block under Settings (its title is a <p>, not a heading). */
const block = (page: Page, title: string) => cardOf(page.locator('p').filter({ hasText: title }).first())
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A row of the user table by its username. */
const userRow = (page: Page, username: string) => page.locator('table tbody tr').filter({ hasText: username }).first()
/** An addon tile by the addon's name. */
const tile = (page: Page, name: string) => page.locator('h4').filter({ hasText: new RegExp(`^${name}$`) }).first().locator('xpath=ancestor::article[1]')
async function openAdmin(page: Page, tab: string): Promise<void> {
await page.goto('/admin')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
// The run's server is in development mode, which adds a developer-only tab
// nobody has in production; the pictures show the sidebar as shipped.
await sidebar(page).getByRole('button', { name: 'Dev: Notifications' }).evaluateAll(els => {
for (const el of els) (el as HTMLElement).style.display = 'none'
})
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
await settle(page)
}
const opener = (contextId: string) => (page: Page) => openAdmin(page, TAB_OF[contextId])
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
// ── Users ────────────────────────────────────────────────────────────────
'create-user': {
guide: guide('create-user'),
start: opener('admin-users'),
steps: [
{
target: p => section(p, /^Users$/).getByRole('button', { name: 'Create User' }),
act: async p => {
await section(p, /^Users$/).getByRole('button', { name: 'Create User' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Username'), 'lena')
await typeInto(p, modal(p).getByPlaceholder('Email'), 'lena.k@example.com')
await typeInto(p, modal(p).getByPlaceholder('Password'), 'Lena12345!')
await beat(p, 300)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
},
{
target: p => modal(p).getByRole('button', { name: 'Create User' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create User' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(userRow(p, 'lena')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
},
'edit-user': {
guide: guide('edit-user'),
start: opener('admin-users'),
steps: [
{
target: p => userRow(p, 'mara').getByTitle('Edit User'),
act: async p => {
await userRow(p, 'mara').getByTitle('Edit User').click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
'invite-links': {
guide: guide('invite-links'),
start: opener('admin-users'),
steps: [
{
target: p => section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }),
act: async p => {
await section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: '3×', exact: true }).click()
await modal(p).getByRole('button', { name: '14d', exact: true }).click()
await beat(p, 300)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Create & Copy' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
only(p => section(p, 'Invite Links')),
],
},
'delete-user': {
guide: guide('delete-user'),
start: opener('admin-users'),
steps: [
only(p => userRow(p, 'jonas').getByTitle('Delete user')),
],
},
permissions: {
guide: guide('permissions'),
start: opener('admin-users'),
steps: [
{
prepare: async p => {
const row = section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..')
// The select's trigger is the row's only button; its options are buttons too.
await row.locator('button').first().click()
await p.getByRole('button', { name: 'Admin only', exact: true }).last().click()
await expect(row.getByText('customized')).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..'),
},
{
target: p => section(p, 'Permission Settings').getByRole('button', { name: 'Save', exact: true }),
// Not saved: the seed keeps its defaults for the other screens.
act: async p => {
await section(p, 'Permission Settings').getByRole('button', { name: 'Reset to defaults' }).click()
await settle(p)
},
},
],
},
// ── User Defaults ────────────────────────────────────────────────────────
'default-map': {
guide: guide('default-map'),
start: opener('admin-defaults'),
steps: [
only(p => section(p, /^Map$/)),
{
// The reset link only exists next to a field an admin has set.
prepare: async p => {
await section(p, 'Default User Settings').getByRole('button', { name: 'Dark', exact: true }).click()
// The link sits inside the field's label, so its accessible name is the label's, not "reset".
await expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, 'Default User Settings').locator('label').filter({ hasText: 'Color Mode' }).locator('xpath=..'),
act: async p => {
await section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ }).first().click()
await settle(p)
},
},
],
},
// ── Personalization ──────────────────────────────────────────────────────
'packing-templates': {
guide: guide('packing-templates'),
start: opener('admin-config'),
steps: [
{
target: p => section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }),
act: async p => {
await section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }).click()
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
await expect(input).toBeVisible()
await typeInto(p, input, 'City Break')
await beat(p, 300)
// Leave the seed as it is: the second icon next to the field discards the draft.
await input.locator('xpath=..').locator('button').nth(1).click()
await expect(input).toHaveCount(0)
},
},
{
prepare: async p => {
await p.getByRole('button', { name: 'Beach Holiday', exact: true }).click()
await expect(p.getByRole('button', { name: 'Add category' })).toBeVisible()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=../..').locator('> div').nth(1),
},
only(p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=..')),
],
},
categories: {
guide: guide('categories'),
start: opener('admin-config'),
steps: [
{
prepare: async p => {
await section(p, /^Categories$/).getByRole('button', { name: 'New Category' }).click()
await expect(p.getByPlaceholder('Category name')).toBeVisible()
await typeInto(p, p.getByPlaceholder('Category name'), 'Viewpoints')
await settle(p)
},
target: p => cardOf(p.getByPlaceholder('Category name')),
act: async p => {
await section(p, /^Categories$/).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
},
},
{
target: p => section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"group")][1]'),
hover: async p => {
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().hover()
},
},
],
},
'school-holiday-catalog': {
guide: guide('school-holiday-catalog'),
start: opener('admin-config'),
steps: [
{
prepare: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Add country' }).click()
await expect(section(p, 'School holidays').getByPlaceholder('US')).toBeVisible()
await settle(p)
},
target: p => section(p, 'School holidays'),
act: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Cancel' }).click()
await expect(section(p, 'School holidays').getByPlaceholder('US')).toHaveCount(0)
},
},
{
prepare: async p => {
await section(p, 'School holidays').getByRole('button', { name: 'Bavaria', exact: true }).click()
await expect(modal(p)).toBeVisible()
await expect(modal(p).getByRole('textbox', { name: 'Holiday name' }).first()).toHaveValue('Autumn break', { timeout: 10_000 })
await settle(p)
},
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
// ── Settings ─────────────────────────────────────────────────────────────
'auth-methods': {
guide: guide('auth-methods'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Authentication Methods').getByText('Password Login', { exact: true }).locator('xpath=../..')),
// The SSO rows exist only once OIDC is configured; the run shows where that happens.
only(p => section(p, 'Single Sign-On (OIDC)')),
only(p => section(p, 'Require two-factor authentication (2FA)')),
],
},
oidc: {
guide: guide('oidc'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Single Sign-On (OIDC)')),
only(p => section(p, 'Authentication Methods')),
],
},
'instance-keys': {
guide: guide('instance-keys'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Google Maps API Key')),
{
prepare: async p => {
const details = p.locator('details').filter({ hasText: 'What the key may be used for' })
if (!(await details.evaluate(el => (el as HTMLDetailsElement).open))) await details.locator('summary').click()
await settle(p)
},
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
},
only(p => block(p, 'Unsplash API Key')),
],
},
'places-transit': {
guide: guide('places-transit'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Place search provider')),
only(p => block(p, 'Transit Provider')),
],
},
'file-types': {
guide: guide('file-types'),
start: opener('admin-settings'),
steps: [
only(p => section(p, 'Allowed File Types')),
],
},
// ── Addons ───────────────────────────────────────────────────────────────
'toggle-addon': {
guide: guide('toggle-addon'),
start: opener('admin-addons'),
steps: [
{
target: p => tile(p, 'Atlas'),
act: async p => {
const toggle = tile(p, 'Atlas').getByRole('button', { name: 'Atlas' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'false', { timeout: 10_000 })
await beat(p, 600)
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
await settle(p)
},
},
only(p => tile(p, 'Lists')),
],
},
// ── Plugins ──────────────────────────────────────────────────────────────
'install-plugin': {
guide: guide('install-plugin'),
start: async p => {
const upload = await p.request.post('/api/admin/plugins/upload', {
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
})
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`)
await openAdmin(p, 'Plugins')
},
steps: [
{
prepare: async p => {
await p.getByRole('tab', { name: /Discover/ }).click()
await settle(p)
await p.waitForTimeout(1500)
},
target: p => section(p, /^Plugins$/),
act: async p => {
await p.getByRole('tab', { name: /Installed/ }).click()
await settle(p)
},
},
{
target: p => p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]'),
act: async p => {
const row = p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
await row.getByRole('button', { name: 'Enable plugin' }).click()
await expect(row.getByText('Active', { exact: true })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
prepare: async p => {
await p.getByTestId('plugin-row-menu-btn-trip-doctor').click()
await expect(p.getByTestId('plugin-row-menu-trip-doctor')).toBeVisible()
await beat(p, 300)
},
target: p => p.getByTestId('plugin-row-menu-trip-doctor'),
// Allowed hosts only shows for plugins that call out; Trip Doctor does not.
hover: async p => { await p.getByTestId('plugin-row-menu-trip-doctor').getByText('View error log').hover() },
act: async p => { await p.keyboard.press('Escape') },
},
],
cleanup: async p => {
const res = await p.request.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } })
if (!res.ok()) throw new Error(`could not uninstall the plugin: ${res.status()} ${await res.text()}`)
},
},
// ── Storage ──────────────────────────────────────────────────────────────
'storage-backends': {
guide: guide('storage-backends'),
start: opener('admin-storage'),
steps: [
{
prepare: async p => {
await section(p, /^Backends$/).getByRole('button', { name: 'Add backend' }).click()
await expect(p.locator('p').filter({ hasText: /^Add backend$/ })).toBeVisible()
await typeInto(p, cardOf(p.locator('p').filter({ hasText: /^Add backend$/ })).locator('input').first(), 'archive')
await settle(p)
},
target: p => cardOf(p.locator('p').filter({ hasText: /^Add backend$/ })),
act: async p => {
await cardOf(p.locator('p').filter({ hasText: /^Add backend$/ })).getByRole('button', { name: 'Cancel' }).click()
await expect(p.locator('p').filter({ hasText: /^Add backend$/ })).toHaveCount(0)
},
},
only(p => section(p, /^Categories$/)),
only(p => section(p, /^Health$/)),
],
},
// ── Notifications ────────────────────────────────────────────────────────
'channels-instance': {
guide: guide('channels-instance'),
start: opener('admin-notifications'),
steps: [
only(p => section(p, 'Email (SMTP)')),
only(p => section(p, /^Ntfy$/)),
only(p => section(p, 'Trip Reminders')),
],
},
'admin-channels': {
guide: guide('admin-channels'),
start: opener('admin-notifications'),
steps: [
only(p => section(p, 'Admin Ntfy')),
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
],
},
// ── MCP Access ───────────────────────────────────────────────────────────
'mcp-tokens-admin': {
guide: guide('mcp-tokens-admin'),
start: opener('admin-mcp-tokens'),
steps: [
only(p => p.locator('h3').filter({ hasText: 'API Tokens' }).locator('xpath=..')),
only(p => p.locator('h3').filter({ hasText: 'OAuth Sessions' }).locator('xpath=..')),
],
},
// ── GitHub ───────────────────────────────────────────────────────────────
'release-history': {
guide: guide('release-history'),
start: opener('admin-github'),
steps: [
{
// The banner exists only while a newer release is out; otherwise the card's head.
target: p => {
const banner = p.getByText('Update available', { exact: true })
return banner.or(section(p, 'Release History').locator('> div').first()).first()
},
},
{
prepare: async p => {
await expect(section(p, 'Release History').getByRole('button', { name: 'Show details' }).first()).toBeVisible({ timeout: 20_000 })
},
target: p => section(p, 'Release History'),
},
],
},
// ── Backup ───────────────────────────────────────────────────────────────
'create-backup': {
guide: guide('create-backup'),
start: opener('admin-backup'),
steps: [
{
target: p => section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }),
act: async p => {
await section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }).click()
await expect(section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first()).toBeVisible({ timeout: 120_000 })
await settle(p)
},
},
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first().locator('xpath=ancestor::div[contains(@class,"py-3")][1]')),
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Restore' }).first()),
],
cleanup: async p => {
const list = await p.request.get('/api/backup/list')
const body = (await list.json()) as { backups?: { filename: string }[] } | { filename: string }[]
const backups = Array.isArray(body) ? body : (body.backups ?? [])
for (const b of backups) await p.request.delete(`/api/backup/${encodeURIComponent(b.filename)}`)
},
},
'auto-backup': {
guide: guide('auto-backup'),
start: opener('admin-backup'),
steps: [
{
target: p => section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).locator('xpath=ancestor::label[1]'),
act: async p => {
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
only(p => section(p, 'Auto-Backup').getByText('Delete old backups after', { exact: true }).locator('xpath=..')),
],
cleanup: async p => {
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toHaveCount(0)
await settle(p)
},
},
// ── Audit ────────────────────────────────────────────────────────────────
'audit-log': {
guide: guide('audit-log'),
start: opener('admin-audit'),
steps: [
only(p => cardOf(p.locator('table').first())),
only(p => p.getByRole('button', { name: 'Refresh' })),
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
const ALL_CONTEXTS = [adminContext, ...adminTabContexts]
test.beforeAll(async ({ request }) => {
await ensureAdminFixtures(request)
})
// The invite guide copies the new link; without the permission the write rejects
// and the page logs an unhandled promise, which is noise the pictures do not need.
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
test('every registered admin guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
})
test('hero: admin', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, adminContext.id, p => openAdmin(p, 'Users'))
})
for (const ctx of adminTabContexts) {
test(`hero: ${ctx.id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, ctx.id, opener(ctx.id))
})
}
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+381
View File
@@ -0,0 +1,381 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureAtlasFixtures } from './fixtures'
import { atlasGuides, atlasContext } from '../../src/help/contexts/atlas'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
* They run in the order the context lists them and leave their marks on the
* map on purpose: Greece is marked and removed again, a region of Austria and
* Bavaria stay, Iceland and Petra land on the bucket list. The countries
* are drawn on a canvas, so the way in is always the search box: it flies to
* the country and opens the same popup a click on the map would.
*/
const guide = (id: string): HelpGuide => {
const g = atlasGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
async function openAtlas(page: Page): Promise<void> {
await page.goto('/atlas')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Stats' })).toBeVisible({ timeout: 20_000 })
// The country layer is a canvas; it exists once the borders have arrived.
await expect(page.locator('.leaflet-overlay-pane canvas').first()).toBeVisible({ timeout: 20_000 })
await settle(page)
await page.waitForTimeout(600)
}
/** The search box over the map and its dropdown. */
const searchInput = (page: Page) => page.getByPlaceholder('Search a country...')
const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div[2]')
/** A country in the dropdown (its accessible name starts with the flag's alt text). */
const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) })
/** A geocoded place in the dropdown, under the Places heading. */
const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first()
/** The country / region popup card. */
const popup = (page: Page) => page.locator('div[role="presentation"] > div[role="presentation"]')
/** The glass panel at the bottom. */
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
/** The detail card the panel grows for a country picked in the search. */
const detailCard = (page: Page, country: string) =>
panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..')
async function searchCountry(page: Page, name: string): Promise<void> {
const input = searchInput(page)
await input.click()
await input.fill('')
await typeInto(page, input, name)
await expect(countryHit(page, name)).toBeVisible()
}
async function pickCountry(page: Page, name: string): Promise<void> {
await countryHit(page, name).click()
await page.waitForTimeout(900) // fitBounds animates
await settle(page)
}
/** Close the country popup by clicking beside it (there is no Escape). */
async function closePopup(page: Page): Promise<void> {
await page.mouse.click(40, VIEWPORT.height - 40)
await expect(popup(page)).toHaveCount(0)
}
/** The next unset select in a scope: month and year both read “—” until picked. */
const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first()
/** Pick an option in one of the month / year selects. */
async function choose(page: Page, trigger: Locator, label: string): Promise<void> {
await trigger.click()
await page.getByRole('button', { name: label, exact: true }).last().click()
await beat(page, 300)
}
/**
* Tag the region path under the middle of the map, so a step can ring it. The
* regions are SVG, but no element carries a name; what is at the centre after
* flying to a country is what the reader would click first anyway.
*/
async function tagCentreRegion(page: Page): Promise<void> {
// The first region request builds the server's admin-1 index; that can take a while.
await expect(page.locator('.leaflet-region-pane path').first()).toBeVisible({ timeout: 90_000 })
const map = await page.locator('.leaflet-container').boundingBox()
if (!map) throw new Error('no map on screen')
const point = { x: map.x + map.width / 2, y: map.y + map.height / 2 }
await page.evaluate(({ x, y }) => {
document.querySelectorAll('[data-help-target]').forEach(el => el.removeAttribute('data-help-target'))
const el = document.elementFromPoint(x, y)
if (!(el instanceof SVGPathElement)) throw new Error(`no region under the map centre, found ${el?.tagName ?? 'nothing'}`)
el.setAttribute('data-help-target', '1')
}, point)
}
const centreRegion = (page: Page) => page.locator('path[data-help-target]')
async function mapCentre(page: Page): Promise<{ x: number; y: number }> {
const map = await page.locator('.leaflet-container').boundingBox()
if (!map) throw new Error('no map on screen')
return { x: map.x + map.width / 2, y: map.y + map.height / 2 }
}
async function hoverMapCentre(page: Page): Promise<void> {
const c = await mapCentre(page)
await page.mouse.move(c.x, c.y)
}
async function clickMapCentre(page: Page): Promise<void> {
const c = await mapCentre(page)
await page.mouse.click(c.x, c.y)
}
const SCRIPTS: Record<string, GuideScript> = {
'mark-country': {
guide: guide('mark-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => { await searchCountry(p, 'Greece') },
},
{
target: p => countryHit(p, 'Greece'),
act: async p => {
await pickCountry(p, 'Greece')
await expect(popup(p)).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'unmark-country': {
guide: guide('unmark-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Greece')
await pickCountry(p, 'Greece')
await expect(popup(p).getByText('Remove this country from your visited list?')).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: 'Remove', exact: true }),
act: async p => {
await popup(p).getByRole('button', { name: 'Remove', exact: true }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'country-details': {
guide: guide('country-details'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => { await searchCountry(p, 'Japan') },
},
{
target: p => countryHit(p, 'Japan'),
act: async p => {
await pickCountry(p, 'Japan')
await expect(detailCard(p, 'Japan')).toBeVisible()
},
},
],
},
'planned-countries': {
guide: guide('planned-countries'),
start: openAtlas,
steps: [
{
target: p => p.getByRole('button', { name: 'Show planned countries' }).locator('xpath=..'),
act: async p => {
const toggle = p.getByRole('button', { name: 'Show planned countries' })
if ((await toggle.getAttribute('aria-pressed')) !== 'true') await toggle.click()
await settle(p)
},
},
{
prepare: async p => {
await searchCountry(p, 'Portugal')
await pickCountry(p, 'Portugal')
await expect(detailCard(p, 'Portugal')).toBeVisible()
},
target: p => detailCard(p, 'Portugal'),
},
],
},
regions: {
guide: guide('regions'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Austria')
await pickCountry(p, 'Austria')
// An unvisited country opens its popup on arrival; the regions are the point here.
await expect(popup(p)).toBeVisible()
await closePopup(p)
await tagCentreRegion(p)
},
},
{
target: centreRegion,
hover: hoverMapCentre,
act: async p => {
await clickMapCentre(p)
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'search-place': {
guide: guide('search-place'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
const input = searchInput(p)
await input.click()
await typeInto(p, input, 'Munich')
await expect(placeHit(p, 'Munich, Bavaria')).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => placeHit(p, 'Munich, Bavaria'),
act: async p => {
await placeHit(p, 'Munich, Bavaria').click()
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'bucket-country': {
guide: guide('bucket-country'),
start: openAtlas,
steps: [
{
target: searchInput,
act: async p => {
await searchCountry(p, 'Iceland')
await pickCountry(p, 'Iceland')
await expect(popup(p)).toBeVisible()
},
},
{
target: p => popup(p).getByRole('button', { name: /Add to bucket list/ }),
act: async p => {
await popup(p).getByRole('button', { name: /Add to bucket list/ }).click()
await expect(popup(p).getByText('When do you plan to visit?')).toBeVisible()
},
},
{
prepare: async p => {
await choose(p, unsetSelect(popup(p)), 'June')
await choose(p, unsetSelect(popup(p)), '2027')
},
target: p => popup(p),
act: async p => {
await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click()
await expect(popup(p)).toHaveCount(0)
await settle(p)
await p.getByRole('button', { name: 'Bucket List' }).click()
await settle(p)
},
},
],
},
'bucket-place': {
guide: guide('bucket-place'),
start: openAtlas,
steps: [
{
target: p => p.getByRole('button', { name: 'Bucket List' }),
act: async p => { await p.getByRole('button', { name: 'Bucket List' }).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Add place' }),
act: async p => { await p.getByRole('button', { name: 'Add place' }).click() },
},
{
target: p => p.getByPlaceholder('Name (country, city, place...)'),
act: async p => {
const input = p.getByPlaceholder('Name (country, city, place...)')
await typeInto(p, input, 'Petra')
await input.locator('xpath=following-sibling::button[1]').click()
// The geocoder also knows a Petra in Mallorca and a few streets; the one in Jordan is the wish.
const hit = p.locator('body > div.bg-surface-card button').filter({ hasText: 'Jordan' }).first()
await expect(hit).toBeVisible({ timeout: 15_000 })
await hit.click()
await expect(input).toHaveValue(/petra/i)
},
},
{
prepare: async p => {
await choose(p, unsetSelect(panel(p)), 'Sep')
await choose(p, unsetSelect(panel(p)), '2027')
},
target: p => p.getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Add', exact: true }).click()
await expect(panel(p).getByText('Petra').first()).toBeVisible()
await settle(p)
},
},
],
},
stats: {
guide: guide('stats'),
start: openAtlas,
steps: [
{ target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() },
{ target: p => panel(p).locator('.flex.items-center.gap-4').first() },
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureAtlasFixtures(request)
})
test('every registered atlas guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(atlasContext.guides.slice().sort())
})
test('hero: atlas', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, atlasContext.id, openAtlas)
})
for (const id of atlasContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+521
View File
@@ -0,0 +1,521 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import path from 'node:path'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, type GuideScript } from './guide'
import { ensureExtraTrips } from './fixtures'
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Collections guides, keyed by the ids in
* `src/help/contexts/collections.ts`. The seed leaves one list, "Kyoto
* shortlist", with four of the Japan trip's places on it; the guides add a
* second list, more places, a label, a member and a file round trip, in the
* order the context lists them, and leave it all in place.
*/
const KYOTO = 'Kyoto shortlist'
const LISBON = 'Lisbon coffee'
const guide = (id: string): HelpGuide => {
const g = collectionsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const rail = (page: Page) => page.locator('.col-rail')
const listRow = (page: Page, name: string) => rail(page).locator('button.col-row-btn').filter({ has: page.locator('.nm').getByText(name, { exact: true }) })
const hero = (page: Page) => page.locator('.col-hero')
const filterBar = (page: Page) => page.locator('.col-filterbar')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** The card inside the backdrop, for framing a whole dialog. */
const modalCard = (page: Page) => modal(page).locator('> [role="presentation"]').first()
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
const placeRows = (page: Page) => page.locator('.col-lrow')
const selectionBar = (page: Page) => page.locator('.col-selbar')
/** The detail sheet beside the list. */
const detail = (page: Page) => page.locator('.col-detail').first()
async function openCollections(page: Page): Promise<void> {
await page.goto('/collections')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(rail(page)).toBeVisible({ timeout: 20_000 })
await expect(listRow(page, KYOTO)).toBeVisible({ timeout: 20_000 })
await settle(page)
}
async function openList(page: Page, name: string): Promise<void> {
await openCollections(page)
await listRow(page, name).click()
await expect(hero(page)).toContainText(name)
await settle(page)
}
/** Pick an option in a CustomSelect that has a search box. */
async function pickSearchable(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click()
}
const SCRIPTS: Record<string, GuideScript> = {
'create-list': {
guide: guide('create-list'),
start: openCollections,
steps: [
{
target: p => rail(p).getByRole('button', { name: 'New list' }),
act: async p => {
await rail(p).getByRole('button', { name: 'New list' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: modalCard,
act: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Tokyo 2025'), LISBON)
await modal(p).getByRole('button', { name: '#f97316' }).click()
await typeInto(p, modal(p).getByPlaceholder('Add a description…'), 'Pastéis, miradouros and the best flat white in Alfama.')
await beat(p, 400)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(hero(p)).toContainText(LISBON)
await settle(p)
},
},
],
},
'add-place': {
guide: guide('add-place'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Add a place' }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Add a place' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByPlaceholder('Search for a place…'),
act: async p => {
const search = modal(p).getByPlaceholder('Search for a place…')
await typeInto(p, search, 'Fushimi Inari')
await modal(p).getByRole('button', { name: 'Search', exact: true }).click()
const hit = modal(p).locator('button').filter({ hasText: /Fushimi Inari/ }).first()
await expect(hit).toBeVisible({ timeout: 20_000 })
await hit.click()
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(/Fushimi/)
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(placeRow(p, 'Fushimi Inari Shrine')).toBeVisible({ timeout: 15_000 })
// The dialog stays open for the next place; the guide is done with it.
await beat(p, 600)
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'import-from-trip': {
guide: guide('import-from-trip'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Import from a trip' }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Import from a trip' }).click()
await expect(modal(p)).toBeVisible()
await expect(modal(p).locator('button.col-imp-trip').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }),
act: async p => {
await modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }).click()
await expect(modal(p).locator('button.col-imp-row').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => modal(p).locator('.col-imp-list'),
act: async p => {
// Make sure something is ticked: the first two rows that are not already on the list.
const rows = modal(p).locator('button.col-imp-row:not(.dup)')
for (let i = 0; i < Math.min(2, await rows.count()); i++) {
if ((await rows.nth(i).getAttribute('aria-pressed')) !== 'true') await rows.nth(i).click()
}
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: /^Import \d+$/ }),
act: async p => {
await modal(p).getByRole('button', { name: /^Import \d+$/ }).click()
await expect(modal(p).getByText('Import finished')).toBeVisible({ timeout: 15_000 })
await modal(p).getByRole('button', { name: 'Close' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'place-status': {
guide: guide('place-status'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => placeRows(p).first().getByRole('button', { name: 'Idea' }),
act: async p => {
await placeRows(p).first().getByRole('button', { name: 'Idea' }).click()
await expect(placeRows(p).first().getByRole('button', { name: 'Want to go' })).toBeVisible()
await settle(p)
},
},
{
target: p => placeRows(p).first().getByRole('button', { name: 'Want to go' }),
act: async p => {
await placeRows(p).first().getByRole('button', { name: 'Want to go' }).click()
await expect(placeRows(p).first().getByRole('button', { name: 'Visited' })).toBeVisible()
await settle(p)
},
},
],
},
'place-detail': {
guide: guide('place-detail'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => placeRows(p).nth(1),
act: async p => {
await placeRows(p).nth(1).click()
await expect(detail(p)).toBeVisible()
await settle(p)
},
},
{
target: p => detail(p).locator('.col-detail-footer'),
},
],
},
labels: {
guide: guide('labels'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first(),
act: async p => {
await p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first().click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByPlaceholder('e.g. Berlin').locator('xpath=../..'),
act: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Berlin'), 'Arashiyama')
await modal(p).getByRole('button', { name: 'Add label' }).click()
await expect(modal(p).locator('input[aria-label="Label name"][value="Arashiyama"]')).toBeVisible()
await beat(p, 400)
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toBeVisible()
await placeRows(p).nth(0).click()
await placeRows(p).nth(1).click()
},
target: p => selectionBar(p).getByRole('button', { name: 'Assign label' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Assign label' }).click()
await expect(modal(p)).toBeVisible()
await modal(p).getByRole('button', { name: 'Arashiyama' }).click()
await modal(p).getByRole('button', { name: 'Assign label' }).click()
await expect(modal(p)).toHaveCount(0)
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => p.locator('.col-labelfilter').first(),
act: async p => {
await p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ }).click()
await settle(p)
},
},
],
cleanup: async p => {
// Leave the list unfiltered for the guides that follow.
const chip = p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ })
if ((await chip.count()) && (await chip.getAttribute('aria-pressed')) === 'true') await chip.click()
},
},
'filter-select': {
guide: guide('filter-select'),
start: p => openList(p, KYOTO),
steps: [
{
prepare: async p => {
await filterBar(p).locator('button[aria-haspopup="listbox"]').first().click()
await expect(filterBar(p).locator('[role="listbox"]')).toBeVisible()
},
target: p => filterBar(p).locator('button[aria-haspopup="listbox"]').first(),
act: async p => {
await filterBar(p).locator('[role="option"]').first().click()
await expect(filterBar(p).locator('[role="listbox"]')).toHaveCount(0)
},
},
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await selectionBar(p).getByRole('button', { name: 'Select all' }).click() },
target: selectionBar,
},
],
cleanup: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toHaveCount(0)
},
},
'copy-to-trip': {
guide: guide('copy-to-trip'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(p)).toBeVisible()
await placeRows(p).nth(0).click()
await placeRows(p).nth(2).click()
await beat(p, 300)
},
},
{
target: p => selectionBar(p).getByRole('button', { name: 'Copy to trip' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Copy to trip' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }),
act: async p => {
await modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
},
},
'share-list': {
guide: guide('share-list'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Share' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Share' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../..'),
act: async p => {
await pickSearchable(p, modal(p).getByRole('button', { name: 'Select a user' }), 'mira')
await beat(p, 300)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Send invite' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Send invite' }).click()
await expect(modal(p).getByText('pending invite')).toBeVisible({ timeout: 10_000 })
await beat(p, 500)
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'export-list': {
guide: guide('export-list'),
start: p => openList(p, KYOTO),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Export' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Export' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
{
target: p => p.getByRole('menu'),
act: async p => {
const download = p.waitForEvent('download')
await p.getByRole('menuitem', { name: /TREK list/ }).click()
const file = await download
exportedFile = path.join(OUT_DIR, 'kyoto.trekcollection.json')
await file.saveAs(exportedFile)
await settle(p)
},
},
],
},
'import-file': {
guide: guide('import-file'),
start: openCollections,
steps: [
{
target: p => rail(p).getByRole('button', { name: 'Import a list from a file' }),
act: async p => {
await rail(p).getByRole('button', { name: 'Import a list from a file' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Choose a list file' }),
act: async p => {
if (!exportedFile) throw new Error('the export guide did not leave a file to import')
await modal(p).locator('input[type="file"]').setInputFiles(exportedFile)
await expect(modal(p).getByRole('button', { name: /^Import$/ })).toBeEnabled({ timeout: 10_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'New list' }).locator('xpath=..'),
act: async p => {
const name = modal(p).locator('input:not([type="file"])').last()
await name.fill('')
await typeInto(p, name, 'Kyoto shortlist (from file)')
await modal(p).getByRole('button', { name: /^Import$/ }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await expect(hero(p)).toContainText('Kyoto shortlist (from file)')
await settle(p)
},
},
],
},
'edit-list': {
guide: guide('edit-list'),
start: p => openList(p, LISBON),
steps: [
{
target: p => hero(p).getByRole('button', { name: 'Edit' }),
act: async p => {
await hero(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: modalCard,
act: async p => {
const desc = modal(p).getByPlaceholder('Add a description…')
await desc.fill('')
await typeInto(p, desc, 'Coffee first, then the miradouros. Weekend of 16 October.')
await modal(p).getByRole('button', { name: 'Save' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'all-saved': {
guide: guide('all-saved'),
start: openCollections,
steps: [
{
target: p => rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }),
act: async p => {
await rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }).click()
await expect(hero(p)).toContainText('All saved')
await settle(p)
},
},
{
target: p => p.getByPlaceholder('Search places').first(),
act: async p => {
await typeInto(p, p.getByPlaceholder('Search places').first(), 'temple')
await settle(p)
},
},
],
},
}
/** The list file the export guide downloads and the import guide reads back. */
let exportedFile: string | null = null
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
})
test('every registered collections guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(collectionsContext.guides.slice().sort())
})
test('hero: collections', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, collectionsContext.id, p => openList(p, KYOTO))
})
for (const id of collectionsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+381
View File
@@ -0,0 +1,381 @@
import { test, expect, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureExtraTrips, coverFixture, card, EXTRA_TRIPS } from './fixtures'
import { dashboardGuides, dashboardContext } from '../../src/help/contexts/dashboard'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the dashboard guides. Each script is keyed by the guide id from
* `src/help/contexts/dashboard.ts` and has exactly one action per registered
* step; the runner refuses a mismatch, so a step added to the registry without
* an action here fails this run instead of shipping a raw key.
*/
const LISBON = EXTRA_TRIPS[0].title
const NORWAY = EXTRA_TRIPS[1].title
const guide = (id: string): HelpGuide => {
const g = dashboardGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
async function openDashboard(page: Page): Promise<void> {
await page.goto('/dashboard')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.locator('.hero-trip')).toBeVisible()
await expect(card(page, LISBON)).toBeVisible()
}
/** The trip form, scoped so the cover-search inputs above the title cannot be matched by accident. */
const modal = (page: Page) => page.locator('.trek-modal-backdrop')
/** The topmost dialog of any kind (confirm, copy, calendar): they all share the backdrop class. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
const titleField = (page: Page) => modal(page).getByPlaceholder('e.g. Summer in Japan')
/**
* A date picker's trigger by the label above it. The trigger names itself after
* its value once one is set (and the end date is filled in the moment the start
* is picked), so "End Date" as a button name only works while it is empty.
*/
const datePicker = (page: Page, label: string) =>
modal(page).getByText(label, { exact: true }).locator('xpath=..').getByRole('button').first()
/** Pick a date in the calendar popup: open it, go to the month, click the day. */
async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, monthsAhead: number, dayLabel: string): Promise<void> {
await trigger.click()
for (let i = 0; i < monthsAhead; i++) {
await page.getByRole('button', { name: 'Next month' }).click()
await beat(page, 350)
}
await page.getByRole('button', { name: dayLabel, exact: true }).click()
}
/** Hover-revealed action on a card. */
const cardAction = (page: Page, title: string, name: string) => card(page, title).locator(`.trip-action-btn[aria-label="${name}"]`)
const SCRIPTS: Record<string, GuideScript> = {
'create-trip': {
guide: guide('create-trip'),
start: openDashboard,
steps: [
{
target: p => p.locator('.add-trip-card'),
act: async p => { await p.locator('.add-trip-card').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => titleField(p),
act: async p => { await typeInto(p, titleField(p), 'Amalfi Coast in May') },
},
{
target: p => datePicker(p, 'Start Date'),
act: async p => {
await pickDate(p, datePicker(p, 'Start Date'), 8, 'May 8, 2027')
await beat(p, 500)
// The end date follows the start date, so its calendar already opens on May.
await pickDate(p, datePicker(p, 'End Date'), 0, 'May 15, 2027')
},
},
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
},
{
target: p => modal(p).getByRole('button', { name: 'Create New Trip' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create New Trip' }).click()
await expect(card(p, 'Amalfi Coast in May')).toBeVisible({ timeout: 15_000 })
},
},
],
cleanup: async p => {
// The delete guide removes this trip on camera; until then it stays.
void p
},
},
'edit-trip': {
guide: guide('edit-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, NORWAY, 'Edit'),
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => titleField(p),
act: async p => {
await titleField(p).fill('')
await typeInto(p, titleField(p), 'Norway Road Trip')
},
},
{
target: p => modal(p).getByRole('button', { name: 'Update' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update' }).click()
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
},
},
],
},
'cover-image': {
guide: guide('cover-image'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, LISBON, 'Edit'),
act: async p => { await cardAction(p, LISBON, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
act: async p => {
await modal(p).locator('input[type="file"]').setInputFiles(await coverFixture())
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
},
},
{
prepare: async p => { await settle(p) },
target: p => modal(p).getByRole('button', { name: 'Update' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update' }).click()
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
await expect(card(p, LISBON).locator('.trip-cover img')).toBeVisible({ timeout: 15_000 })
},
},
],
},
'duplicate-trip': {
guide: guide('duplicate-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, LISBON, 'Duplicate'),
act: async p => { await cardAction(p, LISBON, 'Duplicate').click() },
},
{
target: p => dialog(p).getByRole('button', { name: 'Copy trip' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Copy trip' }).click()
await expect(p.locator('.trip-card').filter({ hasText: LISBON })).toHaveCount(2, { timeout: 15_000 })
},
},
],
cleanup: async p => {
// Drop the copy over the API so the grid is back to the seed for the next guide.
const res = await p.request.get('/api/trips')
const { trips } = (await res.json()) as { trips: { id: number; title: string }[] }
const copies = trips.filter(t => t.title.startsWith(LISBON)).slice(1)
for (const t of copies) await p.request.delete(`/api/trips/${t.id}`)
},
},
'archive-trip': {
guide: guide('archive-trip'),
start: openDashboard,
steps: [
{
target: p => cardAction(p, NORWAY, 'Archive'),
act: async p => {
await cardAction(p, NORWAY, 'Archive').click()
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
},
},
{
target: p => p.locator('.seg').getByRole('button', { name: 'Archived' }),
act: async p => {
await p.locator('.seg').getByRole('button', { name: 'Archived' }).click()
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => cardAction(p, NORWAY, 'Restore'),
act: async p => {
await cardAction(p, NORWAY, 'Restore').click()
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
await beat(p, 400)
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
},
},
],
},
'delete-trip': {
guide: guide('delete-trip'),
start: async p => {
await openDashboard(p)
await expect(card(p, 'Amalfi Coast in May')).toBeVisible()
},
steps: [
{
target: p => cardAction(p, 'Amalfi Coast in May', 'Delete'),
act: async p => { await cardAction(p, 'Amalfi Coast in May', 'Delete').click() },
},
{
target: p => dialog(p).getByRole('button', { name: 'Delete', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(card(p, 'Amalfi Coast in May')).toBeHidden({ timeout: 15_000 })
},
},
],
},
'filter-and-view': {
guide: guide('filter-and-view'),
start: openDashboard,
steps: [
{
target: p => p.locator('.seg'),
act: async p => {
await p.locator('.seg').getByRole('button', { name: 'Completed' }).click()
await beat(p, 900)
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
await expect(card(p, LISBON)).toBeVisible()
},
},
{
target: p => p.getByRole('button', { name: 'Toggle view' }),
act: async p => {
await p.getByRole('button', { name: 'Toggle view' }).click()
await expect(p.locator('.trips.list-view')).toBeVisible()
},
},
],
cleanup: async p => { await p.getByRole('button', { name: 'Toggle view' }).click() },
},
'calendar-feed': {
guide: guide('calendar-feed'),
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Subscribe to all trips calendar' }),
act: async p => { await p.getByRole('button', { name: 'Subscribe to all trips calendar' }).click() },
},
{
target: p => p.getByRole('button', { name: 'Enable calendar subscription' }),
act: async p => {
await p.getByRole('button', { name: 'Enable calendar subscription' }).click()
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
},
},
{
// This dialog has no shared backdrop class: its card is the box that holds the buttons.
target: p => p.getByText(/Add to Google Calendar/i).locator('xpath=ancestor::div[contains(@style,"border-radius")][1]'),
},
],
cleanup: async p => { await p.keyboard.press('Escape') },
},
widgets: {
guide: guide('widgets'),
start: openDashboard,
steps: [
{
prepare: async p => {
await p.locator('nav button').filter({ has: p.locator('svg.lucide-chevron-down') }).first().click()
await beat(p, 500)
},
target: p => p.getByRole('link', { name: 'Settings' }),
act: async p => {
await p.getByRole('link', { name: 'Settings' }).click()
await expect(p).toHaveURL(/\/settings/)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Appearance', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Appearance', exact: true }).first().click()
await expect(p.getByText('Dashboard widgets', { exact: true }).first()).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await p.getByText('Dashboard widgets', { exact: true }).first().scrollIntoViewIfNeeded()
await beat(p, 400)
},
// The Section card itself: the nearest rounded container around its heading.
target: p => p.getByText('Dashboard widgets', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"rounded-xl")][1]'),
},
{
target: p => p.getByRole('link', { name: 'My Trips' }),
act: async p => {
await p.getByRole('link', { name: 'My Trips' }).click()
await expect(p.locator('.hero-trip')).toBeVisible()
await settle(p)
},
},
],
},
'currency-widget': {
guide: guide('currency-widget'),
start: openDashboard,
steps: [
{
target: p => p.locator('.tool').filter({ hasText: 'Currency' }).locator('.fx-input'),
act: async p => {
const amount = p.locator('.tool').filter({ hasText: 'Currency' }).locator('input.amt').first()
await amount.fill('')
await typeInto(p, amount, '250')
},
},
{
target: p => p.getByRole('button', { name: 'Swap currencies' }),
act: async p => { await p.getByRole('button', { name: 'Swap currencies' }).click() },
},
],
},
'timezones-widget': {
guide: guide('timezones-widget'),
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Add timezone' }),
act: async p => {
await p.getByRole('button', { name: 'Add timezone' }).click()
await beat(p, 600)
},
},
{
prepare: async p => {
// Close the picker again so the row's remove button is what the picture shows.
await p.getByRole('button', { name: 'Add timezone' }).click()
await p.locator('.tz-row').first().hover()
},
target: p => p.locator('.tz-row').first().locator('.tz-del'),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
})
test('every registered dashboard guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(dashboardContext.guides.slice().sort())
})
test('hero: the dashboard', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, dashboardContext.id, openDashboard)
})
for (const id of dashboardContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+447
View File
@@ -0,0 +1,447 @@
import sharp from 'sharp'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import type { APIRequestContext, Page } from '@playwright/test'
import { OUT_DIR } from './guide'
/**
* What the dashboard guides act on beyond the seeded trip.
*
* The seed's "Autumn in Japan" is the running trip, so it sits in the boarding
* pass rather than in the grid. The card actions (edit, duplicate, archive,
* delete) need cards, so two more trips are created over the API once, before
* the first guide runs. Uploads use a drawn dusk-over-mountains cover rather
* than a photograph: nothing in the repo may carry someone's picture.
*/
export const EXTRA_TRIPS = [
{ title: 'Weekend in Lisbon', description: 'Pastéis, miradouros and a day trip to Sintra.', start_date: '2026-10-16', end_date: '2026-10-18', currency: 'EUR' },
{ title: 'Norway Road Trip', description: 'Bergen to the Lofoten, fjord by fjord.', start_date: '2027-06-05', end_date: '2027-06-19', currency: 'NOK' },
]
export async function ensureExtraTrips(api: APIRequestContext): Promise<void> {
const res = await api.get('/api/trips')
const body = (await res.json()) as { trips?: { title: string }[] } | { title: string }[]
const trips = Array.isArray(body) ? body : (body.trips ?? [])
for (const trip of EXTRA_TRIPS) {
if (trips.some(t => t.title === trip.title)) continue
const created = await api.post('/api/trips', { data: trip })
if (!created.ok()) throw new Error(`could not create "${trip.title}": ${created.status()} ${await created.text()}`)
}
}
/** Path of a 1600×900 JPEG the cover guide uploads; drawn on first use. */
export async function coverFixture(): Promise<string> {
mkdirSync(OUT_DIR, { recursive: true })
const file = path.join(OUT_DIR, 'cover-fixture.jpg')
if (existsSync(file)) return file
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b1f3b"/>
<stop offset="0.45" stop-color="#6b3d7a"/>
<stop offset="0.75" stop-color="#e8865a"/>
<stop offset="1" stop-color="#f7c36b"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fff2c2"/>
<stop offset="0.6" stop-color="#ffb35c"/>
<stop offset="1" stop-color="#ffb35c" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1600" height="900" fill="url(#sky)"/>
<circle cx="1090" cy="560" r="150" fill="url(#sun)"/>
<path d="M0 640 L180 520 L330 600 L480 470 L640 590 L800 500 L960 610 L1120 540 L1280 620 L1440 560 L1600 640 L1600 900 L0 900 Z" fill="#2a2140" opacity="0.85"/>
<path d="M0 720 L200 640 L360 700 L560 600 L720 690 L900 640 L1080 720 L1260 660 L1440 730 L1600 700 L1600 900 L0 900 Z" fill="#1a1530"/>
<path d="M0 820 Q400 760 800 800 T1600 790 L1600 900 L0 900 Z" fill="#100c1f"/>
</svg>`
await sharp(Buffer.from(svg)).jpeg({ quality: 88 }).toFile(file)
return file
}
/** Trip card in the grid by its title. */
export function card(page: Page, title: string) {
return page.locator('.trip-card').filter({ hasText: title }).first()
}
// ── Atlas ─────────────────────────────────────────────────────────────────────
/**
* What the Atlas guides act on. The seed's Japan trip colours one country;
* a map with a single country on it says nothing about what the screen does,
* so a handful of countries are marked by hand (the way someone catching up
* on their pre-TREK travels would), one wish goes on the bucket list, and the
* Lisbon trip gets a place so Portugal shows up as planned and the "Show
* planned countries" switch exists at all.
*/
export const ATLAS_MARKED = ['IT', 'ES', 'FR', 'TH', 'MA']
export const ATLAS_WISH = { name: 'New Zealand', country_code: 'NZ' }
export const PLANNED_PLACE = {
trip: 'Weekend in Lisbon',
place: { name: 'Belém Tower', lat: 38.6916, lng: -9.216, address: 'Av. Brasília, 1400-038 Lisboa, Portugal' },
}
export async function ensureAtlasFixtures(api: APIRequestContext): Promise<void> {
await ensureExtraTrips(api)
const res = await api.get('/api/trips')
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
const trips = Array.isArray(body) ? body : (body.trips ?? [])
const lisbon = trips.find(t => t.title === PLANNED_PLACE.trip)
if (!lisbon) throw new Error(`fixture trip "${PLANNED_PLACE.trip}" is missing`)
const placesRes = await api.get(`/api/trips/${lisbon.id}/places`)
const placesBody = (await placesRes.json()) as { places?: { name: string }[] } | { name: string }[]
const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? [])
if (!places.some(p => p.name === PLANNED_PLACE.place.name)) {
const created = await api.post(`/api/trips/${lisbon.id}/places`, { data: PLANNED_PLACE.place })
if (!created.ok()) throw new Error(`could not create "${PLANNED_PLACE.place.name}": ${created.status()} ${await created.text()}`)
}
// Idempotent on the server (INSERT OR IGNORE), so no lookup first.
for (const code of ATLAS_MARKED) {
const marked = await api.post(`/api/addons/atlas/country/${code}/mark`)
if (!marked.ok()) throw new Error(`could not mark ${code}: ${marked.status()}`)
}
const listRes = await api.get('/api/addons/atlas/bucket-list')
const { items } = (await listRes.json()) as { items: { country_code: string | null; target_date: string | null }[] }
if (!items.some(i => i.country_code === ATLAS_WISH.country_code && !i.target_date)) {
const wish = await api.post('/api/addons/atlas/bucket-list', { data: ATLAS_WISH })
if (!wish.ok()) throw new Error(`could not add the ${ATLAS_WISH.name} wish: ${wish.status()} ${await wish.text()}`)
}
}
// ── Journey ───────────────────────────────────────────────────────────────────
/**
* What the Journey guides act on. The seed creates "Autumn in Japan" from the
* trip, which leaves a timeline of suggestions and nothing written. Three of
* them get a story, a mood and the weather, and the first one a photo, so the
* journal looks lived-in; the rest stay suggestions for the guide about them.
*/
export const JOURNEY_TITLE = 'Autumn in Japan'
const ENTRIES = [
{
title: 'First morning in Asakusa',
story: 'Up before the shops. The Kaminarimon lantern with nobody under it, incense smoke drifting across the courtyard, and a bowl of soba from a counter that opened just as we arrived.',
mood: 'amazing', weather: 'sunny', tags: ['hidden gem', 'early start'],
},
{
title: 'Barefoot through the light',
story: 'teamLab Planets is best without expectations. You wade through warm water while koi drawn from light swim around your ankles, then lie under a ceiling of orchids that moves out of your way.',
mood: 'good', weather: 'partly', tags: ['best moment'],
},
{
title: 'The crossing, from above',
story: 'Shibuya from the Sky deck at dusk: the scramble fills and empties every ninety seconds like a tide. Rough evening otherwise, the rain came in sideways and the umbrella did not make it home.',
mood: 'rough', weather: 'rainy', tags: ['viewpoint'],
pros_cons: { pros: ['The view at blue hour', 'Timed entry, no queue'], cons: ['Rain, then more rain', 'Crowded lift'] },
},
]
/** A second, future journey, so the list has a card below the banner. */
export const SECOND_JOURNEY = { title: 'Fjords and ferries', subtitle: 'Norway, next summer', trip: 'Norway Road Trip' }
/** A trip no journey has claimed, for the guide that links one. */
export const SPARE_TRIP = { title: 'Alps by rail', description: 'Zurich to Venice over the Bernina.', start_date: '2027-02-12', end_date: '2027-02-19', currency: 'CHF' }
export async function ensureJourneyFixtures(api: APIRequestContext): Promise<number> {
const listRes = await api.get('/api/journeys')
const { journeys } = (await listRes.json()) as { journeys: { id: number; title: string }[] }
const journey = journeys.find(j => j.title === JOURNEY_TITLE)
if (!journey) throw new Error(`fixture journey "${JOURNEY_TITLE}" is missing`)
if (!journeys.some(j => j.title === SECOND_JOURNEY.title)) {
const tripsRes = await api.get('/api/trips')
const tripsBody = (await tripsRes.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
const trips = Array.isArray(tripsBody) ? tripsBody : (tripsBody.trips ?? [])
const norway = trips.find(t => t.title === SECOND_JOURNEY.trip)
if (!norway) throw new Error(`fixture trip "${SECOND_JOURNEY.trip}" is missing`)
const created = await api.post('/api/journeys', { data: { title: SECOND_JOURNEY.title, subtitle: SECOND_JOURNEY.subtitle, trip_ids: [norway.id] } })
if (!created.ok()) throw new Error(`could not create "${SECOND_JOURNEY.title}": ${created.status()} ${await created.text()}`)
}
const spareRes = await api.get('/api/trips')
const spareBody = (await spareRes.json()) as { trips?: { title: string }[] } | { title: string }[]
const allTrips = Array.isArray(spareBody) ? spareBody : (spareBody.trips ?? [])
if (!allTrips.some(t => t.title === SPARE_TRIP.title)) {
const created = await api.post('/api/trips', { data: SPARE_TRIP })
if (!created.ok()) throw new Error(`could not create "${SPARE_TRIP.title}": ${created.status()} ${await created.text()}`)
}
const entriesRes = await api.get(`/api/journeys/${journey.id}/entries`)
const { entries } = (await entriesRes.json()) as { entries: { id: number; type: string; title: string | null }[] }
if (entries.some(e => e.title === ENTRIES[0].title)) return journey.id
const skeletons = entries.filter(e => e.type === 'skeleton')
for (let i = 0; i < ENTRIES.length && i < skeletons.length; i++) {
const res = await api.patch(`/api/journeys/entries/${skeletons[i].id}`, { data: { type: 'entry', ...ENTRIES[i] } })
if (!res.ok()) throw new Error(`could not write entry "${ENTRIES[i].title}": ${res.status()} ${await res.text()}`)
}
const photo = await coverFixture()
const upload = await api.post(`/api/journeys/entries/${skeletons[0].id}/photos`, {
multipart: { photos: { name: 'asakusa.jpg', mimeType: 'image/jpeg', buffer: readFileSync(photo) } },
})
if (!upload.ok()) throw new Error(`could not upload the entry photo: ${upload.status()} ${await upload.text()}`)
return journey.id
}
// ── Admin ─────────────────────────────────────────────────────────────────────
/**
* What the admin guides act on. Two more accounts so the user table has rows
* to edit and delete, one invite link, a packing template with categories and
* items, a school-holiday country with one region, the MCP addon (the tab only
* exists while it is on) with a token, and the ntfy and webhook channels.
*/
export const ADMIN_USERS = [
{ username: 'mara', email: 'mara.lind@example.com', password: 'Mara12345!', role: 'user' },
{ username: 'jonas', email: 'jonas.b@example.com', password: 'Jonas12345!', role: 'user' },
]
const PACKING_TEMPLATE = {
name: 'Beach Holiday',
categories: [
{ name: 'Clothing', items: ['Swimsuit', 'Sandals', 'Sun hat', 'Light jacket'] },
{ name: 'Toiletries', items: ['Sunscreen', 'After sun', 'Toothbrush'] },
],
}
const SCHOOL_COUNTRY = { code: 'DE', name: 'Germany' }
const SCHOOL_REGION = {
name: 'Bavaria',
revision: 0,
holidays: [
{ name: 'Autumn break', startDate: '2026-11-02', endDate: '2026-11-06' },
{ name: 'Christmas break', startDate: '2026-12-24', endDate: '2027-01-06' },
{ name: 'Easter break', startDate: '2027-03-29', endDate: '2027-04-09' },
],
}
async function listOf<T>(api: APIRequestContext, url: string, key: string): Promise<T[]> {
const res = await api.get(url)
if (!res.ok()) throw new Error(`${url}: ${res.status()} ${await res.text()}`)
const body = (await res.json()) as Record<string, T[]> | T[]
return Array.isArray(body) ? body : (body[key] ?? [])
}
/** Accounts by username; existing ones are left alone. */
export async function ensureUsers(api: APIRequestContext, wanted: { username: string; email: string; password: string; role: string }[]): Promise<void> {
const users = await listOf<{ username: string }>(api, '/api/admin/users', 'users')
for (const user of wanted) {
if (users.some(u => u.username === user.username)) continue
const created = await api.post('/api/admin/users', { data: user })
if (!created.ok()) throw new Error(`could not create user "${user.username}": ${created.status()} ${await created.text()}`)
}
}
/** The demo trip the screenshot seed creates; its ids are on disk because Playwright projects share no memory. */
export function seededTrip(): { tripId: number; dayIds: number[]; placeIds: number[]; memberIds: number[] } {
return JSON.parse(readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'))
}
export async function ensureAdminFixtures(api: APIRequestContext): Promise<void> {
await ensureUsers(api, ADMIN_USERS)
const invites = await listOf<{ id: number }>(api, '/api/admin/invites', 'invites')
if (invites.length === 0) {
const created = await api.post('/api/admin/invites', { data: { max_uses: 3, expires_in_days: 14 } })
if (!created.ok()) throw new Error(`could not create the invite link: ${created.status()} ${await created.text()}`)
}
const templates = await listOf<{ id: number; name: string }>(api, '/api/admin/packing-templates', 'templates')
if (!templates.some(t => t.name === PACKING_TEMPLATE.name)) {
const created = await api.post('/api/admin/packing-templates', { data: { name: PACKING_TEMPLATE.name } })
if (!created.ok()) throw new Error(`could not create the packing template: ${created.status()} ${await created.text()}`)
const template = (await created.json()) as { id?: number; template?: { id: number } }
const templateId = template.id ?? template.template?.id
if (!templateId) throw new Error('packing template came back without an id')
for (const cat of PACKING_TEMPLATE.categories) {
const catRes = await api.post(`/api/admin/packing-templates/${templateId}/categories`, { data: { name: cat.name } })
if (!catRes.ok()) throw new Error(`could not add category "${cat.name}": ${catRes.status()} ${await catRes.text()}`)
const category = (await catRes.json()) as { id?: number; category?: { id: number } }
const catId = category.id ?? category.category?.id
for (const item of cat.items) {
const itemRes = await api.post(`/api/admin/packing-templates/${templateId}/categories/${catId}/items`, { data: { name: item } })
if (!itemRes.ok()) throw new Error(`could not add item "${item}": ${itemRes.status()} ${await itemRes.text()}`)
}
}
}
const catalogRes = await api.get('/api/school-holiday-catalog')
if (!catalogRes.ok()) throw new Error(`school holiday catalog: ${catalogRes.status()} ${await catalogRes.text()}`)
const catalog = (await catalogRes.json()) as { countries: { code: string; regions?: { name: string }[] }[]; regions?: { country: string; name: string }[] }
if (!catalog.countries.some(c => c.code === SCHOOL_COUNTRY.code)) {
const created = await api.post('/api/school-holiday-catalog/countries', { data: SCHOOL_COUNTRY })
if (!created.ok()) throw new Error(`could not create the holiday country: ${created.status()} ${await created.text()}`)
}
const regions = catalog.regions ?? catalog.countries.find(c => c.code === SCHOOL_COUNTRY.code)?.regions ?? []
if (!regions.some(r => r.name === SCHOOL_REGION.name)) {
const created = await api.post(`/api/school-holiday-catalog/countries/${SCHOOL_COUNTRY.code}/regions`, { data: SCHOOL_REGION })
if (!created.ok()) throw new Error(`could not create the holiday region: ${created.status()} ${await created.text()}`)
}
const mcp = await api.put('/api/admin/addons/mcp', { data: { enabled: true } })
if (!mcp.ok()) throw new Error(`could not enable the MCP addon: ${mcp.status()} ${await mcp.text()}`)
const tokens = await listOf<{ id: number }>(api, '/api/auth/mcp-tokens', 'tokens')
if (tokens.length === 0) {
const created = await api.post('/api/auth/mcp-tokens', { data: { name: 'Claude Desktop' } })
if (!created.ok()) throw new Error(`could not create the MCP token: ${created.status()} ${await created.text()}`)
}
const channels = await api.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook' } })
if (!channels.ok()) throw new Error(`could not enable the notification channels: ${channels.status()} ${await channels.text()}`)
}
/** A minimal ustar tar.gz, enough for the plugin upload route. */
function tarGz(files: { name: string; data: Buffer }[]): Buffer {
const blocks: Buffer[] = []
for (const f of files) {
const h = Buffer.alloc(512)
h.write(f.name, 0, 100, 'utf8')
h.write('0000644\0', 100, 8, 'utf8')
h.write('0000000\0', 108, 8, 'utf8')
h.write('0000000\0', 116, 8, 'utf8')
h.write(f.data.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'utf8')
h.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0', 136, 12, 'utf8')
h.write(' ', 148, 8, 'utf8')
h.write('0', 156, 1, 'utf8')
h.write('ustar\0', 257, 6, 'utf8')
h.write('00', 263, 2, 'utf8')
let sum = 0
for (const b of h) sum += b
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'utf8')
blocks.push(h, f.data, Buffer.alloc((512 - (f.data.length % 512)) % 512))
}
blocks.push(Buffer.alloc(1024))
return gzipSync(Buffer.concat(blocks))
}
/**
* The SDK's Trip Doctor example packed for upload. Its manifest pins TREK 3.x,
* so the range is widened to the running major; nothing else changes.
*/
export function pluginPackage(): Buffer {
const dir = path.join(process.cwd(), '..', 'plugin-sdk', 'examples', 'trip-doctor')
const manifest = JSON.parse(readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as Record<string, unknown>
manifest.trek = '>=4.0.0'
return tarGz([
{ name: 'trip-doctor/trek-plugin.json', data: Buffer.from(JSON.stringify(manifest, null, 2)) },
{ name: 'trip-doctor/server/index.js', data: readFileSync(path.join(dir, 'server', 'index.js')) },
])
}
// ── Trip ──────────────────────────────────────────────────────────────────────
/** Two short Kyoto walks as GPX, each a track with two waypoints; drawn once, on first use. */
const WALKS: Record<string, { name: string; waypoints: [string, number, number][]; track: [number, number][] }> = {
'kyoto-walk': {
name: "Philosopher's Path",
waypoints: [['Ginkaku-ji', 35.0270, 135.7982], ['Nanzen-ji', 35.0113, 135.7943]],
track: [[35.0270, 135.7982], [35.0248, 135.7969], [35.0221, 135.7957], [35.0194, 135.7951], [35.0166, 135.7948], [35.0140, 135.7945], [35.0113, 135.7943]],
},
'arashiyama-loop': {
name: 'Arashiyama loop',
waypoints: [['Togetsukyo Bridge', 35.0128, 135.6777], ['Okochi Sanso Villa', 35.0177, 135.6700]],
track: [[35.0128, 135.6777], [35.0141, 135.6752], [35.0158, 135.6728], [35.0170, 135.6716], [35.0177, 135.6700], [35.0165, 135.6740], [35.0140, 135.6765]],
},
}
export function gpxFixture(id: keyof typeof WALKS): string {
const dir = path.join(process.cwd(), 'e2e', '.tmp')
mkdirSync(dir, { recursive: true })
const file = path.join(dir, `${id}.gpx`)
if (existsSync(file)) return file
const walk = WALKS[id]
const wpts = walk.waypoints.map(([name, lat, lon]) => ` <wpt lat="${lat}" lon="${lon}"><name>${name}</name></wpt>`).join('\n')
const pts = walk.track.map(([lat, lon]) => ` <trkpt lat="${lat}" lon="${lon}"></trkpt>`).join('\n')
writeFileSync(file, `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="TREK help" xmlns="http://www.topografix.com/GPX/1/1">
${wpts}
<trk><name>${walk.name}</name><trkseg>
${pts}
</trkseg></trk>
</gpx>
`)
return file
}
/** Import a walk over the API, so the trip has a track before the pictures start. */
export async function ensureTrack(api: APIRequestContext, tripId: number, id: keyof typeof WALKS): Promise<void> {
const res = await api.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { name: string }[] } | { name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
if (places.some(p => p.name === WALKS[id].name)) return
const imported = await api.post(`/api/trips/${tripId}/places/import/gpx`, {
multipart: {
file: { name: `${id}.gpx`, mimeType: 'application/gpx+xml', buffer: readFileSync(gpxFixture(id)) },
importWaypoints: 'true', importRoutes: 'true', importTracks: 'true',
},
})
if (!imported.ok()) throw new Error(`could not import ${id}: ${imported.status()} ${await imported.text()}`)
}
/**
* What the day guides need on the seeded trip beyond its places: a stop with a
* time, a reservation tied to a stop, a train between two cities and a hotel
* over the first nights, so the day cards show every kind of row.
*/
export async function ensureDaysFixtures(api: APIRequestContext): Promise<void> {
const { tripId, dayIds } = seededTrip()
const assignmentsOf = async (dayId: number) => {
const res = await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`)
const body = (await res.json()) as { assignments?: { id: number; place_id: number; place?: { name: string } }[] }
return body.assignments ?? []
}
const placesRes = await api.get(`/api/trips/${tripId}/places`)
const placesBody = (await placesRes.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(placesBody) ? placesBody : (placesBody.places ?? [])
const byName = (name: string) => places.find(p => p.name === name)
// A time on the first stop of day 1.
const day1 = await assignmentsOf(dayIds[0])
const senso = day1.find(a => a.place_id === byName('Senso-ji Temple')?.id)
if (senso) await api.put(`/api/trips/${tripId}/assignments/${senso.id}/time`, { data: { place_time: '09:30', end_time: '11:00' } })
const resRes = await api.get(`/api/trips/${tripId}/reservations`)
const resBody = (await resRes.json()) as { reservations?: { title: string }[] } | { title: string }[]
const reservations = Array.isArray(resBody) ? resBody : (resBody.reservations ?? [])
const has = (title: string) => reservations.some(r => r.title === title)
// A reservation at the market stop on day 6.
const day6 = await assignmentsOf(dayIds[5])
const market = day6.find(a => a.place_id === byName('Nishiki Market')?.id)
if (market && !has('Lunch at Nishiki')) {
await api.post(`/api/trips/${tripId}/reservations`, {
data: {
title: 'Lunch at Nishiki', type: 'restaurant', status: 'confirmed', day_id: dayIds[5],
place_id: market.place_id, assignment_id: market.id, reservation_time: '2026-09-17T12:30:00', confirmation_number: 'NK-2211',
},
})
}
// The train from Tokyo to Kyoto on day 5.
if (!has('Nozomi 21 Tokyo → Kyoto')) {
await api.post(`/api/trips/${tripId}/reservations`, {
data: {
title: 'Nozomi 21 Tokyo → Kyoto', type: 'train', status: 'confirmed', location: 'Tokyo Station',
reservation_time: '2026-09-16T08:30:00', reservation_end_time: '2026-09-16T10:45:00', confirmation_number: 'JR-58204',
metadata: { train_number: 'Nozomi 21', platform: '17', seat: '8A' },
endpoints: [
{ role: 'from', sequence: 0, name: 'Tokyo Station', lat: 35.6812, lng: 139.7671, timezone: 'Asia/Tokyo', local_date: '2026-09-16', local_time: '08:30' },
{ role: 'to', sequence: 1, name: 'Kyoto Station', lat: 34.9858, lng: 135.7588, timezone: 'Asia/Tokyo', local_date: '2026-09-16', local_time: '10:45' },
],
},
})
}
// A hotel over the Tokyo nights.
if (!byName('Hotel Gracery Shinjuku')) {
const created = await api.post(`/api/trips/${tripId}/places`, {
data: { name: 'Hotel Gracery Shinjuku', lat: 35.6946, lng: 139.7012, address: '1-19-1 Kabukicho, Shinjuku City, Tokyo' },
})
const place = (await created.json()) as { place?: { id: number }; id?: number }
const placeId = place.place?.id ?? place.id
await api.post(`/api/trips/${tripId}/accommodations`, {
data: { place_id: placeId, start_day_id: dayIds[0], end_day_id: dayIds[3], check_in: '15:00', check_out: '11:00', confirmation: 'GRC-7731' },
})
}
}
+347
View File
@@ -0,0 +1,347 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import type { HelpGuide } from '../../src/help/types'
/**
* Runner for the help-center media (`npm run help:media`).
*
* A guide is defined once, in `src/help/contexts/<screen>.ts`; the file next to
* this one gives every step of it an action against the real app. From that
* single script this runner produces everything the panel shows:
*
* - one framed picture per step, with the element the reader is told to use
* ringed and numbered (`step-<n>.png`),
* - the state after the last step (`result.png`).
*
* And because every step has to succeed for the picture to exist, the same run
* is the test that the guide still matches the UI.
*
* Output lands in a staging directory; `promote.mjs` converts and moves it into
* `public/help-media/`, so a bad run never clobbers good artwork.
*/
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
/**
* A common desktop. Smaller frames squeeze the trip cards until their badges
* overlap the action buttons, which is not what a reader on a normal screen
* sees; the wiki shots use 1440×900 for the docs' narrower column.
*/
export const VIEWPORT = { width: 1920, height: 1080 }
export interface StepAction {
/** Bring the screen to where the step starts (runs after the previous step's `act`). */
prepare?: (page: Page) => Promise<void>
/** The element the reader is told to use; ringed and framed in the picture. */
target?: (page: Page) => Locator
/**
* Where to put the pointer for the picture when the target's centre is not on
* it (an SVG region whose bounding-box centre lies in a neighbour). Default:
* hover the target.
*/
hover?: (page: Page) => Promise<void>
/**
* A drag: the target is what the reader picks up, this is where it goes.
* Both are ringed and an arrow joins them, so one still shows the gesture.
*/
dropTo?: (page: Page) => Locator
/** What the step does. */
act?: (page: Page) => Promise<void>
}
export interface GuideScript {
guide: HelpGuide
/** Where the guide starts: navigate, dismiss notices, wait for data. */
start: (page: Page) => Promise<void>
/** Exactly one entry per registered step. */
steps: StepAction[]
/** Undo what the guide changed, so the next one starts from the seed again. */
cleanup?: (page: Page) => Promise<void>
}
/**
* 'still' takes the pictures at full speed. The mode stays as a switch so a
* recorder that plays the same scripts at a watchable pace can be added
* without touching them; nothing sets 'paced' today.
*/
let mode: 'still' | 'paced' = 'still'
/** A pause a person would make; nothing in still mode. */
export async function beat(page: Page, ms = 700): Promise<void> {
if (mode === 'paced') await page.waitForTimeout(ms)
}
/** Fill instantly for the pictures; type like a person when paced. */
export async function typeInto(page: Page, locator: Locator, text: string): Promise<void> {
if (mode === 'paced') {
await locator.click()
await locator.pressSequentially(text, { delay: 55 })
} else {
await locator.fill(text)
}
}
// ── Stills ────────────────────────────────────────────────────────────────────
/** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */
export async function captureGuide(page: Page, script: GuideScript): Promise<void> {
mode = 'still'
const { guide } = script
if (script.steps.length !== guide.steps) {
throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`)
}
const dir = path.join(OUT_DIR, guide.id)
mkdirSync(dir, { recursive: true })
await script.start(page)
await settle(page)
for (let i = 0; i < script.steps.length; i++) {
const n = i + 1
const step = script.steps[i]
await step.prepare?.(page)
if (step.target && guide.media.steps) {
const target = step.target(page)
await expect(target, `guide "${guide.id}" step ${n}: target`).toBeVisible()
await target.scrollIntoViewIfNeeded()
let box = await target.boundingBox()
if (!box) throw new Error(`guide "${guide.id}" step ${n}: target has no box`)
if (box.height > VIEWPORT.height - 2 * PAD) {
// A block taller than the frame (a whole settings section): show its top
// rather than its middle, which is where the heading is.
await target.evaluate(el => el.scrollIntoView({ block: 'start' }))
await page.evaluate(() => {
const nav = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64
window.scrollBy(0, -nav - 24)
})
} else {
// Playwright scrolls only as far as it must, which leaves a target at the
// bottom edge and the frame full of whatever sits above it. Centre it so
// the picture shows the element in its surroundings. Works on whichever
// ancestor scrolls (the dashboard has its own pane, not the document).
await target.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
}
if (step.hover) await step.hover(page)
else await target.hover()
await settle(page)
box = (await target.boundingBox()) ?? box
if (step.dropTo) {
const dest = step.dropTo(page)
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
const to = await dest.boundingBox()
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
await ringDrag(page, box, to, n)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
} else {
await ring(page, box, n)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(box) })
}
await unring(page)
}
await step.act?.(page)
}
if (guide.media.result) {
await settle(page)
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) // nothing hovered in the after-picture
await page.waitForTimeout(250)
await page.screenshot({ path: path.join(dir, 'result.png') })
}
await script.cleanup?.(page)
}
/** The screen overview picture for a context. */
export async function captureHero(page: Page, contextId: string, start: (page: Page) => Promise<void>): Promise<void> {
mode = 'still'
const dir = path.join(OUT_DIR, 'ctx')
mkdirSync(dir, { recursive: true })
await start(page)
await settle(page)
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4)
await page.waitForTimeout(250)
await page.screenshot({ path: path.join(dir, `${contextId}.png`) })
}
// ── Framing ──────────────────────────────────────────────────────────────────
const PAD = 64
const MIN_W = 960
const RATIO = 16 / 10
/**
* A 16:10 window around the target: at least MIN_W wide, big enough to hold the
* element with breathing room, kept inside the viewport. A target too large for
* the largest 16:10 frame that fits gets that frame, anchored at its top, so a
* long settings section shows its heading rather than its middle.
*/
export function frameFor(box: { x: number; y: number; width: number; height: number }) {
const { width: vw, height: vh } = VIEWPORT
const maxH = Math.min(vh, vw / RATIO)
const maxW = maxH * RATIO
let w = Math.max(MIN_W, box.width + 2 * PAD)
let h = Math.max(w / RATIO, box.height + 2 * PAD)
w = Math.max(w, h * RATIO)
const tall = h > maxH
if (w > maxW || tall) {
w = maxW
h = maxH
}
const x = clamp(box.x + box.width / 2 - w / 2, 0, vw - w)
const y = tall ? clamp(box.y - PAD / 2, 0, vh - h) : clamp(box.y + box.height / 2 - h / 2, 0, vh - h)
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) }
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
/** Ring the target in the app's accent, dim everything else, number it. */
async function ring(page: Page, box: { x: number; y: number; width: number; height: number }, n: number): Promise<void> {
await page.evaluate(
({ box, n }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const pad = 6
const el = document.createElement('div')
el.id = 'trek-help-ring'
Object.assign(el.style, {
position: 'fixed',
left: `${box.x - pad}px`,
top: `${box.y - pad}px`,
width: `${box.width + 2 * pad}px`,
height: `${box.height + 2 * pad}px`,
borderRadius: '12px',
boxShadow: `0 0 0 3px ${accent}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
pointerEvents: 'none',
zIndex: '2147483647',
})
const badge = document.createElement('div')
Object.assign(badge.style, {
position: 'absolute',
left: '-14px',
top: '-14px',
width: '28px',
height: '28px',
borderRadius: '999px',
background: accent,
color: accentText,
font: '700 13px/28px Poppins, system-ui, sans-serif',
textAlign: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
})
badge.textContent = String(n)
el.appendChild(badge)
document.body.appendChild(el)
},
{ box, n },
)
}
type Box = { x: number; y: number; width: number; height: number }
/** The smallest box around two. */
function union(a: Box, b: Box): Box {
const x = Math.min(a.x, b.x)
const y = Math.min(a.y, b.y)
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }
}
/**
* The drag picture: a dim over everything but the two boxes, the dragged one
* ringed and numbered, the destination ringed in a dashed line, and an arrow
* from the one to the other, bowed a little so it reads as a movement.
*/
async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void> {
await page.evaluate(
({ from, to, n }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const pad = 6
const W = window.innerWidth
const H = window.innerHeight
const ns = 'http://www.w3.org/2000/svg'
const el = (tag: string, attrs: Record<string, string | number>) => {
const node = document.createElementNS(ns, tag)
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v))
return node
}
const svg = el('svg', { id: 'trek-help-ring', width: W, height: H, viewBox: `0 0 ${W} ${H}` })
Object.assign(svg.style, { position: 'fixed', left: '0', top: '0', pointerEvents: 'none', zIndex: '2147483647' })
const grow = (b: typeof from) => ({ x: b.x - pad, y: b.y - pad, w: b.width + 2 * pad, h: b.height + 2 * pad })
const a = grow(from)
const b = grow(to)
const defs = el('defs', {})
const mask = el('mask', { id: 'trek-help-mask' })
mask.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'white' }))
mask.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'black' }))
mask.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'black' }))
defs.appendChild(mask)
const marker = el('marker', { id: 'trek-help-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' })
marker.appendChild(el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: accent }))
defs.appendChild(marker)
svg.appendChild(defs)
svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'rgba(0,0,0,0.30)', mask: 'url(#trek-help-mask)' }))
svg.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3 }))
svg.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3, 'stroke-dasharray': '10 7' }))
// From the edge of the dragged box that faces the destination to the facing edge there.
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 }
const horizontal = Math.abs(bc.x - ac.x) > Math.abs(bc.y - ac.y)
const start = horizontal ? { x: bc.x > ac.x ? a.x + a.w : a.x, y: ac.y } : { x: ac.x, y: bc.y > ac.y ? a.y + a.h : a.y }
const end = horizontal ? { x: bc.x > ac.x ? b.x : b.x + b.w, y: bc.y } : { x: bc.x, y: bc.y > ac.y ? b.y : b.y + b.h }
const dx = end.x - start.x
const dy = end.y - start.y
const len = Math.hypot(dx, dy) || 1
const bow = Math.min(90, len * 0.18)
const ctrl = { x: (start.x + end.x) / 2 - (dy / len) * bow, y: (start.y + end.y) / 2 + (dx / len) * bow }
svg.appendChild(el('path', {
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
fill: 'none', stroke: 'rgba(255,255,255,0.9)', 'stroke-width': 8, 'stroke-linecap': 'round',
}))
svg.appendChild(el('path', {
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
fill: 'none', stroke: accent, 'stroke-width': 4, 'stroke-linecap': 'round', 'marker-end': 'url(#trek-help-arrow)',
}))
const badge = el('g', {})
badge.appendChild(el('circle', { cx: a.x, cy: a.y, r: 14, fill: accent }))
const text = el('text', { x: a.x, y: a.y + 5, 'text-anchor': 'middle', fill: accentText, 'font-family': 'Poppins, system-ui, sans-serif', 'font-size': 13, 'font-weight': 700 })
text.textContent = String(n)
badge.appendChild(text)
svg.appendChild(badge)
document.body.appendChild(svg)
},
{ from, to, n },
)
}
async function unring(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
}
/**
* The release-notes modal greets every user once per version and its overlay
* swallows every click underneath. clearNotices handles the plain notices; this
* one has its own close button.
*/
export async function dismissReleaseNotice(page: Page): Promise<void> {
const close = page.locator('.rn-close')
await close.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {})
for (let i = 0; i < 3 && (await close.isVisible().catch(() => false)); i++) {
await close.click()
await page.waitForTimeout(500)
}
await expect(page.locator('.rn-overlay')).toHaveCount(0)
}
/** Fonts loaded, images decoded, transitions landed, network quiet (bounded: /ws never idles). */
export async function settle(page: Page): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
await page.evaluate(async () => { await document.fonts.ready })
// Bounded: a picture whose request never answers (a tile, a CDN flag) must
// not hold the run; three seconds is longer than anything that does load.
await page.evaluate(async () => {
const pending = Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res }))
await Promise.race([Promise.all(pending), new Promise(res => setTimeout(res, 3000))])
})
await page.waitForTimeout(450)
}
+797
View File
@@ -0,0 +1,797 @@
import { test, expect, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureExtraTrips, ensureJourneyFixtures, coverFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the two Journey screens, keyed by the ids in
* `src/help/contexts/journey.ts`: the list at `/journey` and the open journal
* at `/journey/:id`. The fixtures write three entries into the seeded
* "Autumn in Japan" journey and leave the rest of its timeline as suggestions.
*/
const ALL = [...journeyGuides, ...journalGuides, ...studioGuides]
const guide = (id: string): HelpGuide => {
const g = ALL.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
let journeyId = 0
async function openJourneys(page: Page): Promise<void> {
await page.goto('/journey')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(createCard(page)).toBeVisible({ timeout: 20_000 })
await expect(journeyCard(page, SECOND_JOURNEY.title)).toBeVisible({ timeout: 20_000 })
await settle(page)
}
async function openJournal(page: Page): Promise<void> {
await page.goto(`/journey/${journeyId}`)
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Add Entry' }).first()).toBeVisible({ timeout: 20_000 })
await expect(entryCard(page, 'First morning in Asakusa')).toBeVisible({ timeout: 20_000 })
await settle(page)
await page.waitForTimeout(600) // the map tiles
}
const journeyCard = (page: Page, title: string) => page.locator('.vg-card').filter({ hasText: title }).first()
/** The + card at the end of the grid. */
const createCard = (page: Page) => page.getByRole('button', { name: /Create a new Journey/ })
/** Full-screen dialogs of this screen (create, settings, add trip, invite) all sit at z-200. */
const anyDialog = (page: Page) => page.locator('div.fixed.inset-0.z-\\[200\\]')
const dialog = (page: Page) => anyDialog(page).last()
const dialogCard = (page: Page) => dialog(page).locator('> div').first()
/** The entry editor. */
const editor = (page: Page) => page.locator('div.fixed.inset-0.z-\\[9999\\]')
/** A written entry's card in the timeline, and the ⋯ menu button on its cover. */
const entryCard = (page: Page, title: string) => page.locator('div[class*="rounded-[20px]"]').filter({ hasText: title }).first()
// Two builds of the button: on a photo cover (rounded-[10px]) and on a plain card (w-7 h-7 rounded-md).
const entryMenu = (page: Page, title: string) => entryCard(page, title).locator('button[class*="rounded-[10px]"], button.w-7.rounded-md').first()
/** A suggestion card: the one kind of card that carries a dismiss button. */
const suggestion = (page: Page) =>
page.locator('[role="button"]').filter({ has: page.getByRole('button', { name: 'Dismiss this suggestion' }) }).first()
const settingsButton = (page: Page) => page.getByRole('button', { name: 'Journey Settings' }).first()
const mapPanel = (page: Page) => page.locator('aside').filter({ has: page.locator('.leaflet-container, .maplibregl-map, canvas') }).first()
async function openSettings(page: Page): Promise<void> {
await settingsButton(page).click()
await expect(dialog(page).getByText('Journey Settings')).toBeVisible()
await settle(page)
}
/** Close whatever dialogs are stacked: Escape first, then the backdrop, until none is left. */
async function closeDialog(page: Page): Promise<void> {
await page.keyboard.press('Escape')
for (let i = 0; i < 3 && (await anyDialog(page).count()); i++) {
await page.mouse.click(20, VIEWPORT.height / 2)
await page.waitForTimeout(300)
}
await expect(anyDialog(page)).toHaveCount(0)
await settle(page)
}
async function openEditor(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add Entry' }).first().click()
await expect(editor(page)).toBeVisible()
await settle(page)
}
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last()
const exportDialog = (page: Page) => page.locator('.st-ex')
const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click()
await expect(sidePanel(page)).toBeVisible()
await settle(page)
}
async function relayoutBook(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Auto layout' }).click()
await studioMenu(page).getByText('The whole book').click()
await expect.poll(async () => thumbs(page).count(), { timeout: 30_000 }).toBeGreaterThan(6)
await settle(page)
await page.waitForTimeout(800)
}
/**
* Studio over the journal. A journey with no book yet opens on an empty one,
* so the first visit lays the book out; every guide then finds pages to work
* on, and the overview picture shows a book rather than blank covers.
*/
async function openStudio(page: Page): Promise<void> {
await page.goto(`/journey/${journeyId}/studio`)
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.locator('.st-bar')).toBeVisible({ timeout: 30_000 })
await expect(rail(page)).toBeVisible({ timeout: 30_000 })
await studioSection(page, 'Pages')
// An untouched book is cover, first page, one empty spread, last page and back: five thumbnails.
if ((await thumbs(page).count()) < 7) await relayoutBook(page)
await settle(page)
await page.waitForTimeout(800)
}
const SCRIPTS: Record<string, GuideScript> = {
// ── /journey ────────────────────────────────────────────────────────────
'create-journey': {
guide: guide('create-journey'),
start: openJourneys,
steps: [
{
target: createCard,
act: async p => {
await createCard(p).click()
await expect(dialog(p)).toBeVisible()
await settle(p)
},
},
{
target: dialogCard,
act: async p => {
await typeInto(p, dialog(p).locator('input').first(), 'Lisbon weekend')
await typeInto(p, dialog(p).locator('input').nth(1), 'Two days of tiles and custard tarts')
await dialog(p).getByRole('checkbox').filter({ hasText: 'Weekend in Lisbon' }).click()
await beat(p, 400)
},
},
{
target: p => dialog(p).getByRole('button', { name: 'Create Journey' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Create Journey' }).click()
await expect(p).toHaveURL(/\/journey\/\d+/, { timeout: 15_000 })
await expect(p.getByRole('button', { name: 'Add Entry' }).first()).toBeVisible({ timeout: 20_000 })
await settle(p)
await p.waitForTimeout(600)
},
},
],
},
'open-journey': {
guide: guide('open-journey'),
start: openJourneys,
steps: [
{
target: p => journeyCard(p, SECOND_JOURNEY.title),
},
],
},
'continue-writing': {
guide: guide('continue-writing'),
start: openJourneys,
steps: [
{
target: p => p.getByText('Continue writing').first(),
},
],
},
// ── /journey/:id ────────────────────────────────────────────────────────
'add-entry': {
guide: guide('add-entry'),
start: openJournal,
steps: [
{
target: p => p.getByRole('button', { name: 'Add Entry' }).first(),
act: openEditor,
},
{
target: p => editor(p).getByPlaceholder('Write your story...'),
act: async p => {
await typeInto(p, editor(p).getByPlaceholder('Give this moment a name...'), 'Night market on the river')
await typeInto(
p,
editor(p).getByPlaceholder('Write your story...'),
'Lanterns along the Kamo, grilled sweetfish on sticks and a jazz trio nobody had booked. We stayed until the last stall packed up.',
)
await beat(p, 400)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Amazing' }).locator('xpath=../..'),
act: async p => {
await editor(p).getByRole('button', { name: 'Amazing' }).click()
await editor(p).getByRole('button', { name: 'Partly cloudy' }).click()
await beat(p, 400)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
await expect(entryCard(p, 'Night market on the river')).toBeVisible()
await settle(p)
},
},
],
},
'entry-photos': {
guide: guide('entry-photos'),
start: openJournal,
steps: [
{
target: p => entryMenu(p, 'Barefoot through the light'),
act: async p => {
await entryMenu(p, 'Barefoot through the light').click()
await p.getByRole('button', { name: 'Edit', exact: true }).first().click()
await expect(editor(p)).toBeVisible()
await settle(p)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Upload photos' }),
act: async p => {
const file = await coverFixture()
await editor(p).locator('input[type="file"][multiple]').setInputFiles(file)
await expect(editor(p).locator('img').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => { await editor(p).locator('img').first().hover() },
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(editor(p)).toHaveCount(0, { timeout: 30_000 })
await expect(entryCard(p, 'Barefoot through the light').locator('img').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
},
suggestions: {
guide: guide('suggestions'),
start: openJournal,
steps: [
{
target: suggestion,
},
{
target: p => suggestion(p).getByRole('button', { name: 'Dismiss this suggestion' }),
act: async p => {
const before = await p.getByRole('button', { name: 'Dismiss this suggestion' }).count()
await suggestion(p).getByRole('button', { name: 'Dismiss this suggestion' }).click()
// A place kept across two days is two suggestions, so one click can take two away.
await expect.poll(() => p.getByRole('button', { name: 'Dismiss this suggestion' }).count()).toBeLessThan(before)
await settle(p)
},
},
{
prepare: async p => {
await openSettings(p)
await dialog(p).getByText('Bring back dismissed suggestions').scrollIntoViewIfNeeded()
},
target: p => dialog(p).getByText('Bring back dismissed suggestions').locator('xpath=../..'),
act: async p => {
await dialog(p).getByRole('button', { name: 'Bring back dismissed suggestions' }).click()
await beat(p, 500)
await closeDialog(p)
},
},
],
},
'add-on-day': {
guide: guide('add-on-day'),
start: openJournal,
steps: [
{
target: p => p.getByRole('button', { name: 'Add an entry on this day' }).nth(1),
act: async p => {
await p.getByRole('button', { name: 'Add an entry on this day' }).nth(1).click()
await expect(editor(p)).toBeVisible()
await settle(p)
},
},
{
target: p => editor(p).getByText('Date', { exact: true }).locator('xpath=..'),
act: async p => {
await editor(p).getByRole('button', { name: 'Cancel' }).click()
await expect(editor(p)).toHaveCount(0)
},
},
],
},
'pros-cons': {
guide: guide('pros-cons'),
start: openJournal,
steps: [
{
prepare: async p => {
await entryMenu(p, 'First morning in Asakusa').click()
await p.getByRole('button', { name: 'Edit', exact: true }).first().click()
await expect(editor(p)).toBeVisible()
await editor(p).getByText('Pros & Cons').scrollIntoViewIfNeeded()
await settle(p)
},
target: p => editor(p).getByText('Pros & Cons').locator('xpath=../..'),
act: async p => {
await typeInto(p, editor(p).getByPlaceholder('Something great...').last(), 'Empty courtyard at seven')
await typeInto(p, editor(p).getByPlaceholder('Not so great...').last(), 'Coffee only after nine')
await beat(p, 400)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
// The card folds a long entry, so the saved verdict is checked at the source.
await expect.poll(async () => {
const res = await p.request.get(`/api/journeys/${journeyId}/entries`)
const { entries } = (await res.json()) as { entries: { title: string | null; pros_cons?: { pros?: string[] } | null }[] }
return entries.find(e => e.title === 'First morning in Asakusa')?.pros_cons?.pros ?? []
}).toContain('Empty courtyard at seven')
await settle(p)
},
},
],
},
'search-journey': {
guide: guide('search-journey'),
start: openJournal,
steps: [
{
target: p => p.getByPlaceholder('Search this journey'),
act: async p => {
await typeInto(p, p.getByPlaceholder('Search this journey'), 'crossing')
await settle(p)
},
},
{
prepare: async p => {
await p.getByPlaceholder('Search this journey').fill('')
await settle(p)
},
target: p => p.getByRole('button', { name: /^(Hide|Show) suggestions$/ }).first(),
},
],
},
'gallery-map': {
guide: guide('gallery-map'),
start: openJournal,
steps: [
{
target: p => p.getByRole('button', { name: 'Gallery' }).first(),
act: async p => {
await p.getByRole('button', { name: 'Gallery' }).first().click()
await settle(p)
},
},
{
prepare: async p => {
await p.getByRole('button', { name: 'Timeline', exact: true }).first().click()
await settle(p)
},
target: mapPanel,
},
],
},
'entry-fields': {
guide: guide('entry-fields'),
start: openJournal,
steps: [
{
target: settingsButton,
act: openSettings,
},
{
prepare: async p => { await dialog(p).getByText('Entry fields').scrollIntoViewIfNeeded() },
target: p => dialog(p).getByText('Entry fields').locator('xpath=..'),
act: closeDialog,
},
],
},
'link-trip': {
guide: guide('link-trip'),
start: openJournal,
steps: [
{
target: settingsButton,
act: openSettings,
},
{
prepare: async p => { await dialog(p).getByRole('button', { name: 'Add Trip' }).scrollIntoViewIfNeeded() },
target: p => dialog(p).getByRole('button', { name: 'Add Trip' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Add Trip' }).click()
await expect(dialog(p).getByPlaceholder('Trip name or destination...')).toBeVisible()
await settle(p)
},
},
{
target: p => dialog(p).getByText(SPARE_TRIP.title).locator('xpath=../..'),
act: async p => {
await dialog(p).getByText(SPARE_TRIP.title).locator('xpath=../..').getByRole('button', { name: 'Link' }).click()
await beat(p, 600)
if (await anyDialog(p).count()) await closeDialog(p)
await settle(p)
},
},
],
},
'share-public': {
guide: guide('share-public'),
start: openJournal,
steps: [
{
prepare: async p => {
await openSettings(p)
await dialog(p).getByText('Public Share').scrollIntoViewIfNeeded()
},
target: p => dialog(p).getByText('Public Share').locator('xpath=..'),
},
{
target: p => dialog(p).getByRole('button', { name: 'Create share link' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Create share link' }).click()
await expect(dialog(p).getByRole('button', { name: 'Copy' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => dialog(p).getByText('Public Share').locator('xpath=..'),
act: closeDialog,
},
],
},
contributors: {
guide: guide('contributors'),
start: openJournal,
steps: [
{
prepare: async p => {
await openSettings(p)
await dialog(p).getByRole('button', { name: 'Invite Contributor' }).scrollIntoViewIfNeeded()
},
target: p => dialog(p).getByRole('button', { name: 'Invite Contributor' }).locator('xpath=..'),
},
{
target: p => dialog(p).getByRole('button', { name: 'Invite Contributor' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Invite Contributor' }).click()
const search = p.getByPlaceholder('Username or email...')
await expect(search).toBeVisible()
await typeInto(p, search, 'jonas')
await dialog(p).locator('button').filter({ hasText: 'jonas' }).first().click()
await settle(p)
},
},
{
target: p => dialog(p).getByRole('button', { name: 'Invite', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(dialog(p).getByText('jonas')).toBeVisible({ timeout: 15_000 })
await beat(p, 500)
await closeDialog(p)
},
},
],
},
studio: {
guide: guide('studio'),
start: openJournal,
steps: [
{
target: p => p.getByRole('button', { name: 'Studio', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Studio', exact: true }).first().click()
await expect(p).toHaveURL(/\/studio/, { timeout: 15_000 })
await expect(p.locator('.st-bar')).toBeVisible({ timeout: 30_000 })
await settle(p)
await p.waitForTimeout(1200)
},
},
{
target: p => p.locator('.st-back'),
},
],
},
'archive-journey': {
guide: guide('archive-journey'),
start: openJournal,
steps: [
{
target: settingsButton,
act: openSettings,
},
{
prepare: async p => { await dialog(p).getByRole('button', { name: 'Archive Journey' }).scrollIntoViewIfNeeded() },
target: p => dialog(p).getByRole('button', { name: 'Archive Journey' }).locator('xpath=..'),
act: closeDialog,
},
],
},
// ── /journey/:id/studio ─────────────────────────────────────────────────
'studio-auto-layout': {
guide: guide('studio-auto-layout'),
start: openStudio,
steps: [
{
target: p => p.getByRole('button', { name: 'Auto layout' }),
act: async p => {
await p.getByRole('button', { name: 'Auto layout' }).click()
await expect(studioMenu(p)).toBeVisible()
await settle(p)
},
},
{
target: studioMenu,
act: async p => {
await studioMenu(p).getByText('The whole book').click()
await expect.poll(async () => thumbs(p).count(), { timeout: 30_000 }).toBeGreaterThan(6)
await settle(p)
await p.waitForTimeout(800)
},
},
{
target: p => p.getByRole('button', { name: 'Undo' }),
},
],
},
'studio-pages': {
guide: guide('studio-pages'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Pages'),
act: async p => { await studioSection(p, 'Pages') },
},
{
target: p => sidePanel(p).getByRole('button', { name: 'Add spread' }),
act: async p => {
const before = await thumbs(p).count()
await sidePanel(p).getByRole('button', { name: 'Add spread' }).click()
await expect(thumbs(p)).toHaveCount(before + 1)
await settle(p)
},
},
{
prepare: async p => {
await thumbs(p).nth(2).scrollIntoViewIfNeeded()
await thumbs(p).nth(2).hover()
},
target: p => thumbs(p).nth(2),
},
],
},
'studio-layouts': {
guide: guide('studio-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
target: p => sidePanel(p).locator('.st-tile, .st-thumb').nth(3),
act: async p => {
await sidePanel(p).locator('.st-tile, .st-thumb').nth(3).click()
await settle(p)
await p.waitForTimeout(600)
},
},
],
},
'studio-content': {
guide: guide('studio-content'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Content'),
act: async p => { await studioSection(p, 'Content') },
},
{
target: p => sidePanel(p).locator('.st-photo-grid').first(),
act: async p => {
const add = sidePanel(p).locator('button[title="Add to this page"]').first()
await add.hover()
await add.click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p)
},
target: p => sidePanel(p).locator('.st-entry').first(),
act: async p => {
await sidePanel(p).locator('.st-entry').first().getByRole('button', { name: 'Title', exact: true }).click()
await settle(p)
},
},
],
},
'studio-elements': {
guide: guide('studio-elements'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Elements'),
act: async p => { await studioSection(p, 'Elements') },
},
{
target: sidePanel,
},
],
},
'studio-travel': {
guide: guide('studio-travel'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Travel'),
act: async p => { await studioSection(p, 'Travel') },
},
{
target: sidePanel,
},
],
},
'studio-properties': {
guide: guide('studio-properties'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await expect(sheetElements(p).first()).toBeVisible()
},
target: p => sheetElements(p).first(),
act: async p => {
await sheetElements(p).first().click()
await expect(inspector(p).getByRole('button', { name: 'Delete' })).toBeVisible()
await settle(p)
},
},
{
target: inspector,
},
{
target: p => inspector(p).getByRole('button', { name: 'Delete' }).locator('xpath=..'),
},
],
},
'studio-format': {
guide: guide('studio-format'),
start: openStudio,
steps: [
{
target: p => p.getByRole('button', { name: 'Page format' }),
act: async p => {
await p.getByRole('button', { name: 'Page format' }).click()
await expect(studioMenu(p)).toBeVisible()
await settle(p)
},
},
{
target: studioMenu,
act: async p => {
await p.keyboard.press('Escape')
if (await studioMenu(p).isVisible()) await p.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2)
},
},
],
},
'studio-export': {
guide: guide('studio-export'),
start: openStudio,
steps: [
{
target: p => p.getByRole('button', { name: 'Export' }),
act: async p => {
await p.getByRole('button', { name: 'Export' }).click()
await expect(exportDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: exportDialog,
},
{
target: p => exportDialog(p).getByRole('button', { name: 'Print view' }),
act: async p => {
await exportDialog(p).getByRole('button', { name: 'Close' }).click()
await expect(exportDialog(p)).toHaveCount(0)
},
},
],
},
'studio-spread-file': {
guide: guide('studio-spread-file'),
start: openStudio,
steps: [
{
target: p => p.getByRole('button', { name: 'Download this spread' }),
},
{
prepare: async p => { await studioSection(p, 'Pages') },
target: p => sidePanel(p).getByRole('button', { name: 'Import', exact: true }),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
journeyId = await ensureJourneyFixtures(request)
})
test('every registered journey guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...journeyContext.guides, ...journalContext.guides, ...studioContext.guides].sort())
})
test('hero: journey', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, journeyContext.id, openJourneys)
})
test('hero: journey-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, journalContext.id, openJournal)
})
test('hero: journey-studio', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, studioContext.id, openStudio)
})
for (const id of [...journeyContext.guides, ...journalContext.guides, ...studioContext.guides]) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+61
View File
@@ -0,0 +1,61 @@
// Moves the help-center media from the staging directory into public/help-media/,
// re-encoding the PNG captures to WebP on the way.
//
// Captures are 2x (3840px wide for a full frame). The panel shows a step
// picture at ~600px and a full frame at ~1000px, on HiDPI displays, so 1440px
// and 1920px keep them sharp at a fraction of the bytes. WebP rather than PNG
// on purpose: the service worker precaches `png`, and help pictures must not
// ride along in every visitor's first download.
//
// Usage: node e2e/help/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
const DEST = path.join(process.cwd(), 'public', 'help-media')
const STEP_WIDTH = 1440
const FRAME_WIDTH = 1920
const dry = process.argv.includes('--dry')
if (!existsSync(SRC)) {
console.error(`No media in ${SRC} — run \`npm run help:media\` first.`)
process.exit(1)
}
let before = 0
let after = 0
let files = 0
async function picture(src, dest, width) {
const bytes = statSync(src).size
before += bytes
if (dry) {
console.log(` ${path.relative(SRC, src)} → ${path.relative(DEST, dest)} (${(bytes / 1024).toFixed(0)} KB)`)
return
}
const img = sharp(src)
const meta = await img.metadata()
const pipeline = (meta.width ?? 0) > width ? img.resize({ width }) : img
await pipeline.webp({ quality: 84, effort: 5 }).toFile(dest)
after += statSync(dest).size
files++
}
mkdirSync(DEST, { recursive: true })
for (const dir of readdirSync(SRC)) {
const from = path.join(SRC, dir)
if (!statSync(from).isDirectory() || dir.startsWith('.')) continue
const to = path.join(DEST, dir)
mkdirSync(to, { recursive: true })
for (const file of readdirSync(from)) {
if (path.extname(file) !== '.png') continue
const width = file.startsWith('step-') ? STEP_WIDTH : FRAME_WIDTH
await picture(path.join(from, file), path.join(to, file.replace(/\.png$/, '.webp')), width)
}
}
const mb = (n) => (n / 1024 / 1024).toFixed(1)
console.log(dry ? `dry run: ${mb(before)} MB in staging` : `promoted ${files} files: ${mb(before)} MB → ${mb(after)} MB in ${path.relative(process.cwd(), DEST)}`)
+23
View File
@@ -0,0 +1,23 @@
// `npm run help:media`: the help-center media run on its own port pair, so it
// can record while `npm run dev` keeps serving 5173/3001 to whoever is
// watching. The ports travel as environment variables because Playwright's
// workers re-read the config, and only the environment reaches all of them.
//
// Extra arguments go straight to Playwright, e.g.
// npm run help:media -- --grep "pictures"
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
const env = {
...process.env,
E2E_WEB_PORT: process.env.E2E_WEB_PORT || '5183',
E2E_API_PORT: process.env.E2E_API_PORT || '3011',
}
// The CLI's own entry, run by this node: no shell in between, so a --grep
// pattern with a `|` reaches Playwright untouched.
const cli = createRequire(import.meta.url).resolve('@playwright/test/cli')
const res = spawnSync(process.execPath, [cli, 'test', '--project=help-media', ...process.argv.slice(2)], {
stdio: 'inherit',
env,
})
process.exit(res.status ?? 1)
+343
View File
@@ -0,0 +1,343 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { createHmac } from 'node:crypto'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { settingsContext, settingsTabContexts, settingsGuides } from '../../src/help/contexts/settings'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the settings screens, keyed by the ids in
* `src/help/contexts/settings.ts`. One route, one tab per screen; most guides
* only frame a section, and the few that change something (theme, an API key,
* two-factor authentication) leave the account in a state the run can live
* with, because settings is the last file of the run.
*/
const TAB_OF: Record<string, string> = {
'settings-display': 'General',
'settings-appearance': 'Appearance',
'settings-map': 'Map',
'settings-notifications': 'Notifications',
'settings-integrations': 'Integrations',
'settings-offline': 'Offline',
'settings-account': 'Account',
}
const guide = (id: string): HelpGuide => {
const g = settingsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'General' }) }).first()
/** A settings card by the heading of its section. */
const section = (page: Page, title: string) =>
page.locator('h2').filter({ hasText: title }).first().locator('xpath=ancestor::div[contains(@class,"rounded-xl")][1]')
/** A field of a section by its label. */
const field = (page: Page, sectionTitle: string, label: string) =>
section(page, sectionTitle).getByText(label, { exact: true }).first().locator('xpath=..')
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
/** A sub-section headed by an h3 inside a card (the passkeys block under Account). */
const subsection = (page: Page, title: string) => page.locator('h3').filter({ hasText: title }).first().locator('xpath=../..')
async function openSettings(page: Page, tab: string): Promise<void> {
await page.goto('/settings')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
await settle(page)
}
const opener = (contextId: string) => (page: Page) => openSettings(page, TAB_OF[contextId])
/** RFC 6238 code for the secret the setup shows, so the run can enable 2FA for real. */
function totp(secretBase32: string): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const clean = secretBase32.toUpperCase().replace(/[^A-Z2-7]/g, '')
let bits = ''
for (const ch of clean) bits += alphabet.indexOf(ch).toString(2).padStart(5, '0')
const bytes = Buffer.from((bits.match(/.{8}/g) ?? []).map(b => parseInt(b, 2)))
const counter = Buffer.alloc(8)
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)))
const mac = createHmac('sha1', bytes).update(counter).digest()
const offset = mac[mac.length - 1] & 0x0f
const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]
return String(code % 1_000_000).padStart(6, '0')
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
// ── General ──────────────────────────────────────────────────────────────
'language-region': {
guide: guide('language-region'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Language & region', 'Language')),
only(p => section(p, 'Language & region')),
],
},
'travel-map-prefs': {
guide: guide('travel-map-prefs'),
start: opener('settings-display'),
steps: [
only(p => field(p, 'Travel & map', 'Always show booking routes').locator('xpath=..')),
only(p => field(p, 'Travel & map', 'Blur Booking Codes').locator('xpath=..')),
],
},
startup: {
guide: guide('startup'),
start: opener('settings-display'),
steps: [
{
target: p => field(p, 'Startup', 'Start page').locator('xpath=..'),
// The start tab only exists once the app opens on the active trip.
act: async p => { await section(p, 'Startup').getByRole('button', { name: 'Active trip' }).click(); await settle(p) },
},
only(p => field(p, 'Startup', 'Start tab').locator('xpath=..')),
],
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
},
// ── Appearance ───────────────────────────────────────────────────────────
'theme-scheme': {
guide: guide('theme-scheme'),
start: opener('settings-appearance'),
steps: [
{
target: p => section(p, 'Theme').getByRole('button', { name: 'Dark', exact: true }).locator('xpath=..'),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Light', exact: true }).click(); await beat(p, 400) },
},
{
target: p => section(p, 'Theme').getByText('Color scheme', { exact: true }).locator('xpath=..'),
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click(); await settle(p) },
},
{
prepare: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
target: p => section(p, 'Theme').getByText('Custom accent', { exact: true }).locator('xpath=..'),
act: async p => {
await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click()
await settle(p)
},
},
],
cleanup: async p => {
await section(p, 'Theme').getByRole('button', { name: 'Default', exact: true }).click()
await settle(p)
},
},
readability: {
guide: guide('readability'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Readability').getByText('Transparency', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Readability').getByText('Text size', { exact: true }).first().locator('xpath=../..')),
],
},
'dashboard-widgets': {
guide: guide('dashboard-widgets'),
start: opener('settings-appearance'),
steps: [
only(p => section(p, 'Dashboard widgets')),
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
],
},
// ── Map ──────────────────────────────────────────────────────────────────
'map-provider': {
guide: guide('map-provider'),
start: opener('settings-map'),
steps: [
only(p => section(p, 'Map').getByText('Map Provider', { exact: true }).locator('xpath=..')),
only(p => section(p, 'Map').getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
only(p => section(p, 'Map').getByRole('button', { name: 'Save Map' }).locator('xpath=..')),
],
},
// ── Notifications ────────────────────────────────────────────────────────
'notification-channels': {
guide: guide('notification-channels'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Notifications').getByText('Ntfy Topic', { exact: true }).locator('xpath=../..')),
only(p => section(p, 'Notifications').getByText('Webhook URL', { exact: true }).locator('xpath=../..')),
only(p => page_rows(p)),
],
},
// ── Integrations ─────────────────────────────────────────────────────────
'photo-providers': {
guide: guide('photo-providers'),
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'Immich')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=..')),
],
},
'api-keys': {
guide: guide('api-keys'),
start: opener('settings-integrations'),
steps: [
{
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
act: async p => {
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
await expect(modal(p)).toBeVisible()
await typeInto(p, modal(p).getByPlaceholder('e.g. Dawarich'), 'Home Assistant')
await modal(p).getByRole('button', { name: 'Create', exact: true }).click()
await expect(modal(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"]').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Done' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'mcp-oauth': {
guide: guide('mcp-oauth'),
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'MCP Configuration').getByText('MCP Endpoint', { exact: true }).locator('xpath=..')),
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'OAuth 2.1 Clients' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).locator('xpath=../..'),
},
{
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'API Tokens' }).click(); await settle(p) },
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'Create New Token' }).locator('xpath=../..'),
},
],
},
// ── Offline ──────────────────────────────────────────────────────────────
'offline-prepare': {
guide: guide('offline-prepare'),
start: opener('settings-offline'),
steps: [
only(p => section(p, 'What to store offline')),
only(p => p.getByRole('button', { name: 'Download for offline use' })),
only(p => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
],
},
'offline-conflicts': {
guide: guide('offline-conflicts'),
start: opener('settings-offline'),
steps: [
// The conflicts section only exists while a conflict is open; the cache card shows their count.
only(p => section(p, 'Offline cache')),
only(p => p.getByRole('button', { name: 'Re-sync now' })),
],
},
// ── Account ──────────────────────────────────────────────────────────────
profile: {
guide: guide('profile'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
],
},
password: {
guide: guide('password'),
start: opener('settings-account'),
steps: [
only(p => section(p, 'Account').getByPlaceholder('Current password').locator('xpath=../..')),
only(p => section(p, 'Account').getByRole('button', { name: 'Update password' })),
],
},
mfa: {
guide: guide('mfa'),
start: opener('settings-account'),
steps: [
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }),
act: async p => {
await subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }).click()
await expect(subsection(p, 'Two-factor authentication (2FA)').locator('code')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByPlaceholder('6-digit code').locator('xpath=../..'),
act: async p => {
const mfa = subsection(p, 'Two-factor authentication (2FA)')
const secret = (await mfa.locator('code').first().innerText()).trim()
await typeInto(p, mfa.getByPlaceholder('6-digit code'), totp(secret))
await mfa.getByRole('button', { name: 'Enable 2FA' }).click()
await expect(mfa.getByText('Backup codes').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
},
],
},
passkeys: {
guide: guide('passkeys'),
start: opener('settings-account'),
steps: [
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
{},
{},
],
},
'delete-account': {
guide: guide('delete-account'),
start: opener('settings-account'),
steps: [
only(p => p.getByRole('button', { name: 'Delete account' }).locator('xpath=..')),
],
},
}
/** The per-event preference rows under the channels. */
function page_rows(p: Page): Locator {
return section(p, 'Notifications').locator('table, [role="table"], .space-y-4 > div').last()
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
const ALL_CONTEXTS = [settingsContext, ...settingsTabContexts]
// The photo-provider sections exist only for providers the admin switched on, and the
// ntfy and webhook forms only for channels the admin turned on.
test.beforeAll(async ({ request }) => {
const res = await request.put('/api/admin/addons/immich', { data: { enabled: true } })
if (!res.ok()) throw new Error(`could not enable the Immich provider: ${res.status()} ${await res.text()}`)
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook' } })
if (!channels.ok()) throw new Error(`could not enable the notification channels: ${channels.status()} ${await channels.text()}`)
})
test('every registered settings guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
})
test('hero: settings', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, settingsContext.id, p => openSettings(p, 'General'))
})
for (const ctx of settingsTabContexts) {
test(`hero: ${ctx.id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, ctx.id, opener(ctx.id))
})
}
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+412
View File
@@ -0,0 +1,412 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureDaysFixtures } from './fixtures'
import { openTrip, modal, dialog, selectDay, closeDayDetails } from './trip-shared'
import { tripDaysContext, tripDaysGuides } from '../../src/help/contexts/tripDays'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the days column of a trip, keyed by the ids in
* `src/help/contexts/tripDays.ts`. The seeded trip gets a timed stop, a
* reservation at a stop, a train and a hotel first, so the cards show every
* kind of row; a guide that changes the plan puts it back in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripDaysGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
/** A day card's header button. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** A stop row: the button that holds the lock button and the place's name. */
const stop = (page: Page, name: string) =>
page.locator('button').filter({ has: page.getByRole('button', { name: /Keep position during route optimization|Click to unlock/ }) }).filter({ hasText: name }).first()
/** A row of the places column. */
const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** The context menu of a stop or a note. */
const menu = (page: Page) => page.locator('.trek-popover-enter').last()
/** The route bar of the open day. */
const routeBar = (page: Page) => page.getByRole('button', { name: 'Route', exact: true }).locator('xpath=..')
const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).locator('xpath=..')
/** A connector between two stops, once the day's route is computed. */
const connector = (page: Page) => page.locator('div[role="button"]').filter({ has: page.locator('svg.lucide-car, svg.lucide-footprints') }).first()
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => names.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/** Open the trip on day 1 and draw its route, so the connectors between the stops exist. */
async function openWithRoute(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Route', exact: true }).click()
await expect(connector(page)).toBeVisible({ timeout: 30_000 })
await settle(page)
}
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)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'read-day-plan': {
guide: guide('read-day-plan'),
start: openWithRoute,
steps: [
only(p => dayHeader(p, 1)),
only(p => stop(p, 'Senso-ji Temple')),
only(p => p.locator('button').filter({ hasText: /Departure.*LH716/ }).first()),
only(connector),
only(routeBar),
],
},
'place-onto-day': {
guide: guide('place-onto-day'),
start: async p => {
await createSpare(p)
await openTrip(p)
},
steps: [
{
target: p => placeRow(p, SPARE.name),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
act: async p => {
await placeRow(p, SPARE.name).dragTo(dayHeader(p, 2))
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
// Back to the list for the second way in.
await stop(p, SPARE.name).click({ button: 'right' })
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
await placeRow(p, SPARE.name).hover()
await settle(p)
},
target: p => placeRow(p, SPARE.name).locator('button').last(),
},
only(p => p.getByRole('button', { name: 'Add place to this day' }).first()),
only(p => p.getByRole('button', { name: 'Add to the open day' })),
],
cleanup: p => deleteByName(p, SPARE.name),
},
'reorder-stops': {
guide: guide('reorder-stops'),
start: p => openTrip(p),
steps: [
{
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
dropTo: p => stop(p, 'Senso-ji Temple'),
},
{
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
target: p => stop(p, 'teamLab Planets').locator('button:has(svg.lucide-chevron-up)'),
},
{
target: p => stop(p, 'teamLab Planets'),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
},
{
prepare: async p => {
// Moving the timed stop asks first; the question is the picture.
await stop(p, 'Senso-ji Temple').hover()
await stop(p, 'Senso-ji Temple').locator('button:has(svg.lucide-chevron-down)').click()
await expect(modal(p).getByText('Remove time?')).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: dialog,
act: closeModal,
},
],
},
'set-stop-times': {
guide: guide('set-stop-times'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await stop(p, 'teamLab Planets').click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: async p => {
await menu(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p).getByText('Start', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
{
prepare: async p => {
// The time pickers are text fields that parse on blur.
const times = modal(p).getByPlaceholder(/^(00:00|2:30 PM)$/)
await times.first().fill('13:00')
await times.first().blur()
await times.nth(1).fill('15:00')
await times.nth(1).blur()
await beat(p, 300)
},
target: p => modal(p).getByText('Start', { exact: true }).locator('xpath=../..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Update', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stop(p, 'teamLab Planets').getByText('13:00')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`)
const body = (await res.json()) as { assignments?: { id: number; place?: { name: string }; place_name?: string }[] }
for (const a of body.assignments ?? []) {
if ((a.place?.name ?? a.place_name) === 'teamLab Planets') {
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
}
}
},
},
'remove-from-day': {
guide: guide('remove-from-day'),
start: async p => {
await createSpare(p)
await openTrip(p)
await placeRow(p, SPARE.name).hover()
await placeRow(p, SPARE.name).locator('button').last().click()
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
prepare: async p => {
await stop(p, SPARE.name).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Remove from day' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
only(p => placeRow(p, SPARE.name)),
],
cleanup: p => deleteByName(p, SPARE.name),
},
'lock-stop': {
guide: guide('lock-stop'),
start: p => openTrip(p),
steps: [
{
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }),
act: async p => {
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }).click()
await expect(stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' })).toBeVisible()
await settle(p)
},
},
{
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }),
act: async p => {
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }).click()
},
},
],
},
'day-note': {
guide: guide('day-note'),
start: p => openTrip(p),
steps: [
{
target: p => dayHeader(p, 2).getByRole('button', { name: 'Add Note' }),
act: async p => {
await dayHeader(p, 2).getByRole('button', { name: 'Add Note' }).click()
await expect(modal(p).getByPlaceholder('Details, links, reminders…')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Details, links, reminders…'), 'Rain plan: **Tokyo National Museum** instead of the shrine, tickets at the gate.')
await beat(p, 300)
},
target: p => modal(p).getByPlaceholder('Details, links, reminders…').locator('xpath=ancestor::div[2]'),
},
{
target: p => modal(p).getByText('Colour', { exact: true }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(p.getByText('Rain plan:')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await p.locator('button').filter({ hasText: 'Rain plan:' }).first().click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: p => p.locator('button').filter({ hasText: 'Rain plan:' }).first(),
act: async p => { await p.keyboard.press('Escape') },
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[1]}/notes`)
const body = (await res.json()) as { notes?: { id: number; text: string }[] } | { id: number; text: string }[]
const notes = Array.isArray(body) ? body : (body.notes ?? [])
for (const note of notes.filter(n => n.text.startsWith('Rain plan:'))) {
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
}
},
},
'day-route': {
guide: guide('day-route'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Route', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).click()
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
{
prepare: async p => {
await connector(p).click()
await expect(menu(p).getByRole('button', { name: 'Use day default' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: async p => { await p.keyboard.press('Escape') },
},
only(p => p.getByRole('button', { name: 'Optimize' })),
only(p => p.getByRole('button', { name: 'Open in Google Maps' }).locator('xpath=..')),
],
},
'manage-days': {
guide: guide('manage-days'),
start: p => openTrip(p),
steps: [
only(p => dayHeader(p, 1)),
{
prepare: async p => {
await p.getByRole('button', { name: 'Reorder days' }).click()
await expect(p.getByText('Reorder days').first()).toBeVisible()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Add day' }).locator('xpath=ancestor::div[3]'),
act: async p => { await p.keyboard.press('Escape'); await settle(p) },
},
{
prepare: async p => {
await selectDay(p, 1)
await expect(p.getByRole('button', { name: 'Edit', exact: true })).toBeVisible()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Edit', exact: true }).locator('xpath=..'),
act: closeDayDetails,
},
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
],
},
'bookings-in-plan': {
guide: guide('bookings-in-plan'),
start: async p => {
await openTrip(p, { day: 5 })
},
steps: [
only(p => p.locator('button').filter({ hasText: /Departure.*Nozomi 21/ }).first()),
{
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
target: p => stop(p, 'Nishiki Market'),
},
{
prepare: async p => {
await selectDay(p, 1)
await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible()
await settle(p)
},
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=..'),
act: closeDayDetails,
},
only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })),
{
prepare: async p => { await stop(p, 'Senso-ji Temple').hover() },
target: p => stop(p, 'Senso-ji Temple').getByRole('button', { name: 'Add booking' }),
},
],
},
'export-plan': {
guide: guide('export-plan'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Export' }),
act: async p => {
await p.getByRole('button', { name: 'Export' }).click()
await expect(modal(p).getByText('Document', { exact: true })).toBeVisible()
await settle(p)
},
},
only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=..')),
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=..')),
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=..')),
],
cleanup: closeModal,
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureDaysFixtures(request)
})
test('every registered days guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDaysContext.guides].sort())
})
test('hero: trip-days', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripDaysContext.id, openWithRoute)
})
for (const id of tripDaysContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+334
View File
@@ -0,0 +1,334 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, gpxFixture, ensureTrack } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the places column of a trip, keyed by the ids in
* `src/help/contexts/tripPlaces.ts`. They run on the seeded "Autumn in Japan"
* with one walk imported up front, so the Tracks filter exists; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripPlacesGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
/** A row of the places column by the place's name (the rows are options of a list). */
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */
const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last()
/** The add button: "Add Place/Activity" with no day open, "New place" with one. */
const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ })
/** The filter row's controls. */
const filterSelect = (page: Page) => page.getByTestId('places-filter').getByRole('button').first()
const searchBox = (page: Page) => page.getByPlaceholder('Search places...').last()
/** The column itself: the header block around the search box. */
const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]')
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
for (const place of places.filter(x => names.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
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)
}
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'create-place': {
guide: guide('create-place'),
start: p => openTrip(p),
steps: [
{
// A running trip always has a day open, so the button reads New place.
target: addButton,
act: async p => {
await addButton(p).click()
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const box = modal(p).getByPlaceholder('Search places...')
await typeInto(p, box, 'Tokyo Tower')
// The suggestions drop down under the box; the first one is the tower.
const suggestion = modal(p).locator('.shadow-dropdown button').filter({ hasText: /Tokyo Tower/ }).first()
await expect(suggestion).toBeVisible({ timeout: 20_000 })
await suggestion.click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(/Tokyo Tower/, { timeout: 20_000 })
await settle(p)
},
target: p => modal(p).getByPlaceholder('Search places...').locator('xpath=../..'),
},
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::div[2]')),
only(p => modal(p).getByPlaceholder('e.g. Eiffel Tower').locator('xpath=ancestor::div[3]')),
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, 'Tokyo Tower')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, 'Tokyo Tower'),
},
'place-to-open-day': {
guide: guide('place-to-open-day'),
start: async p => {
await createSpare(p)
await openTrip(p, { day: null })
},
steps: [
{
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
act: async p => {
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Add to the open day' }),
act: async p => {
await p.getByRole('button', { name: 'Add to the open day' }).click()
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
await beat(p, 400)
await closeModal(p)
},
},
{
prepare: async p => { await row(p, SPARE.name).hover() },
target: p => row(p, SPARE.name).locator('button').last(),
act: async p => {
await row(p, SPARE.name).locator('button').last().click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'filter-places': {
guide: guide('filter-places'),
start: async p => {
await createSpare(p)
await openTrip(p)
},
steps: [
{
prepare: async p => { await filterSelect(p).click(); await beat(p, 300) },
target: filterSelect,
// The lists close on a second click of their trigger, not on Escape.
act: async p => { await filterSelect(p).click(); await settle(p) },
},
{
prepare: async p => { await typeInto(p, searchBox(p), 'temple'); await settle(p) },
target: p => searchBox(p).locator('xpath=..'),
act: async p => { await searchBox(p).fill(''); await settle(p) },
},
{
prepare: async p => { await p.getByRole('button', { name: 'All Categories' }).click(); await beat(p, 300) },
target: p => p.getByRole('button', { name: 'All Categories' }),
act: async p => { await p.getByRole('button', { name: 'All Categories' }).click(); await settle(p) },
},
{
prepare: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click(); await beat(p, 300) },
target: p => p.getByRole('button', { name: 'Filter by rating' }),
act: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click() },
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'edit-place': {
guide: guide('edit-place'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await row(p, 'Shibuya Crossing').click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: menu,
act: async p => {
await menu(p).getByRole('button', { name: 'Edit' }).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue('Shibuya Crossing')
await settle(p)
},
},
only(dialog),
{
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: closeModal,
},
],
},
'delete-place': {
guide: guide('delete-place'),
start: async p => {
await createSpare(p)
await openTrip(p)
},
steps: [
{
prepare: async p => {
await row(p, SPARE.name).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Delete' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Delete' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Delete' }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(row(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SPARE.name),
},
'select-places': {
guide: guide('select-places'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Select', exact: true }).click()
await expect(p.getByRole('button', { name: 'Select all' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await row(p, 'Shibuya Crossing').click()
await row(p, 'Meiji Jingu').click()
await expect(p.getByText('2 selected')).toBeVisible()
await beat(p, 300)
},
target: p => p.getByText('2 selected').locator('xpath=..'),
},
only(p => p.getByRole('button', { name: 'Delete selected' }).locator('xpath=..')),
{
target: p => p.getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Select', exact: true }).click()
await expect(p.getByRole('button', { name: 'Select all' })).toHaveCount(0)
},
},
],
},
'import-places-file': {
guide: guide('import-places-file'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Import file' }),
act: async p => {
await p.getByRole('button', { name: 'Import file' }).click()
await expect(modal(p).getByText('Import file')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).locator('input[type="file"]').setInputFiles(gpxFixture('arashiyama-loop'))
await expect(modal(p).getByText('Tracks (with path geometry)')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: dialog,
},
only(p => modal(p).getByText('Enrich places via Google').locator('xpath=ancestor::label[1] | xpath=..').first()),
{
target: p => modal(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect(row(p, 'Arashiyama loop')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
},
'import-places-list': {
guide: guide('import-places-list'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'List Import' }),
act: async p => {
await p.getByRole('button', { name: 'List Import' }).click()
await expect(modal(p).getByPlaceholder(/goo\.gl|naver/)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder(/goo\.gl|naver/), 'https://maps.app.goo.gl/kyoto-favourites')
await beat(p, 300)
},
target: dialog,
},
only(p => modal(p).getByRole('button', { name: 'Import', exact: true })),
],
cleanup: closeModal,
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
})
test('every registered places guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlacesContext.guides].sort())
})
test('hero: trip-places', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripPlacesContext.id, p => openTrip(p))
})
for (const id of tripPlacesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+47
View File
@@ -0,0 +1,47 @@
import { expect, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { dismissReleaseNotice, settle } from './guide'
import { seededTrip } from './fixtures'
/**
* What every trip screen's script needs: the way into the seeded trip, the
* day selection and the dialogs. Not a `.guide.ts`, so importing it registers
* no tests twice.
*/
export const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
export const dialog = (page: Page) => modal(page).locator('> [role="presentation"], > div').first()
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
const { tripId } = seededTrip()
const { tab, day = 1 } = opts
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible({ timeout: 30_000 })
await settle(page)
if (!tab && day) {
// The trip is running, so the plan opens on today, an empty day at the
// bottom. Day 1 has the places; its details panel would cover the map.
await selectDay(page, day)
await closeDayDetails(page)
}
// The map tiles land after the page says it is idle.
await page.waitForTimeout(1200)
}
/** Click the day's header: selects it and opens its details panel. */
export async function selectDay(page: Page, n: number): Promise<void> {
await page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) }).click()
await settle(page)
}
/** The details panel's close is the one unlabelled X button over the map. */
export async function closeDayDetails(page: Page): Promise<void> {
const close = page.locator('button:has(svg.lucide-x)').last()
if (await close.isVisible().catch(() => false)) {
await close.click()
await settle(page)
}
}
+244
View File
@@ -0,0 +1,244 @@
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 } from './fixtures'
import { openTrip, 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 people of the trip (the Share dialog), the columns and the undo. They run
* on the seeded "Autumn in Japan"; what a guide creates (a member, a guest, a
* link) 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
}
/** The block of the Share dialog whose heading this is: the span sits in a row of its own inside the block. */
const block = (page: Page, heading: string | RegExp) => modal(page).getByText(heading, { exact: typeof heading === 'string' }).first().locator('xpath=../..')
/** The Invite User block: its heading is the label, right inside the block. */
const inviteBlock = (page: Page) => modal(page).locator('label').filter({ hasText: 'Invite User' }).locator('xpath=..')
/** The days column's fold toggle comes first in the page; the day cards' own Collapse buttons follow it. */
const leftToggle = (page: Page) => page.locator('button[title="Collapse"], button[title="Plan"]').first()
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Share' }).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(page)
}
const closeModal = async (page: Page): Promise<void> => {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'add-member': {
guide: guide('add-member'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByRole('button', { name: 'Share' }),
act: async p => {
await p.getByRole('button', { name: 'Share' }).click()
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
await settle(p)
},
},
{
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(block(p, /^Access \(/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Access \(/)),
],
cleanup: closeModal,
},
'trip-invite-link': {
guide: guide('trip-invite-link'),
start: openShare,
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')),
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: openShare,
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(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: closeModal,
},
'public-link': {
guide: guide('public-link'),
start: openShare,
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
await beat(p, 300)
},
},
{
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 })
},
},
],
cleanup: closeModal,
},
'transfer-ownership': {
guide: guide('transfer-ownership'),
start: openShare,
steps: [
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
],
cleanup: closeModal,
},
'collapse-columns': {
guide: guide('collapse-columns'),
start: p => openTrip(p),
steps: [
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(p.locator('button[title="Plan"]')).toBeVisible()
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: p => p.locator('div[style*="col-resize"]').first(),
},
],
},
'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()}`)
await openTrip(p)
// A change to undo: the spare place onto day 1 (selected by openTrip) from the places column.
const row = p.getByRole('option', { name: /^Tsukiji Outer Market/ })
await row.hover()
await row.locator('button').last().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.getByRole('button', { name: 'Undo' })).toBeDisabled({ 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])
})
}
+393
View File
@@ -0,0 +1,393 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Vacay guides, keyed by the ids in `src/help/contexts/vacay.ts`.
* They run in the order the context lists them and build on each other: the
* year the calendar shows gets a few logged days, company holidays and a
* public-holiday calendar as the run goes on, which is what a lived-in plan
* looks like anyway.
*/
const YEAR = 2026
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
async function openVacay(page: Page): Promise<void> {
await page.goto('/vacay')
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(toolbar(page)).toBeVisible()
await expect(monthCard(page, 'October')).toBeVisible()
}
/** The floating mode toolbar under the grid. */
const toolbar = (page: Page) => page.locator('.vg-card.rounded-full').first()
/** A month card by its (English) heading. */
const monthCard = (page: Page, month: string) => page.locator('.vg-card').filter({ hasText: month }).first()
/** A day cell inside a month card. */
const day = (page: Page, month: string, n: number) => monthCard(page, month).getByRole('button', { name: String(n), exact: true })
/** A sidebar card by its label (the DOM text; CSS uppercases it). */
const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter({ hasText: label }).first()
/** The settings dialog. */
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
/** The invite and share forms: small dialogs portalled to the body. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
/** The switch of a settings row, found through the row's label. */
const toggleRow = (page: Page, label: string): Locator =>
settings(page).getByText(label, { exact: true }).locator('xpath=../../..').getByRole('button').first()
async function openSettings(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Settings' }).click()
await expect(settings(page)).toBeVisible()
await settle(page)
}
async function closeSettings(page: Page): Promise<void> {
await page.keyboard.press('Escape')
await expect(settings(page)).toBeHidden()
await settle(page)
}
/**
* Pick an option in a CustomSelect: open it, filter, click the match inside
* the open menu. Scoped to the menu on purpose: a calendar row already set to
* "Germany" renders a trigger button of that very name, which a page-wide
* lookup would hit first.
*/
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
const search = page.getByPlaceholder('...')
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
const menu = search.locator('xpath=../..')
await menu.getByRole('button', { name: text, exact: true }).first().click()
}
const SCRIPTS: Record<string, GuideScript> = {
'log-day': {
guide: guide('log-day'),
start: openVacay,
steps: [
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, 'October', 14),
act: async p => { await day(p, 'October', 14).click(); await beat(p, 600) },
},
{
target: p => day(p, 'October', 14),
act: async p => {
await day(p, 'October', 14).click()
await beat(p, 600)
// Leave the day logged for the after-picture and the guides that follow.
await day(p, 'October', 14).click()
await settle(p)
},
},
],
},
'half-day': {
guide: guide('half-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
{
target: p => day(p, 'October', 15),
act: async p => { await day(p, 'October', 15).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
],
},
'comp-day': {
guide: guide('comp-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Comp / Flex' }),
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
},
{
target: p => day(p, 'October', 16),
act: async p => {
await day(p, 'October', 16).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
},
},
],
},
entitlement: {
guide: guide('entitlement'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first(),
act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() },
},
{
target: p => sideCard(p, 'Entitlement').locator('input'),
act: async p => {
const input = sideCard(p, 'Entitlement').locator('input')
await input.fill('')
await typeInto(p, input, '30')
await input.press('Enter')
await settle(p)
},
},
],
},
years: {
guide: guide('years'),
start: openVacay,
steps: [
{
target: p => p.getByTitle('Add next year'),
act: async p => {
await p.getByTitle('Add next year').click()
await expect(sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) })).toBeVisible()
await settle(p)
},
},
{
target: p => sideCard(p, 'Year').locator('.grid'),
act: async p => {
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click()
await settle(p)
},
},
{
prepare: async p => { await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().hover() },
target: p => sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().getByRole('button', { name: 'Remove year' }),
},
],
cleanup: async p => {
// The extra year was for the pictures only; the guides that follow expect the seed's single year.
const res = await p.request.delete(`/api/addons/vacay/years/${YEAR + 1}`)
if (!res.ok()) throw new Error(`could not remove year ${YEAR + 1}: ${res.status()}`)
},
},
'company-holidays': {
guide: guide('company-holidays'),
start: openVacay,
steps: [
{
// On by default, so the step only shows where the switch lives.
prepare: openSettings,
target: p => toggleRow(p, 'Company Holidays'),
act: closeSettings,
},
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await beat(p, 400)
await day(p, 'December', 31).click()
await settle(p)
// Back to logging vacation, so the next guide starts where a reader would.
await toolbar(p).getByRole('button').first().click()
},
},
],
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => toggleRow(p, 'Public Holidays'),
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
},
{
target: p => settings(p).getByRole('button', { name: 'Add calendar' }).first(),
act: async p => {
await settings(p).getByRole('button', { name: 'Add calendar' }).first().click()
await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Berlin')
await pick(p, settings(p).getByRole('button', { name: 'Select country' }), 'Germany')
// The region list loads after the country is picked; give it a moment to show up.
const region = settings(p).getByRole('button', { name: 'Select region (required)' })
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin')
await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
await settings(p).getByRole('button', { name: 'Add', exact: true }).click()
await settle(p)
},
},
{
// The dialog's close button has no label of its own: it is the button in the header next to the title.
target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(),
act: async p => { await closeSettings(p); await settle(p) },
},
],
},
'school-holidays': {
guide: guide('school-holidays'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => toggleRow(p, 'School Holidays'),
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
},
{
target: p => settings(p).getByRole('button', { name: 'Add calendar' }).last(),
act: async p => {
await settings(p).getByRole('button', { name: 'Add calendar' }).last().click()
await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Schools Berlin')
await pick(p, settings(p).getByRole('button', { name: 'Select country' }), 'Germany')
// The region list loads after the country is picked; give it a moment to show up.
const region = settings(p).getByRole('button', { name: 'Select region (required)' })
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin')
await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
await settings(p).getByRole('button', { name: 'Add', exact: true }).click()
await settle(p)
},
},
{
// The dialog's close button has no label of its own: it is the button in the header next to the title.
target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(),
act: async p => { await closeSettings(p); await settle(p) },
},
],
},
weekends: {
guide: guide('weekends'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Block Weekends'),
act: async p => { await toggleRow(p, 'Block Weekends').click(); await settle(p) },
},
{
target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'),
act: closeSettings,
},
],
},
'leave-year': {
guide: guide('leave-year'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'),
},
{
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first(),
act: closeSettings,
},
],
},
'carry-over': {
guide: guide('carry-over'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Carry Over'),
act: async p => { await toggleRow(p, 'Carry Over').click(); await settle(p); await closeSettings(p) },
},
],
},
invite: {
guide: guide('invite'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Persons').getByRole('button').first(),
act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) },
},
{
target: p => dialog(p).getByRole('button', { name: 'Select user' }),
act: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)')
await dialog(p).getByRole('button', { name: 'Send Invite' }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => sideCard(p, 'Persons').getByText('mira', { exact: false }).first(),
},
],
},
'share-calendar': {
guide: guide('share-calendar'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Shared Calendars').getByTitle('Share calendar'),
act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) },
},
{
target: p => dialog(p).getByRole('button', { name: 'Select user' }),
act: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas')
await dialog(p).getByRole('button', { name: 'Share', exact: true }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => sideCard(p, 'Shared Calendars'),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test('every registered vacay guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(vacayContext.guides.slice().sort())
})
test('hero: vacay', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, vacayContext.id, openVacay)
})
for (const id of vacayContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
import { E2E_BASE_URL } from '../../playwright.config'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
@@ -17,7 +18,7 @@ setup('seed the demo trip', async ({ page, playwright }) => {
// members — see the comment in seed.ts on why they must not share one.
const result = await seedDemoData(page.request, token =>
playwright.request.newContext({
baseURL: 'http://localhost:5173',
baseURL: E2E_BASE_URL,
// MUST be explicit: newContext otherwise picks up the project's
// storageState, i.e. the admin's trek_session cookie — and the server
// reads the cookie BEFORE the Authorization header
+2 -1
View File
@@ -27,7 +27,8 @@ const env = {
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
// playwright.config.ts picks the port; 3001 is the historical default.
PORT: process.env.E2E_API_PORT || '3001',
NODE_ENV: 'development',
}
+2
View File
@@ -23,6 +23,8 @@
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"help:media": "node e2e/help/run.mjs",
"help:media:promote": "node e2e/help/promote.mjs",
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
+34 -3
View File
@@ -9,6 +9,18 @@ import { defineConfig, devices } from '@playwright/test'
* /ws to the backend. Tests run serially against one worker so they share the
* single seeded database deterministically.
*/
/**
* Ports. The defaults are what `npm run e2e` has always used. `npm run
* help:media` (e2e/help/run.mjs) sets E2E_WEB_PORT/E2E_API_PORT to a second
* pair so it can record while `npm run dev` keeps serving 5173/3001 to whoever
* is watching. Environment only, no argv sniffing: Playwright's workers load
* this file again with different arguments, and every process must agree.
* vite.config.js honours TREK_DEV_PORT and TREK_DEV_API for the same reason.
*/
const WEB_PORT = Number(process.env.E2E_WEB_PORT) || 5173
const API_PORT = Number(process.env.E2E_API_PORT) || 3001
export const E2E_BASE_URL = `http://localhost:${WEB_PORT}`
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
@@ -19,7 +31,7 @@ export default defineConfig({
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
baseURL: E2E_BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -57,13 +69,31 @@ export default defineConfig({
},
dependencies: ['seed'],
},
// Help-center media (`npm run help:media`): pictures and walkthroughs for
// the in-app guides, generated from the guide definitions in src/help/.
// Same seed as the wiki screenshots; its own match so neither run pays
// for the other. Serial and generous on time: a walkthrough is paced for
// a human to follow.
{
name: 'help-media',
testMatch: /\.guide\.ts/,
timeout: 150_000,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.tmp/state.json',
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: 2,
},
dependencies: ['seed'],
},
],
webServer: [
{
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
port: API_PORT,
env: { E2E_API_PORT: String(API_PORT) },
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
@@ -71,7 +101,8 @@ export default defineConfig({
},
{
command: 'npm run dev',
port: 5173,
port: WEB_PORT,
env: { TREK_DEV_PORT: String(WEB_PORT), TREK_DEV_API: `http://localhost:${API_PORT}` },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More