Compare commits

..

201 Commits

Author SHA1 Message Date
Maurice 9ce33921d2 feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard
Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.

- `dev`: run a plugin locally with a real request loop and hot reload — no full
  TREK. Injects a ctx that enforces the manifest's granted permissions (an
  ungranted call throws, so you catch a missing grant), backs db:own with a real
  SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
  and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
  manifest parity, artifact sha256/size, native scan, README quality gate) so a
  green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
  push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
  author signatures over the artifact bytes, verified 1:1 against the server's
  TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.

24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).
2026-07-04 17:07:15 +02:00
Maurice 648b4aab96 chore(plugin-sdk): make it npm-publishable so npx resolves for authors
The docs told authors to run `npx create-trek-plugin` / `npx trek-plugin`,
but nothing published under those names, so npx couldn't resolve them.

- Ship one package, `trek-plugin-sdk`, with a bin that matches the package
  name (`trek-plugin-sdk`) so `npx trek-plugin-sdk <command>` resolves with
  zero install. The dispatcher gained a `create` subcommand, so every step
  (create/validate/pack/entry/release) runs through that one entry point.
  The short `trek-plugin` / `create-trek-plugin` bins still work once
  installed.
- Package hardening for publish: repository+directory (monorepo subdir),
  homepage/bugs/author/engines, publishConfig public, a prepublishOnly that
  builds + tests, and a LICENSE file.
- A publish workflow: pushing a `plugin-sdk-v*` tag builds and publishes with
  the NPM_TOKEN repo secret.
- Docs (SDK README + the four wiki pages) now use `npx trek-plugin-sdk <cmd>`,
  the invocation that actually resolves.
2026-07-04 14:44:24 +02:00
Maurice ab0a67aa4c docs(plugins): rewrite the plugin wiki against the current code + tooling
The plugin wiki had drifted from the app and the SDK. Rewrote all four
pages, verifying every command, permission, field, path and UI behaviour
against source:

- Plugins: activation is a toggle (no separate consent screen); install is
  the Discover tab (no "Browse plugins" button); you review permissions in
  the detail modal before installing; documents update + re-consent, the ⋯
  menu, toolbar filters, capability chips and the health dot.
- Plugin-Development: full manifest reference; ws:broadcast:trip/:user (there
  is no ws:broadcast:*); onLoad + onUnload; the trek:error bridge message and
  full context payload; trips.* only work in a route handler; asUserId is
  accepted-but-ignored; integration hooks are declared but not yet wired.
- Plugin-Permissions: db:own also covers db.migrate; a host must appear as
  both an http:outbound:<host> permission and an egress[] entry or it's
  silently blocked; bare vs per-host outbound.
- Plugin-Publishing: the new one-command flow (validate → pack → release →
  entry), size is a required entry field, signing reconciled with the schema,
  no reserved namespaces, and the --merge update path.
2026-07-04 14:05:44 +02:00
Maurice 6465ea09f1 feat(plugin-sdk): one-command publishing — pack, entry, release
Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:

- `trek-plugin pack` builds plugin.zip in the exact layout the installer
  reads (own tiny zip writer, so the SDK stays dependency-free and the
  format can't drift from the reader), enforces the same native-binary and
  size rules, and prints the sha256 + size. docs/ is left out — the store
  fetches the screenshot from the repo, so it doesn't belong in the install
  artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
  + the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
  size, and minTrekVersion derived from the manifest's trek range. `--merge`
  prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.

Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.
2026-07-04 13:57:45 +02:00
Maurice d17974d366 fix(plugins): make the detail dialog screenshot fill the full width
aspect-[16/9] together with max-h-64 made the browser shrink the image
width to keep the ratio once the height was capped, leaving a grey strip
on the right. Drop the max-height so the header image spans the dialog.
2026-07-04 13:36:33 +02:00
Maurice e63f2c12e0 feat(plugins): make the plugins admin page work on small screens
The redesign was built desktop-first. On a phone the toolbar wrapped into
a mess and the rows were too cramped. Reworked the responsive behaviour:

- The toolbar stacks on mobile — tabs + rescan on top, full-width search,
  then a right-aligned filter row — and collapses back into one row on
  sm+ (via display:contents), so the desktop layout is unchanged. Filter
  buttons drop their label on mobile and lead with an icon; their menus
  are capped to the viewport width so they never push the page sideways.
- Installed rows use tighter spacing on mobile and the update button
  shrinks to just its icon (full label from sm up).
- Horizontal padding, the discover grid and the detail dialog all get
  mobile-friendly spacing.
2026-07-04 13:00:46 +02:00
Maurice 275698b646 feat(plugins): redesign the admin plugins page — search, filters, cleaner cards
The panel was cramped and hard to scan. Rebuilt it as a proper management
surface:

- A segmented Installed/Discover switch with counts, and a real toolbar:
  search, filter by type, filter by status (active/off/update/error), and
  sort (name/recent/updates first).
- An "N updates available · Update all" bar.
- Installed rows are tidied up: a single health dot on the icon tile
  instead of a wall of badges, and capability chips underneath that show
  what each plugin can actually reach at a glance (reads your trips,
  dashboard widget, the hosts it talks to) — the reach is now visible
  without opening anything. Update, toggle and a ⋯ menu (restart, errors,
  source, uninstall) sit on the right.
- The registry browser is now an App-Store-style card grid: screenshot
  with the plugin's icon chip, a reviewed badge, consistent heights.
- The detail dialog gained "What it can access", "Connects to" and a
  details grid (version, size, requires, reviewed).

To feed the capability chips, the installed list now returns each plugin's
declared permissions and capabilities. New copy is in all 22 locales.
2026-07-04 11:44:32 +02:00
Maurice 96b0e7546e feat(plugins): honest security info in the admin panel + update consent UI
Reworks how the plugins panel talks about safety, since the old copy
oversold it. Drops the "install at your own risk" banner and the generic
trust note, and replaces them with:

- a collapsible security section that lays out plainly how a plugin is
  contained, what the permissions actually mean (a hard limit on what a
  plugin CAN do, not a promise of what it does), where the limits are,
  and what a hostile plugin could do at worst.
- a short note on what "Reviewed" means: a maintainer scanned it for
  malware each version, not for quality — not a guarantee it's harmless.
- the consent dialog for the update flow: when an update asks for rights
  you never granted, it lists the new permissions and outbound hosts and
  makes you approve before the plugin turns back on.

Full copy in all 22 locales.
2026-07-04 11:08:48 +02:00
Maurice 247bb833e3 feat(plugins): re-consent gate when an update wants new permissions
Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.

Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
  but the plugin is left OFF, and the delta is handed back so the admin
  has to approve it before it turns on.

Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.
2026-07-04 11:08:38 +02:00
Maurice 57c676ac37 fix(plugins): close the UDP + DNS egress hole in the network guard
The egress guard only wrapped fetch and net.Socket.connect, so TCP and
HTTP were contained but two channels stayed wide open: a plugin could
send data out over UDP (node:dgram) or tunnel it inside DNS queries
(dns.resolveTxt & friends) to any host it never declared. Neither goes
through net.Socket.connect, so the allowlist never saw them.

Wrap both now against the same declared-host allowlist:
- dgram send/connect: the explicit destination is allowlisted and
  private-IP-checked like a TCP connect (a null address keeps the
  connected/localhost default, which the connect wrapper already vetted).
- the dns resolver family (module fns, dns.promises, Resolver.prototype):
  a forward lookup for an undeclared name is refused, which kills DNS
  tunnelling even when no socket is ever opened.

A plugin with no declared egress now really has no way out.
2026-07-04 11:08:30 +02:00
Maurice 0c960d0264 feat(plugins): zero-config L1 hardening — SSRF egress, RSS reaper, capability audit, no popups
Security that ships from the install itself, no self-hoster setup:

- Egress SSRF/rebinding backstop: the net.Socket connect guard now RESOLVES the
  destination and refuses private/loopback/link-local/metadata/CGNAT/ULA
  addresses, pinning the resolved IP (a declared host that re-resolves to an
  internal address is blocked). Pure policy in egress-policy.ts + tests.
  TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on opts back into internal targets.
- RSS memory reaper: the supervisor now kills a child that blows a real RSS
  ceiling (TREK_PLUGIN_MAX_RSS_MB, default 300) — --max-old-space-size only
  bounds the V8 heap, so Buffers could OOM the box under it.
- Hash-chained capability audit log (migration 159): every core-data / broadcast
  call is recorded at the RPC boundary with the host-bound acting user and a
  per-plugin hash chain, so wide grants stay attributable + tamper-evident.
  Admin endpoint GET /api/admin/plugins/:id/audit.
- Drop allow-popups from the plugin frame (sandbox + CSP): window.open ignores
  connect-src, so it was an egress/phishing bypass.

Server suite 207 green, client + migration reconciliation green.
2026-07-04 01:11:48 +02:00
Maurice 067b3b330d test(plugins): cover pluginRealCodeDir fallback + ensurePluginModuleType 2026-07-04 00:43:02 +02:00
Maurice f377e3842c fix(plugins): make the permission-model child load from the real plugin path
The prod data dir is a symlink (server/data -> volume), so resolving the plugin
under it tripped the permission model, and Node's module-type lookup walked up
into the (denied) data dir. Fork the child from the plugin's realpath and drop a
{"type":"commonjs"} package.json at its root so resolution stops there — trek.db
and the secret files stay unreadable, verified against Koffi.
2026-07-04 00:38:32 +02:00
Maurice 711f6e9881 feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures
Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
2026-07-04 00:31:01 +02:00
Maurice 8fc4d6db71 feat(plugins): denser browse grid + prominent install-risk disclaimer
Four cards per row on desktop with tighter card padding, and a full-width
warning banner above the browse grid: installs are at the admin's own risk,
a prior quick review does not rule out harmful content, inspect a plugin
yourself when in doubt — TREK accepts no responsibility. All 22 locales.
2026-07-03 23:44:39 +02:00
Maurice 600ed1f7fd i18n: localize the plugins admin section across all locales
The admin.plugins block was still English filler in most locales; translate
it everywhere and add the new detail-view keys in all 22 languages.
2026-07-03 23:31:38 +02:00
Maurice 3685742c06 feat(plugins): richer admin panel + registry detail view
Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.

Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.

Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.
2026-07-03 23:31:28 +02:00
mauriceboe 8d1f707557 feat(plugins): hero widget slot + Koffi reference plugin
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.

Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.

Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).
2026-07-03 21:02:28 +02:00
mauriceboe 34ef93771d fix(plugins): backfill enabled for any plugin not explicitly deactivated
status at migration time can be error/stopped/starting after a crash or shutdown,
not just 'active' — so backfill enabled=1 for everything except 'inactive' (the
only status deactivate() sets).
2026-07-03 19:23:58 +02:00
mauriceboe 529c17baf4 feat(plugins): persist enable-intent across restarts + redesign admin page
The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.

Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
  a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
  and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
  browser marks already-installed plugins. i18n across all locales.
2026-07-03 19:19:30 +02:00
mauriceboe 1a7f7a9340 fix(plugins): trips.getById returns the actual trip row, not the access check
canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').
2026-07-03 19:01:23 +02:00
mauriceboe 2d492d4f4e feat(plugins): pass the dashboard's spotlight trip id to widget plugins
Widget plugins now receive the current (spotlight) trip id in their bridge
context, so a widget like Trip Countdown can show a real countdown instead of
the empty state.
2026-07-03 18:52:38 +02:00
mauriceboe 2a24af63f5 fix(plugins): exclude /plugin-frame/ from the service-worker navigate fallback
The PWA service worker's navigateFallback served the SPA shell for any
navigation not on its denylist. /plugin-frame/ wasn't listed, so the SW
intercepted the sandboxed opaque-origin plugin iframe navigation, which Chrome
reports as 'Unsafe attempt to load URL … from frame with URL …'. Denylist it so
plugin frames are served straight from the network.
2026-07-03 18:44:42 +02:00
mauriceboe a4257f3611 fix(plugins): allow inline scripts in the sandboxed frame + fix server lint error
- Plugin frame CSP: the frame runs at an opaque origin (sandbox without
  allow-same-origin), so script-src 'self' matches nothing and the widget's own
  script never runs (stuck on 'Loading…'). Allow 'unsafe-inline' — the sandbox,
  not this directive, is the isolation boundary, and the plugin author controls
  the frame code either way.
- Fix a no-constant-binary-expression eslint error in registry.test.ts that was
  failing the server lint:check (eslint .) in CI.
2026-07-03 18:36:32 +02:00
mauriceboe 69a5367bd0 fix(plugins): allow GitHub's rotating release-asset host in the installer
GitHub 302-redirects release-asset downloads to a rotating *.githubusercontent.com
host (objects / github-releases / release-assets). The SSRF allowlist only had
objects.githubusercontent.com, so installs failed with 'host not allowlisted'.
Allow the whole *.githubusercontent.com suffix (plus github.com/codeload); the
private-IP check remains the SSRF backstop.
2026-07-03 18:28:16 +02:00
mauriceboe 369672f701 docs(plugins): clarify the fork-and-PR publishing flow 2026-07-03 18:20:06 +02:00
mauriceboe edeb097d88 feat(plugins): lifecycle polish — uninstall, error log, egress guard, widget slot (M8)
- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
  plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
  error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
  request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
  host not in the plugin's declared egress[]; with none declared, all outbound is
  blocked. Process-level defense in depth (the container runtime enforces it at
  the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
  registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.

The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.
2026-07-03 18:08:11 +02:00
mauriceboe 0a5c8be83d docs(plugins): plugin wiki + reference plugin (M7)
- Wiki pages (sync to the GitHub wiki on push to main): Plugins overview + trust
  model, Plugin Development (SDK, definePlugin, ctx, routes/jobs, the client
  bridge, testing with the mock host), Plugin Permissions reference, and
  Publishing (registry PR + CI gates + provenance). Linked from the sidebar.
- Reference plugin plugin-sdk/examples/trip-countdown: a complete, minimal-
  permission widget (reads trip data through ctx, renders in the sandboxed
  iframe via the bridge, filled-in README). Validated in the SDK test suite so it
  passes the exact gate authors face.
2026-07-03 17:55:07 +02:00
mauriceboe 7318ee0d42 feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)
The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).

- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
  PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
  injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
  SAME permission model + membership checks, so authors can unit-test that their
  plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
  predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
  and trek-plugin validate (manifest + README sanity).

Consolidating the server loader to import this shared validator is a follow-up.
2026-07-03 17:44:55 +02:00
mauriceboe c98bf7977d feat(plugins): TREK-side registry — browse + one-click install (M5)
Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.

- registry.service: fetches the single aggregated dist/index.json (never
  per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
  soft-fails to a stale/empty registry, and installs a pinned version through
  the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
  manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
  recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
  wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
  POST /api/admin/plugins/install { id, version }. Install never executes code;
  activation stays a separate, deliberate step.
2026-07-03 17:38:10 +02:00
mauriceboe be07f29acd feat(plugins): secure installer — manifest, discovery, safe extract/fetch/scan (M4)
Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.

- manifest.ts: strict trek-plugin.json validation (id/version/type, known
  permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
  rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
  downgrades or wipes an already-installed plugin's status / grants / config.
  Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
  them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
  - safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
    redirect following + size cap + sha256 (constant-time compare).
  - safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
    readers; rejects traversal, absolute paths, symlinks, oversized/too-many
    entries, and unsupported formats.
  - native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.
2026-07-03 17:26:46 +02:00
mauriceboe 037eb8a209 feat(plugins): sandboxed page/widget frames + trekBridge (M3)
Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.

- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
  guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
  to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
  so the frame can't read the session cookie or the parent DOM). Global CSP
  frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
  inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
  or origin; pushes context (theme/locale/tripId/userId), validates navigation,
  renders notifications as text, resizes widgets, and proxies trek:invoke to the
  plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.

Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
2026-07-03 17:12:56 +02:00
mauriceboe d823ac443b feat(plugins): activation, HTTP route proxy + instance settings (M2)
Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.

- PluginRuntimeService owns the supervisor: activate spawns the child with its
  granted permissions + decrypted instance config, deactivate kills it, status
  and errors are persisted to the plugins / plugin_error_log tables, and active
  plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
  reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
  plugin's declared routes, enforces per-route auth (auth:false routes are public
  for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
  the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
  instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.

Photo/calendar hook consumers are deferred to a later slice.
2026-07-03 16:50:23 +02:00
mauriceboe 14b348581a feat(plugins): isolated per-plugin runtime + capability RPC (M1)
Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.

- protocol/envelope: the wire types + method→permission map (pure, shared by
  host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
  BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
  ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
  ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
  graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution

Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.
2026-07-03 16:18:55 +02:00
mauriceboe 252eae368d feat(plugins): plugin system scaffold — registry tables + admin panel
First slice of the plugin system. Lays down the data model and a read-only
admin surface; nothing executes yet.

- Migration 155: plugins, plugin_meta_migrations, plugin_error_log and
  plugin_settings_fields tables. Plugin data will live in a per-plugin sqlite
  file under /plugins-data, never in these tables.
- New Nest module server/src/nest/plugins with GET /api/admin/plugins
  (admin-gated, returns the installed list + the runtime-enabled flag).
- TREK_PLUGINS_ENABLED kill switch (config.pluginsEnabled), off by default.
- Admin → Plugins tab with a read-only panel: installed list, status badges,
  and a clear banner when the runtime is disabled by server config.
- i18n keys for the tab and panel across all locales.

Install, activation, the isolated runtime and the registry browser follow in
later slices.
2026-07-03 15:27:11 +02:00
Maurice 452360683c fix(planner): wrap-safe mobile itinerary text — platform below the stop, minutes-first walks 2026-07-03 15:06:31 +02:00
Maurice 132ddcad16 fix(planner): tighter mobile transit search + vertical journey itinerary 2026-07-03 15:06:31 +02:00
Maurice 2b0815be9f fix(planner): mobile layouts for the transit popups
The journey modal and the transit search now lay out properly on phones:
from/to stack vertically with the swap rotated between them, the ranking
segment and search button go full width, the day picker in the automated
header spans the row, the three stat tiles compress to centred mini tiles,
the itinerary tightens its gutters, and the footer wraps with an icon-only
delete. Desktop is unchanged.
2026-07-03 15:06:31 +02:00
Maurice f4dbe02702 style(settings): TREK-styled text-size sliders
The appearance tab's native range inputs become proper TREK sliders: a thin
pill track filled up to the current value in the accent color, with a soft
round thumb that scales slightly on hover/drag.
2026-07-03 15:06:31 +02:00
Maurice 8ffdb9e153 fix(settings): booking route labels default to off
The map endpoint labels only render when the user explicitly enables them;
an unset preference now means hidden, matching the calmer default the
transit paths brought to the map.
2026-07-03 15:06:31 +02:00
Maurice 440bb8497f fix(map): transit polish — earlier label collapse, route-toggle coupling, md note preview
Station badges on transit journeys collapse to icon dots much earlier when
zooming out (label threshold 900px instead of 400). The drawn transit paths
now ride the day-route toggle: turning the route off hides them too, since
they are part of the computed route. The journey card's note preview renders
its first line as inline markdown instead of raw asterisks.
2026-07-03 15:06:31 +02:00
Maurice fab288a889 fix(planner): elegant walk-divider hairlines + proven notes preview
The walk dividers switch from dashed borders to 1px hairlines that fade
towards the outer edges — strongest next to the walk text. A regression
test pins the journey modal opening existing notes on the rendered
markdown preview rather than the raw text.
2026-07-03 15:06:31 +02:00
Maurice 5b9723cf8d feat(map): draw transit journeys along their real rail and bus alignments
MOTIS leg geometry (encoded polylines) now travels through the proxy and is
stored per leg, so both map renderers draw the journey along the actual
tracks instead of a straight line: colored cores in each line's GTFS color
over a white casing, walks as dotted grey connectors. Transit journeys are
always visible on the map — they are part of the plan itself, not an opt-in
overlay — and the day route already anchors to their stations, so the
journey slots into the route computation end to end. Entries saved before
this keep the straight-line fallback.

Also: stronger dashes on the walk dividers, notes open rendered (preview
tab) when present, and MOTIS's START/END placeholders are replaced with the
places the user actually picked.
2026-07-03 15:06:31 +02:00
Maurice 9a823c0bc2 feat(planner): walk legs as centred dividers + journey-card note line
Walk segments in the journey modal and the day-plan inline itinerary
collapse from two lines into a single centred divider — dashed rules left
and right, the walk in the middle (foot icon, destination, minutes). Leg
meta badges sit tighter under their titles. The Transports-tab journey card
shows a dimmed first-line note preview, and the journey modal now reads the
reservation from the live store, so an update is visible the moment the
entry reopens.
2026-07-03 15:06:31 +02:00
Maurice c1eaa91e50 feat(planner): badge meta rows + inline itinerary expansion for transit
Dot-joined meta text becomes quiet badge chips everywhere transit facts are
listed: the journey modal's per-leg line (time, duration, stops, headsign
with an arrow icon, operator de-emphasised), the search results' leg details,
and the Transports-tab journey card (day, date, time span, duration — the
transfer count is gone from the card).

The day-plan transit row swaps the map-connections toggle for an expander:
the chevron folds the stop-by-stop itinerary out right inside the timeline —
times, line badges, stations with platform and stop counts — sized for the
sidebar.
2026-07-03 15:06:31 +02:00
Maurice c7998e926a feat(planner): markdown toolbar for journey notes + calmer transit search form
The journey notes gain a proper markdown toolbar (bold, italic, strike,
heading, list, checklist, link, code) that wraps the selection or prefixes
the current lines. The transit search options settle into one calm card:
depart/arrive + time + date and the ranking preference share the top row,
the mode filters and the search button share the bottom row, with the mode
chips restyled from heavy filled pills to quiet toggles. The day-plan row
drops the transfer count — the leg chips already tell the story.
2026-07-03 15:06:31 +02:00
Maurice 04415be3bc test(nav): the bottom-nav add button is labelled Transport now 2026-07-03 15:06:31 +02:00
Maurice 06b94c14a5 feat(planner): polish the transit journey UI and fix its tab placement
Transit entries were classified as bookings by the planner's transport-type
list and landed in the Bookings tab — they now sit in the Transports tab's
own section, rendered as proper journey cards (tram icon, arrow title, leg
chips, journey stats) instead of the generic booking card.

The journey modal got a redesign: the title renames inline in the header
with an icon arrow, the stats become three full-width tiles (duration /
transfers / walking, each with an icon), status and booking-code fields are
gone, and notes take the full width with a markdown write/preview toggle.
The Automated search mode gains a proper header (icon, hint, day picker)
and the day-plan row now shows walks with their minutes inside the chip
sequence (🚶 3 › U2 › 🚶 3) instead of a detached direct/walk summary.
"A → B" titles render with an arrow icon everywhere. The Transports tab's
add button is simply "Transport" now that the dialog covers both modes.
2026-07-03 15:06:31 +02:00
Maurice 5a007a1f0e feat(planner): integrate transit into the transport system as Automated mode
The add-transport dialog gains a Manual/Automated switch: Automated embeds the
public-transit search (day picker + from/to + modes + preferences + results)
right in the dialog, and the day header's tram button opens it directly in
that mode. The standalone search modal is gone.

Saved journeys get their own roomy journey view — the stop-by-stop itinerary
(times, platforms, lines, headsigns, operators) together with the editable
booking fields, delete, and a "Change route" action that re-runs the search
pre-seeded with the journey's origin/destination and replaces the itinerary on
save. The generic transport form no longer opens for transit entries, from the
timeline or from the Transports tab, where journeys now sit in their own
"Automated public transit" section with their line badges on the card.

New i18n keys in every locale; wiki updated.
2026-07-03 15:06:31 +02:00
Maurice 3c87d37cd9 feat(planner): make transit journeys first-class entries (#1065)
A saved transit route is now its own reservation type instead of piggybacking
on train/bus: it gets a tram icon and its own color everywhere, and the day
timeline renders the itinerary inline — line badges in their official colors
with walk segments, plus the transfer count and walking time — instead of a
generic transport row.

Clicking the row opens the itinerary view (journey summary, stop-by-stop legs
with times, platforms, headsigns and operators) rather than the edit form;
editing stays reachable from an Edit action inside that view. The transit
type is registered across the timeline merge, transport modal, reservations
panel, file manager, map overlays and detail panels, with a translated type
label in every locale.
2026-07-03 15:06:31 +02:00
Maurice f2304e076b test(maps): add buildUserAgent to the mapsService mock
transitService imports it at module load, and the full-app integration boot
now pulls the transit module in — the factory mock lacked the export.
2026-07-03 15:06:31 +02:00
Maurice 216d5e99e1 fix(build): declare tz-lookup as a client dependency
It was present in the lockfile but undeclared, so the local install had it
while the Docker client build (npm ci --workspace=client) did not.
2026-07-03 15:06:31 +02:00
Maurice 45f1ff7799 feat(planner): public transit routing via Transitous (#1065)
Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.

Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.

The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.

Discussion #1065
2026-07-03 15:06:31 +02:00
Maurice 268ed419b2 fix(admin): use TREK's CustomSelect for the invite trip picker
The "add to trip" dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(searchable once there are many trips).
2026-07-02 15:52:53 +02:00
Maurice bd2d91845b feat(costs): filter expenses by category and by a single day
Adds two filter dropdowns next to the Search Expenses field (height-matched
to it): one filters by expense category, the other narrows to a single day.
Selecting a day shows a prominent summary banner with that day's total, and
hides the now-redundant per-day header. Both filters work on the desktop and
mobile layouts and compose with the existing search + all/mine/owed filters.

New i18n keys (costs.filter.allCategories / allDays, costs.expensesCount)
across every locale.
2026-07-02 15:52:53 +02:00
Maurice 8eb5e19fea fix(join): extract JoinTripPage state into a useJoinTrip hook
The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.
2026-07-02 15:18:04 +02:00
Maurice 5f77fa5cca feat(trips): trip invite links + optional trip binding on admin invites
Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.

Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.

Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.

Discussion #1143
2026-07-02 15:18:04 +02:00
Maurice bfc857d246 feat(oidc): use the picture claim as avatar when none is uploaded
When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399
2026-07-02 11:22:40 +02:00
Maurice 05269f0710 feat(planner): show a day's route distances inline on mobile
Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.

The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.

Discussion #1374
2026-07-02 10:39:10 +02:00
Maurice 5152a5849f feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap" 2026-07-01 21:39:44 +02:00
Maurice 1fdc19712f feat(planner): add an "Open in OpenStreetMap" button to the place inspector
Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.

Strings across all 22 locales; a unit test for the URL builder.
2026-07-01 21:39:44 +02:00
Maurice a83ecc4ffb fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)
- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
2026-07-01 21:16:19 +02:00
jubnl 60cbd20327 fix: back-merge v3.1.4 hotfixes into dev (#1371)
Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)

Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.
2026-07-01 20:23:31 +02:00
mauriceboe 3686792ce8 chore(i18n): backfill datepicker keys for sv + vi locales added on dev 2026-07-01 18:27:36 +02:00
Gio Cettuzzi 2af292bd64 fix(date-picker): add missing locale file and fix let-to-const lint error
- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
  to satisfy lint rules
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 33fcbba414 fix(date-picker): locale-aware keyboard input parsing and i18n control labels
- Replace fixed-order date parser with locale-aware implementation
  using Intl.DateTimeFormat.formatToParts to detect field order;
  adds swap fallback for unambiguous inputs (day > 12) to handle
  locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
  (e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
  calls; add new keys under common.datepicker.* namespace across
  all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
  to avoid locale-dependent test failures
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 3011cf6c2f feat(date-picker): add month/year drill-down navigation and keyboard input trigger
- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger
2026-07-01 18:27:36 +02:00
Maurice 7d87d87eec docs(wiki): add Collections addon page (#1081)
New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.
2026-07-01 18:02:44 +02:00
Maurice 8014264b21 test(collections): client component tests + select in All saved, off the map (#1081)
- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).
2026-07-01 18:02:44 +02:00
Maurice baf156a36b style(collections): widen the share modal (#1081) 2026-07-01 18:02:44 +02:00
Maurice 59134ae38e style(collections): custom dropdown for the permission role pickers (#1081)
Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.
2026-07-01 18:02:44 +02:00
Maurice 6cdcc82d9c feat(collections): bulk-add selected trip places to a list (#1081)
Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
2026-07-01 18:02:44 +02:00
Maurice 1e0feb93dd feat(collections): per-member permission roles on shared lists (#1081)
The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
2026-07-01 18:02:44 +02:00
Maurice f2972481fe fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)
From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
  drops the growing spacer) and clears the home indicator (safe-area padding,
  84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
  buttons, detail close/actions, drawer rail rows, and the interactive status badge
  (enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
  stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
  filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
  status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.
2026-07-01 18:02:44 +02:00
Maurice b9da2494d4 test(collections): unit-test the nest controller (branch coverage) (#1081)
The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.
2026-07-01 18:02:44 +02:00
Maurice fd5258129a fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)
- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
  panel instead of stopping half-way, and add list + status filter dropdowns
  (filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
  lists from the store, but on first load those aren't fetched yet (loadAll still
  running). Load them first when empty so the union isn't blank.
2026-07-01 18:02:44 +02:00
Maurice 56fee19d64 style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)
The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.
2026-07-01 18:02:44 +02:00
Maurice 6be8a67c82 feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)
- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 3198fd35eb fix(collections): white screen when editing a place (undefined in places) (#1081)
updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.

- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
  a stray undefined entry so a single bad row can never white-screen the page.
2026-07-01 18:02:44 +02:00
Maurice ef3f043e00 fix(collections): stop the address pin from shrinking on long addresses (#1081)
The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.
2026-07-01 18:02:44 +02:00
Maurice 6a3e6ae083 fix(collections): copy-to-trip labels + Unsplash cover search (#1081)
- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.
2026-07-01 18:02:44 +02:00
Maurice b8ceb081fa feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)
- Editing a place (category, status, …) no longer reloads the view: the mutating
  client's own socket is now excluded from the WS broadcast (x-socket-id threaded
  through save/update/status/delete + list update/cover), so the optimistic update
  stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
  place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
  button beside it; editing/deleting a list now happens there. The list rail drops
  its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.
2026-07-01 18:02:44 +02:00
Maurice 0c484959ce feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)
- Map: markers no longer rebuild on every unrelated re-render (memoised the
  mappable list + only update the hero size when it really changes), the floating
  controls bar is click-through except its buttons, and the collection map runs
  with the hover tooltip off. Together these stop a marker click from landing on
  a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
  above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
  category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
  in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
  instead of their own line.
2026-07-01 18:02:44 +02:00
Maurice f97e284592 feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
  status segment → description → links) with a proper footer action bar — the
  loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
  and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
  same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
  an inset ring and only clip during the map-collapse animation; a picked row
  now scrolls into view above the detail sheet.
- New category strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 0910c90e7f fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)
- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.
2026-07-01 18:02:44 +02:00
Maurice de4077d461 fix(collections): review follow-ups on the B–E work (#1081)
- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.
2026-07-01 18:02:44 +02:00
Maurice 68e101ed37 feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)
Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.
2026-07-01 18:02:44 +02:00
Maurice 25b8d28574 feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)
- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.
2026-07-01 18:02:44 +02:00
Maurice 74cace7c8f feat(collections): list+map split, taller rail, list-menu popover fix (#1081)
- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.
2026-07-01 18:02:44 +02:00
Maurice 4e478722c3 feat(collections): redesign the page on the dashboard glass language (#1081)
Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.
2026-07-01 18:02:44 +02:00
Maurice 5f9da5b72f feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)
- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).
2026-07-01 18:02:44 +02:00
Maurice 783332cc0e feat(collections): /collections page, entry points and i18n (#1081)
Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
  addon: a multi-list rail, a Grid (default) / List / Map view switch, the
  idea/want/visited status with a one-tap badge, search and status filters, and
  considered empty states. Store + hook + model + websocket wiring; the place
  detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
  place inspector (and the two sidebar context menus), a "Copy to trip" modal,
  and a desktop-only two-column add-place picker (mobile keeps the single-column
  form).
- The collection namespace and the new keys across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice ec8509f2de feat(collections): backend for the Overall Places addon (#1081)
Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 37ef3a9d18 fix(map): match the GL place hover tooltip to the Leaflet map (#1385)
The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.
2026-06-30 19:34:07 +02:00
Maurice a54503f26d feat(map): group GL place markers into clusters on zoom-out (#1385)
MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.
2026-06-30 19:12:43 +02:00
Maurice 4abb38b517 style(packing): small gap between the list and the luggage sidebar divider
The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.
2026-06-30 19:12:43 +02:00
Maurice 743b724cbc feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)
Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
2026-06-30 19:12:43 +02:00
Maurice 04f2ec72c6 feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
2026-06-30 15:03:57 +02:00
Maurice 7673aa52f2 fix(packing): drop the always-true guard in the row drag handler (#969)
The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.
2026-06-30 15:03:57 +02:00
Maurice e56a901d82 i18n: translate the booking link field across all locales (#935)
Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.
2026-06-30 15:03:57 +02:00
Maurice 641711322e feat(trips): transfer trip ownership to a member (#973)
Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.
2026-06-30 15:03:57 +02:00
Maurice fac2393388 feat(lists): reorder packing/to-do lists and private packing items (#969, #858)
Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
2026-06-30 15:03:57 +02:00
Maurice 7eac5a5a02 feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
2026-06-30 15:03:57 +02:00
Maurice d6ed7a60a0 feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
2026-06-30 15:03:57 +02:00
Maurice ad64df42ed test(video): cover the new upload-handler branches
Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.
2026-06-30 12:27:28 +02:00
Maurice 4af35b162e test(video): update gallery accept selector + complete fileService mocks
The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.
2026-06-30 12:27:28 +02:00
Maurice 20c1858b23 fix(video): harden upload handling and fix video playback edge cases
Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.
2026-06-30 12:27:28 +02:00
Maurice e986c9ab27 feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
2026-06-30 12:27:28 +02:00
Maurice 61ffdb553e test(photos): assert the forwarded Range arg on the original stream
Follow-up to the Range-aware photo proxy.
2026-06-30 12:27:28 +02:00
Maurice 1abc9b2bc7 feat(video): link and stream Immich videos in the journey gallery
Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.
2026-06-30 12:27:28 +02:00
Maurice 8713443665 feat(video): use Plyr for the gallery video player
Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.
2026-06-30 12:27:28 +02:00
Maurice c92c02e1b8 feat(video): play local gallery videos in the journey gallery
Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.
2026-06-30 12:27:28 +02:00
Maurice 993d9bf713 feat(video): media_type discriminator + local gallery video upload (server)
trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
2026-06-30 12:27:28 +02:00
Maurice c7e4b2781b docs(wiki): document force-offline, selective storage and conflicts 2026-06-30 10:04:15 +02:00
Maurice a88cd772cf i18n(offline): offline settings strings across all locales 2026-06-30 10:04:15 +02:00
Maurice 98d11d4267 feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
2026-06-30 10:04:15 +02:00
Maurice 6707dac4a9 feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
2026-06-30 10:04:15 +02:00
Maurice c552472b63 feat(offline): detect update conflicts on the server for places and packing
Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.
2026-06-30 10:04:15 +02:00
Maurice 5fd66f4833 feat(map): include the day's route in the map fit (#1128)
Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.
2026-06-30 00:04:38 +02:00
Maurice 50609b078a feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.
2026-06-29 23:19:33 +02:00
Maurice 42b45dcd82 feat(dashboard): show the year on trip dates from other years
Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").
2026-06-29 22:29:57 +02:00
Maurice 9dd9057b7b feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
2026-06-29 22:29:57 +02:00
Maurice 23987c76bb harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
2026-06-29 21:53:06 +02:00
michael-bohr 7173e82fe8 feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:06 +02:00
Maurice 72dfa2c60c docs(helm): clean up existingClaim notes
Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.
2026-06-29 21:00:25 +02:00
yael-tramier d19305bda4 fix(helm): emptyDir is used as a fallback when persistence is disabled. 2026-06-29 21:00:25 +02:00
yael-tramier 7aa2f6e4f2 feat(helm): Add existingClaim variable for custom PVC usage. 2026-06-29 21:00:25 +02:00
Maurice 3e64cb86a6 chore(i18n): sync Vietnamese with latest dev keys
Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.
2026-06-29 20:48:51 +02:00
leeduc e4efcf0840 feat(i18n): add Vietnamese translations 2026-06-29 20:48:51 +02:00
Zorth Thorch e34f40b686 feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
2026-06-29 20:34:24 +02:00
Maurice 3701ab6cad feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
2026-06-29 18:32:58 +02:00
Maurice e91f592f22 feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
2026-06-29 18:32:58 +02:00
Maurice 1cc69fc22a refactor(admin): group the admin sidebar tabs into sections
The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.
2026-06-29 13:59:00 +02:00
Maurice 4d131db9af refactor(settings): rename the Display tab to General and group its settings
The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.
2026-06-29 13:59:00 +02:00
Maurice f5d03e7213 chore(about): remove the monthly supporters section 2026-06-29 13:59:00 +02:00
Maurice 891171ce6c feat(appearance): mark the Readability section as experimental
Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.
2026-06-29 13:59:00 +02:00
Maurice 720edce2ee fix(appearance): make the dashboard hero boarding-pass solid with transparency off 2026-06-29 13:59:00 +02:00
Maurice b27793f99a fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile 2026-06-29 13:59:00 +02:00
Maurice 813db0ca6e feat(appearance): show per-size text controls inline with examples
The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).
2026-06-29 13:59:00 +02:00
Maurice 741639edf0 feat(appearance): granular per-size text scaling with live preview
The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.
2026-06-29 13:59:00 +02:00
Maurice bb8f4d4e5e fix(appearance): keep i18n key parity and update the scaled-emoji test
Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.
2026-06-29 13:59:00 +02:00
Maurice fac043c691 fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off
Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.
2026-06-29 13:59:00 +02:00
Maurice a3f395e5ac fix(appearance): scale inline px font sizes so text-size reaches all content
The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.
2026-06-29 13:59:00 +02:00
Maurice b6a414b79f chore(appearance): add theme:lint guard for hardcoded styles
A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.
2026-06-29 13:59:00 +02:00
Maurice 200108b76a feat(dashboard): per-device widget visibility with layout reflow
Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.
2026-06-29 13:59:00 +02:00
Maurice a7334a9060 feat(settings): appearance settings tab
New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.
2026-06-29 13:59:00 +02:00
Maurice 2cda779bc5 feat(appearance): token-driven theme engine with schemes and FOUC-safe boot
applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.
2026-06-29 13:59:00 +02:00
Maurice 4742915389 feat(appearance): add per-user appearance config contract
Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.
2026-06-29 13:59:00 +02:00
Maurice d6bba454e0 fix(llm): stop the browser autofilling the LLM base URL (#1301)
The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.
2026-06-28 21:38:46 +02:00
Maurice 6f42e84183 fix(docker): keep server/reset-admin.js in the build context (#1339)
The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.
2026-06-28 21:11:54 +02:00
Maurice cb3f9f0021 test(trips): cover the Unsplash cover download and search-race guard (#1277)
Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.
2026-06-28 20:21:13 +02:00
Maurice f24d44b4a3 feat(trips): download chosen Unsplash covers into uploads (#1277)
Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.
2026-06-28 20:21:13 +02:00
Azalea af90ba0911 [+] i18n 2026-06-28 20:21:13 +02:00
Azalea 8c941b52f9 [+] Unsplash 2026-06-28 20:21:13 +02:00
Maurice c7e8a5614d feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)
On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.

Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.

Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).

Implements mauriceboe/TREK#1349
2026-06-28 16:26:16 +02:00
Maurice c10b9cc202 fix(map): keep the mobile GPS button above the day-detail panel (#1348)
On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.

DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:

  hasDayDetail
    ? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
    : calc(var(--bottom-nav-h) + 12px)

Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.

Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).
2026-06-28 14:54:31 +02:00
Maurice d1e024277f fix(pwa): stop unregistering the service worker on offline boot (#1346)
Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.

Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.

Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.

Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).
2026-06-28 12:48:27 +02:00
Maurice 172cff57a2 fix(airtrail): import departure/arrival times for manually-entered flights (#1336)
The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.

AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).

Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).
2026-06-28 12:12:48 +02:00
jufy111 0d6737726d Added focus to search places in placeFormModal 2026-06-28 11:53:42 +02:00
Maurice 6996a67670 fix(extract): don't let the day-clamp fallback break reservation resync (#1288)
This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).

Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).
2026-06-28 11:53:19 +02:00
Maurice 84adc28684 fix(i18n): add Swedish translations for the AI booking-import settings
The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.
2026-06-28 11:53:19 +02:00
Maurice f206fa6dff test(setup): stub websocket addListener/removeListener in the global mock
BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)
2026-06-28 11:53:19 +02:00
Maurice c3b3c278b8 test(llm-parse): cover the extraction router, client factory and import jobs
The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).
2026-06-28 11:53:19 +02:00
Maurice d09a62fcc8 refactor(planner): move the import-review bridge effect into the page hook
TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.
2026-06-28 11:53:19 +02:00
Maurice f4b2143a59 feat(settings): use the shared custom dropdown for the AI parsing provider
Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).
2026-06-28 11:53:19 +02:00
Maurice 33f554b1bf fix(settings): show the Integrations tab when only AI parsing is enabled
hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.
2026-06-28 11:53:19 +02:00
Maurice fc1f29bb29 feat(settings): let users set their own AI parsing model
Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.
2026-06-28 11:53:19 +02:00
Maurice 01e5859564 chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)
Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.
2026-06-28 11:53:19 +02:00
Maurice 6a70f4fc41 fix(import): persist source files in IndexedDB so attach survives a reload
The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.
2026-06-28 11:53:19 +02:00
Maurice 27fbc241e8 fix(import): preview the parsed cost as linked in the review modal
During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.
2026-06-28 11:53:19 +02:00
Maurice 574c54c16c perf(extract): cap single-booking text tighter; require rental company
A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.
2026-06-28 11:53:19 +02:00
Maurice 0cb0567d28 fix(import): refresh costs immediately after an imported booking is saved
The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.
2026-06-28 11:53:19 +02:00
Maurice 76447f4a73 fix(extract): require the hotel address and ask for the rental company
After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.
2026-06-28 11:53:19 +02:00
Maurice 55ff5c03dd refactor(extract): drop vendor templates, let the model drive with deterministic backfill
Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.
2026-06-28 11:53:19 +02:00
Maurice 3277965426 feat(extract): recommend Qwen3-8B as the local extraction model
A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.
2026-06-28 11:53:19 +02:00
Maurice d95d26e493 fix(extract): disable model thinking for grammar-constrained extraction
Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.
2026-06-28 11:53:19 +02:00
Maurice 4abe96fe01 feat(import): attach the parsed source document to each booking
Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.
2026-06-28 11:53:19 +02:00
Maurice 7bac753ff3 refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers
Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.
2026-06-28 11:53:19 +02:00
Maurice 743397994e fix(import): refresh costs after a booking review so imported expenses appear without a reload
Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.
2026-06-28 11:53:19 +02:00
Maurice 459426ed43 fix(import): resolve an imported transport's day from its parsed dates
A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.
2026-06-28 11:53:19 +02:00
Maurice b3fa87bdd6 fix(reservations): skip un-geocoded endpoints instead of failing the save
reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.
2026-06-28 11:53:19 +02:00
Maurice 519dc3b0d8 fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
2026-06-28 11:53:19 +02:00
Maurice c1d61c98f0 fix(extract): backfill booking code/total and harden the reference match
Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.
2026-06-28 11:53:19 +02:00
Maurice c7f5694f63 feat(extract): add Expedia and rental-broker booking templates
Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.
2026-06-28 11:53:19 +02:00
Maurice d0b4052c5d fix(import): create linked costs and accommodations from reviewed bookings
Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.
2026-06-28 11:53:19 +02:00
Maurice 1c81e8b959 feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
2026-06-28 11:53:19 +02:00
Maurice 8f1c99a07a feat(extract): drive local parsing through a layered extraction router
The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.
2026-06-28 11:53:19 +02:00
Maurice 5fdd4aa153 feat(import): review each parsed booking before it's saved
Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.
2026-06-28 11:53:19 +02:00
Maurice 22801938b5 fix(admin): tidy the AI parsing settings and recommend the 2B model
The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.
2026-06-28 11:53:19 +02:00
Maurice 8640100312 feat(extract): drive NuExtract with its native template
NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.
2026-06-28 11:53:19 +02:00
Maurice e666313865 fix(extract): refresh accommodations after a booking import
A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.
2026-06-28 11:53:19 +02:00
Maurice aa72d527c9 feat(extract): create a linked cost from the booking price on import
When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.
2026-06-28 11:53:19 +02:00
Maurice 684ac3b442 feat(extract): capture seat, class, platform, price + event venue contact
Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.
2026-06-28 11:53:19 +02:00
Maurice f049229e25 perf(extract): cap LLM input at 4000 chars for CPU-only speed
On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.
2026-06-28 11:53:19 +02:00
Maurice 38565c3c6d feat(extract): fill transport/booking fields, geocode endpoints, assign days
- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.
2026-06-28 11:53:19 +02:00
Maurice a1cbc11169 fix(extract): make AI imports reliable and fast on local models
client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.
2026-06-28 11:53:19 +02:00
Maurice b859ae8b00 fix(extract): auto-run the AI fallback when the addon is enabled
Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.
2026-06-28 11:53:19 +02:00
jubnl ae14a6c860 feat(extract): extract data using LLM 2026-06-28 11:53:19 +02:00
Maurice 41c541828f fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin
The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.
2026-06-28 11:10:40 +02:00
Maurice 37f1fff367 Merge main into dev after the v3.1.3 release 2026-06-28 10:59:53 +02:00
Maurice 0c1c534435 docs(wiki): document the snap Docker + no-new-privileges startup failure 2026-06-28 10:30:37 +02:00
8717 changed files with 129614 additions and 1339371 deletions
+1 -2
View File
@@ -15,8 +15,6 @@ uploads
chart/
docs/
docker-compose.yml
docker-compose.minio-test.yml
minio-licence.key
unraid-template.xml
*.sqlite
*.sqlite-shm
@@ -34,5 +32,6 @@ server/tests/
server/vitest.config.ts
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
-6
View File
@@ -1,6 +0,0 @@
# Commits that only reformat code. git blame skips them with
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub reads this file on its own).
# style: format server and shared with prettier
724c88c201b2c16d31317eaea5f59f43d314b833
-3
View File
@@ -15,7 +15,6 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
@@ -26,5 +25,3 @@
*.eot binary
*.pdf binary
*.zip binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+2 -2
View File
@@ -8,11 +8,11 @@ body:
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/liketrek/TREK/wiki
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/liketrek/TREK/discussions
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead
+2 -2
View File
@@ -13,8 +13,8 @@
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
-71
View File
@@ -1,71 +0,0 @@
# Version updates, weekly, into dev. Minor and patch bumps of one ecosystem
# arrive as a single grouped PR; a major bump gets a PR of its own so its
# breaking notes are read on their own.
#
# Security updates are a repository setting, not part of this file, and
# GitHub opens them against the default branch whatever target-branch says.
# enforce-target-branch.yml moves those onto dev.
#
# Every PR runs test.yml like any other, so ci-ok decides whether it can
# merge. Dependabot runs get no Actions secrets, so s3-contract and the Sonar
# scan skip themselves on these PRs and ci-ok counts the skip as passed.
version: 2
updates:
# The root lockfile covers the client, server and shared workspaces.
- package-ecosystem: npm
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
prefix-development: chore(deps-dev)
groups:
npm-minor-and-patch:
update-types: [minor, patch]
# plugin-sdk is standalone with a lockfile of its own and ships to npm.
- package-ecosystem: npm
directory: /plugin-sdk
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(plugin-sdk)
prefix-development: chore(plugin-sdk)
groups:
plugin-sdk-minor-and-patch:
update-types: [minor, patch]
# Keeps the SHA pins of third-party actions current, comment included.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: ci
groups:
actions-minor-and-patch:
update-types: [minor, patch]
# The Dockerfile's base images. The Node major follows .nvmrc and the
# engines ranges, so it moves by hand, together with them.
- package-ecosystem: docker
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(docker)
ignore:
- dependency-name: node
update-types: [version-update:semver-major]
@@ -9,12 +9,10 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const badTitles = [
@@ -10,12 +10,10 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
+3 -5
View File
@@ -9,12 +9,10 @@ permissions:
jobs:
check-title:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
@@ -78,7 +76,7 @@ jobs:
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
+23 -81
View File
@@ -1,11 +1,5 @@
name: Build & Push Docker Image (Prerelease)
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
on:
workflow_dispatch:
inputs:
@@ -17,7 +11,6 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: prerelease-build
@@ -25,42 +18,23 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being built
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
- name: Determine prerelease version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -68,12 +42,14 @@ jobs:
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
@@ -86,47 +62,38 @@ jobs:
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Image metadata
id: meta
run: |
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Build and push by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
@@ -134,9 +101,6 @@ jobs:
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
- name: Export digest
run: |
@@ -145,7 +109,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -153,27 +117,25 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-24.04
timeout-minutes: 120
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
merge-multiple: true
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -181,30 +143,10 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
# The labels live on each platform image; the index gets the ones a
# client reads before it picks a platform (#1498).
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
--annotation "index:org.opencontainers.image.version=$VERSION" \
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
@@ -227,7 +169,7 @@ jobs:
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="${VERSION%%-pre.*}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
+63 -150
View File
@@ -1,24 +1,20 @@
name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
description: 'Force bump line (auto = patch/finalize as today)'
type: choice
options: [auto, patch, minor, major]
default: auto
@@ -29,7 +25,6 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: stable-build
@@ -37,66 +32,33 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 120
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- name: Release from main only
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being released
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags, no package.json dependency
# Derive version from git tags — no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
@@ -109,96 +71,79 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto finalizes the in-flight prerelease and nothing else. It used
# to fall back to a patch, which would have shipped any change,
# however big, as a patch nobody chose.
PRE_BASE=""
# auto: finalize in-flight prerelease if one exists, else patch
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
exit 1
fi
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump, it would bypass confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
fi
# Patch releases have carried migrations before, so this only warns:
# whoever pinned :MAJOR.MINOR gets this schema change too.
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
fi
echo "Bump type: $BUMP"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag. The push fast-forwards main from the commit ci-ok
# was checked on, so it fails rather than release past anything that
# landed on main in the meantime.
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin HEAD:main --follow-tags
# build, merge and release-helm check out exactly this commit.
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
ref: main
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Image metadata
id: meta
run: |
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Build and push by digest
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
@@ -206,9 +151,6 @@ jobs:
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
- name: Export digest
run: |
@@ -217,7 +159,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -225,25 +167,23 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-24.04
timeout-minutes: 120
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
ref: main
- name: Download build digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
merge-multiple: true
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -251,37 +191,12 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
# release that migrates.
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
# of the index rather than matching their own platform then compare the
# wrong digest and report "no update" — which is what Unraid users hit
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
digests=()
for platform in linux-amd64 linux-arm64; do
dir="digests-$platform"
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
for f in "$dir"/*; do
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
done
done
# The labels live on each platform image; the index gets the ones a
# client reads before it picks a platform (#1498).
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
--annotation "index:org.opencontainers.image.version=$VERSION" \
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$MINOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
@@ -289,18 +204,16 @@ jobs:
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-24.04
timeout-minutes: 120
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
+2 -36
View File
@@ -6,9 +6,7 @@ on:
jobs:
check-target:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
@@ -16,7 +14,7 @@ jobs:
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base.ref;
@@ -69,38 +67,6 @@ jobs:
return;
}
// Dependabot's security updates cannot be pointed at dev. `target-branch`
// in .github/dependabot.yml applies to version updates only; security
// updates always open against the default branch. Labelling them meant
// close-stale-wrong-branch shut them 24 hours later, which is how 13 of
// them died. Retarget instead: a bot cannot click "Edit" the way the
// comment below asks a human to.
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
base: 'dev',
});
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
console.log('Retargeted the Dependabot PR onto dev.');
return;
} catch (err) {
// Retargeting can fail when the branch was cut from main and dev has
// moved on. Fall through to the normal path so a human sees it rather
// than the check passing on a PR that is still aimed at main.
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
}
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
+8 -19
View File
@@ -2,41 +2,31 @@ name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
node-version: '24'
- name: Install dependencies
run: npm install
- name: Run lint & format check
# Both are check-only: lint used to run eslint --fix, which rewrote the
# files before format:check looked at them, so neither could fail on
# formatting. lint:format holds every file outside
# scripts/format-baseline.json to Prettier; the list only shrinks.
id: checks
continue-on-error: true
run: |
cd shared
npm run lint
npm run lint:format
npm run format:check
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
@@ -50,9 +40,8 @@ jobs:
'',
'```bash',
'cd shared',
'npm run lint:fix',
'npx prettier --write <file> # each file lint:format names',
'npm run lint:format -- --update # when a listed file is formatted now',
'npm run lint',
'npm run format',
'```',
'',
'Then commit and push the changes.',
+4 -8
View File
@@ -14,19 +14,15 @@ permissions:
jobs:
publish:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# The Node the TREK image runs the plugins on. It has the node:sqlite
# the dev-server tests exercise.
node-version-file: .nvmrc
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
+8 -58
View File
@@ -1,85 +1,35 @@
name: Security Scan
# Neither job is a required check. What they report depends on the advisory
# databases as much as on the change: a CVE published overnight would turn
# every open PR red at once, including the ones that cannot fix it. The
# security floors that hold the fixes live in the root package.json's
# overrides, which a regenerated lockfile keeps.
#
# - npm-audit reads the lockfile only, so it runs in seconds on every PR
# and push into dev and main. It audits what the image installs (the
# server and shared workspaces without dev dependencies) and the
# standalone plugin-sdk.
# - scout scans the built image, base image packages included. It needs
# the Docker Hub login, so it runs on PRs and pushes into main only and
# skips fork PRs. Whether the image builds at all is test.yml's
# docker-image job, which a red scan cannot hide.
on:
pull_request:
branches: [main, dev]
branches: [main]
push:
branches: [main, dev]
branches: [main]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
npm-audit:
name: npm audit
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# The client's dependencies end up in a browser bundle, not in the
# image, and are audited by hand until maplibre-gl's fix (a major) lands.
- name: Audit the image's runtime dependencies
run: npm audit --package-lock-only --omit=dev --audit-level=high --workspace=server --workspace=shared
- name: Audit trek-plugin-sdk
working-directory: plugin-sdk
run: npm audit --package-lock-only --omit=dev --audit-level=high
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
# Scout login can never succeed there.
if: >-
github.repository == 'liketrek/TREK'
&& github.event.pull_request.head.repo.fork != true
&& (github.base_ref == 'main' || github.ref == 'refs/heads/main')
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
- uses: docker/scout-action@v1
with:
command: cves
image: trek:scan
+63 -1042
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -17,12 +17,10 @@ concurrency:
jobs:
deploy:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
+2 -5
View File
@@ -4,7 +4,6 @@ node_modules/
# Build output
client/dist/
server/dist/
server/VERSION
shared/dist/
server/public/*
!server/public/.gitkeep
@@ -52,7 +51,6 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
@@ -66,8 +64,7 @@ coverage-*/
.scannerwork
test-data
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
minio-licence.key
.run
.full-review
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
server/assets/wiki/
-1
View File
@@ -1 +0,0 @@
24
-163
View File
@@ -1,163 +0,0 @@
# CLAUDE.md
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
## Commands
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
npm run format # prettier --write all three
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
```
Tests:
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate)
cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate)
cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate)
cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml)
cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate)
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate)
cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate)
cd client && npm run lint:skips # no .only; skipped tests may only shrink (CI gate)
cd client && npm run lint:i18n-keys # every key the client names exists in en; unused en keys may only shrink (CI gate)
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate)
cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
Run a single test file or test:
```bash
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). `.nvmrc` pins the Node major the Docker image runs and every CI job reads; `engines` in each package.json states the range that installs (npm only warns outside it). `server/.env.example` is the env-var reference.
## Server invariants
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `server/src/db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
## Client invariants
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too.
- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
## Project direction
These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. The Kysely table types the repositories query through are generated the same way (`gen:db-types`/`check:db-types`, `server/scripts/generate-db-types.ts`).
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
- **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current.
- **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there.
- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:<major>-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:<major>`, `:<major>.<minor>` and `:<version>`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:<major>.<minor>` can wait it out.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
| Condition (new code) | Must be |
|---|---|
| Coverage | ≥ 80% |
| Duplicated lines | ≤ 3% |
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
## Reference docs
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
-136
View File
@@ -1,136 +0,0 @@
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
people.
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
report@liketrek.com**.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
resolution.
## Addressing and Repairing Harm
****
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
skipped.
1) Warning
1) Event: A violation involving a single incident or series of incidents.
2) Consequence: A private, written warning from the Community Moderators.
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
clarification on expectations.
2) Temporarily Limited Activities
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
more serious violation.
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
seriousness of the situation and give the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions with particular community members.
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
impact, and being thoughtful about re-entering community spaces after the period is over.
3) Temporary Suspension
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
single serious violation.
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
4) Permanent Ban
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
this person as a member.
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
through other remedies has failed to change the behavior.
3) Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
the community in public or other spaces. Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
of this license,
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
For answers to common questions about Contributor Covenant, see the FAQ
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
enforcement and community guideline resources can be found
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
+3 -3
View File
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
## Development Environment
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+29 -87
View File
@@ -2,10 +2,8 @@
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.26-alpine AS gosu-build
# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever
# was pushed last, and the tag itself is not a semver version go install accepts.
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
@@ -22,18 +20,13 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
# A prerelease build does not bump client/package.json, so the bundle takes its
# version from the same build argument the server's VERSION file comes from.
ARG APP_VERSION=dev
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
@@ -42,9 +35,7 @@ COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
# tsc needs more than the 2 GB heap Node picks on a host with about 12 GB of RAM;
# on a bigger build machine the default is larger, so only small hosts failed.
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build --workspace=server
RUN npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-trixie-slim
@@ -55,32 +46,16 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# The trailing chown runs in this layer on purpose: it covers the manifests and
# the freshly installed node_modules while they are already part of this layer's
# changeset, so it costs nothing. Everything copied after this point carries
# --chown=node:node for the same reason — a recursive chown in a later layer
# would copy up every inode it touches and duplicate the whole tree in the image.
#
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
# python3 + build-essential fallback compiled it against whatever Node headers
# the base image had that day, and 24.19+ headers give an addon that aborts with
# "Assertion failed: (env) != nullptr" the first time a statement is collected
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
{ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
# it to drop to node.
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
@@ -90,75 +65,42 @@ ENV QT_QPA_PLATFORM=offscreen
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
# The in-app help pages (/help) read this straight from disk at runtime, so the
# docs always match the version running. Without it, wikiService falls back to
# fetching the GitHub wiki, which tracks main and needs network access.
COPY --chown=node:node wiki ./wiki
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY --chown=node:node server/tsconfig.json ./server/
COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
# a dir created lazily on first upload needs write permission on the uploads
# mount point itself, which fails with EACCES when the bind-mounted host dir
# isn't writable by node. Only paths this layer creates are chowned; anything
# already in the image arrived node-owned via --chown above.
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app/data /app/uploads && \
chown -h node:node /app/server/uploads /app/server/data
chown -R node:node /app
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
# The version goes into a file, not into ENV. A container keeps its environment
# when it is recreated on a newer image (Portainer's recreate, a copied run
# config), so an APP_VERSION env var would keep announcing the old release and
# the clients would never pick up the new bundle. The server reads this file
# first (server/src/app-config/image-version.ts).
ARG APP_VERSION=dev
RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s\n' "$APP_VERSION" > /app/server/VERSION; fi
# OCI metadata: Renovate, Watchtower and the registries read the source label to
# link an image update to its release notes (#1498). The release workflows add
# the commit and the build time; a local build carries everything else.
LABEL org.opencontainers.image.title="TREK" \
org.opencontainers.image.description="Self-hosted collaborative travel planner" \
org.opencontainers.image.url="https://github.com/liketrek/TREK" \
org.opencontainers.image.source="https://github.com/liketrek/TREK" \
org.opencontainers.image.documentation="https://github.com/liketrek/TREK/wiki" \
org.opencontainers.image.licenses="AGPL-3.0-only" \
org.opencontainers.image.version="${APP_VERSION}"
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1
# Start-up lives in a script, not an inline `sh -c` string: container management
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
# carrying quotes does not survive that round-trip (the container then dies with
# a shell syntax error before Node starts). A CMD of one bare token does.
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
# node. chmod is explicit because a checkout on Windows carries no exec bit.
RUN chmod 0755 /usr/local/bin/trek-entrypoint
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["/usr/local/bin/trek-entrypoint"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
+575 -1
View File
@@ -1,3 +1,577 @@
# MCP Integration
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Connect your MCP client
#### Option A: OAuth 2.1 (recommended)
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
## Tools (read-write)
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
+2 -24
View File
@@ -21,34 +21,12 @@ property names TREK consumes. Country borders (`admin0`) derive from the geoBoun
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding, place details & descriptions
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
details and descriptions come from Nominatim and **Overpass**. Data is
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## Wikimedia Commons & Wikipedia — place pictures & descriptions
Pictures offered for a place come from **Wikimedia Commons** (geosearch by
coordinate) and are cached locally rather than hotlinked. Descriptions can come
from **Wikipedia**, resolved from the place's OpenStreetMap `wikipedia` tag.
Commons files carry their own licence, most often **CC BY** or **CC BY-SA**. TREK
shows the author, the licence and a link to the file description page next to
every picture while it is being chosen, and keeps the author and licence with the
cached file so the credit stays visible afterwards. Wikipedia article text is
**CC BY-SA 4.0**. https://commons.wikimedia.org/wiki/Commons:Licensing
## Google Places — optional place data
When an administrator configures a Google Maps API key, place search, details,
pictures and editorial summaries can also come from the **Google Places API**,
subject to Google's terms. Photos carry the author attribution Google supplies
and no reusable licence; TREK displays that attribution and claims nothing
further. This path is off unless a key is configured, and each part of it has its
own switch in the admin panel. https://cloud.google.com/maps-platform/terms
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
+220 -151
View File
@@ -1,16 +1,17 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -21,84 +22,80 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
&nbsp;
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
&nbsp;
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK2.webp" alt="TREK, a tour of the app" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Planning
#### 🧭 Trip planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🧳 Travel management
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
</td>
</tr>
@@ -107,71 +104,64 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 Journal, Atlas and Vacay
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
#### 📱 Mobile & PWA
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 📱 Mobile and offline
#### 🧩 Addons (admin-toggleable)
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
#### 🤖 AI / MCP
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
@@ -181,26 +171,6 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<br />
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
maintainer has not read and understood: every change goes through a pull request, is
reviewed and tested, and has a human who can answer for it. "The AI wrote that" is not
an answer any of us would accept from ourselves.
See [**How we use AI in TREK**](https://github.com/liketrek/TREK/discussions/1851) for
the details.
<br />
## Get started in 30 seconds
```bash
@@ -213,7 +183,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
</div>
@@ -223,7 +193,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_24-339933?style=flat-square&logo=node.js&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_19-61DAFB?style=flat-square&logo=react&logoColor=black)
@@ -241,29 +211,76 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
<h2 id="docker-compose-production">Docker Compose (production)</h2>
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
with secure defaults and every option documented inline. Download it, then:
<details>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
Then:
```bash
docker compose up -d
```
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
for the full walkthrough.
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
<br />
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
<br />
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
@@ -279,17 +296,41 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
**Docker Compose:**
<br />
```bash
docker compose pull && docker compose up -d
```
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
<h2 id="reverse-proxy">Reverse Proxy</h2>
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
<details>
<summary>Nginx</summary>
@@ -327,19 +368,6 @@ server {
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
# so buffering must be off or tool results arrive late.
location /mcp {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
```
@@ -362,18 +390,60 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
Every variable, its default and what it does: see
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
<details>
<summary><b>Full reference</b></summary>
## Star History
<br />
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
</picture>
</a>
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
<br />
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
## Data sources
@@ -382,8 +452,7 @@ The Atlas map's country and sub-national (province/county) boundaries come from
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
<br />
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/liketrek/TREK</WebPage>
<Forum>https://github.com/liketrek/TREK/issues</Forum>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+2 -7
View File
@@ -8,20 +8,18 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Configurable health checks (liveness/readiness probes) on `/api/health`
- Health checks on `/api/health`
## Helm Repository
A hosted Helm repository is available:
```sh
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
## Usage
Or install directly from the local chart:
@@ -46,12 +44,9 @@ See `values.yaml` for more options.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.3
version: 3.1.3
description: Minimal Helm chart for TREK app
appVersion: "4.3.3"
appVersion: "3.1.3"
+2 -6
View File
@@ -2,12 +2,8 @@
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
-45
View File
@@ -13,15 +13,6 @@ data:
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.TREK_WIKI_DIR }}
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
{{- end }}
{{- if .Values.env.DEFAULT_LANGUAGE }}
DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }}
{{- end }}
{{- if .Values.env.MCP_MAX_SESSION_PER_USER }}
MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
@@ -49,9 +40,6 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
@@ -70,21 +58,9 @@ data:
{{- if .Values.env.OIDC_ADMIN_VALUE }}
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
{{- end }}
{{- if .Values.env.OIDC_USERNAME_CLAIM }}
OIDC_USERNAME_CLAIM: {{ .Values.env.OIDC_USERNAME_CLAIM | quote }}
{{- end }}
{{- if .Values.env.OIDC_SCOPE }}
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
{{- end }}
{{- if .Values.env.HTTP_PROXY }}
HTTP_PROXY: {{ .Values.env.HTTP_PROXY | quote }}
{{- end }}
{{- if .Values.env.HTTPS_PROXY }}
HTTPS_PROXY: {{ .Values.env.HTTPS_PROXY | quote }}
{{- end }}
{{- if .Values.env.NO_PROXY }}
NO_PROXY: {{ .Values.env.NO_PROXY | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}
@@ -94,30 +70,9 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
{{- if .Values.env.NOMINATIM_URL }}
NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
otherwise drop the key and leave the index on. */}}
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
{{- if .Values.env.VAPID_PUBLIC_KEY }}
VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }}
{{- end }}
{{- if .Values.env.VAPID_SUBJECT }}
VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }}
{{- end }}
+11 -78
View File
@@ -6,12 +6,6 @@ metadata:
app: {{ include "trek.name" . }}
spec:
replicas: 1
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
# RollingUpdate would start a second pod holding the same volume before the old one
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
# the old pod down first. Keep it, see updateStrategy in values.yaml.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
matchLabels:
app: {{ include "trek.name" . }}
@@ -40,14 +34,10 @@ spec:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.env.PORT | default 3000 }}
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
{{- with .Values.extraEnvFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
env:
- name: ENCRYPTION_KEY
valueFrom:
@@ -73,80 +63,23 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
- name: UNSPLASH_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
- name: PLACES_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: PLACES_API_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_KEY
optional: true
- name: AMAP_API_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_SECRET
optional: true
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: VAPID_PRIVATE_KEY
optional: true
- name: SMTP_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: SMTP_PASS
optional: true
- name: MAPBOX_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: MAPBOX_ACCESS_TOKEN
optional: true
- name: CARTO_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: CARTO_API_KEY
optional: true
- name: DEMO_ADMIN_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: DEMO_ADMIN_PASS
optional: true
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).readiness }}
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
+5 -59
View File
@@ -17,33 +17,6 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -58,44 +31,17 @@ metadata:
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec | quote }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 | quote }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | quote }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | quote }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | quote }}
{{- end }}
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- end }}
+1 -1
View File
@@ -8,7 +8,7 @@ spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
targetPort: 3000
protocol: TCP
name: http
selector:
+5 -129
View File
@@ -4,12 +4,6 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and
# during a RollingUpdate the new pod migrates the database while the old one still
# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both
# processes on one host, which network filesystems do not provide.
updateStrategy: Recreate
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -25,16 +19,10 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# TREK_WIKI_DIR: "/app/wiki"
# Where the in-app Help pages (/help) read their content from. Leave unset: the
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
# the version you are running. Only set this to serve your own docs from a mounted
# volume. If the path does not exist, Help falls back to fetching the public GitHub
# wiki, which needs egress and tracks the latest release rather than your version.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
@@ -55,9 +43,6 @@ env:
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# ALLOW_LINK_LOCAL_IPS: ""
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
@@ -72,58 +57,22 @@ env:
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
# Value of the OIDC claim that grants admin role.
# OIDC_USERNAME_CLAIM: ""
# Claim a new account's username comes from, e.g. "preferred_username". Unset: name, then preferred_username.
# OIDC_SCOPE: "openid email profile groups"
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# HTTP_PROXY: ""
# HTTPS_PROXY: ""
# NO_PROXY: "localhost,127.0.0.1"
# Outbound proxy for TREK's requests to the internet; NO_PROXY lists hosts that bypass it.
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# NOMINATIM_URL: ""
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
# way. Defaults to https://nominatim.openstreetmap.org.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "25000"
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# VAPID_PUBLIC_KEY: ""
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
# means every device has to turn push on again.
# VAPID_SUBJECT: ""
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
# address is sent unless you set one here.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret.
@@ -143,45 +92,11 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# Optional Google Maps API key (Places API (New) enabled) for place search, photos,
# details and Google transit. Overrides the key set in Admin > Settings > API Keys,
# whose field then shows this variable read-only. Leave empty to use the key from the
# admin panel, or none.
PLACES_API_KEY: ""
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
# key from the admin panel, or none.
AMAP_API_KEY: ""
# The private secret (数字签名) of that key. Required exactly when the key was created
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
# push off with an error in the log until it is fixed, and every device stays registered.
# Leave both empty to let TREK keep a generated pair in its database.
VAPID_PRIVATE_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades.
# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux
# the chart cannot read the existing secret and renders a new key every time. TREK then
# refuses to start rather than lose its secrets; use existingSecret in those setups.
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, the chart renders no Secret of its own and instead reads every secret env
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
# Missing keys are simply left unset (they are optional), so only provide the ones your
# setup needs.
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
@@ -202,45 +117,6 @@ persistence:
existingClaim: ""
annotations: {}
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
#
# The startup probe gives a first boot, a long migration or a restore at boot up to ten
# minutes before liveness takes over; liveness alone killed such a boot after about
# 105 s, and the next start began it again. Readiness asks the database, so a pod whose
# database does not answer stops getting traffic without being restarted.
probes:
startup:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 60
liveness:
httpGet:
path: /api/health
port: http
periodSeconds: 30
readiness:
httpGet:
path: /api/health/ready
port: http
periodSeconds: 10
# Further environment variables, for any setting the env block above does not list.
# extraEnv:
# - name: SESSION_DURATION
# value: 7d
extraEnv: []
# Whole ConfigMaps or Secrets:
# extraEnvFrom:
# - secretRef:
# name: trek-smtp
extraEnvFrom: []
resources:
requests:
cpu: 100m
-4
View File
@@ -3,7 +3,3 @@ e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
e2e/help/media.env
-89
View File
@@ -1,89 +0,0 @@
# CLAUDE.md
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
## Commands (run from `client/`)
```bash
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit (CI)
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs
npm run lint:pages # enforce the Page pattern (CI gate)
npm run lint:rtl # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
npm run lint:size # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
npm run lint:i18n-keys # every translation key src/ names exists in shared en, and en keys nothing in src/, server/src or plugin-sdk/src reaches may only shrink per en file against scripts/i18n-unused-baseline.json (CI gate; --unused lists them, --update lowers the baseline)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
```
Every baseline ratchet above also fails on an entry that allows more than the tree holds: a count above what its file or rule has now, or a file that is gone. Run the check with `--update` in the change that made it smaller, so a fixed file cannot grow back to its old entry and a new file at a deleted path does not inherit one.
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
## Page pattern (enforced — `lint:pages` fails otherwise)
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
## Data flow — offline-first
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. Versions are append-only: a new `this.version(N)` adds its stores to `HISTORY` and a case to `UPGRADE_CASES` in `tests/unit/db/offlineDb.upgrade.test.ts`, which opens the previous version with seeded rows in fake-indexeddb and upgrades it to the current one; a guard there fails on a declared version without both. Never edit a shipped version's stores or `upgrade()`.
- **`src/sync/`**: in `mutationQueue.ts`, offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Every row `enqueue()` writes carries `schemaVersion` (`MUTATION_SCHEMA_VERSION` in `offlineDb.ts`) and `buildVersion`; bump the constant when a row's fields or the way the replay reads them change incompatibly. A flush replays its own format and older ones (an unstamped row is format 1) and skips a row from a newer format without sending, marking or dropping it, holding back the later writes to its entity, so a tab still on an older bundle never replays a shape it does not know. The guard exists only from the release that introduced `schemaVersion` on; a bundle older than that replays every row, so the first bump of `MUTATION_SCHEMA_VERSION` must not ship while a rollback target older than the guard is still in play. A skipped row stays `pending` with no retry or discard path, so on a rolled-back install it holds its entity until the user clears the cache. Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask `mutationQueue.mustQueue()` before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send `X-Base-Updated-At`, so the server refuses the stale replay with a 409 instead. `flush()` takes the Web Lock `trek-mutation-flush:<db name>` so only one tab replays at a time; a tab that finds it taken waits for it, and a `flush()` called while this tab's flush runs or waits for the lock gets that same flush back (with one more pass asked for), so `flush()` never resolves while a tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flush alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal, and especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in `src/` is the layer of its name, so `src/types.ts` follows the `types/` rules. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`.
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop and phone views
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
## Tests
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
+11 -9
View File
@@ -1,10 +1,8 @@
import { test as setup, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
import { E2E_STATE_FILE } from '../playwright.config'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
const stateFile = E2E_STATE_FILE
const stateFile = 'e2e/.tmp/state.json'
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
// seeded admin is created with must_change_password=1, so the first login goes
@@ -29,12 +27,16 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
// support modal, which has NO "OK" button (only CTAs + the X). The shared
// helper handles both notice shapes; dismissal is recorded server-side
// against this user, so clearing it here keeps it cleared for every
// authenticated flow in the run (shared test DB).
await dismissSystemNotices(page, 10_000)
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
await page.context().storageState({ path: stateFile })
})
+6 -12
View File
@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
@@ -8,23 +7,18 @@ import { dismissSystemNotices } from './helpers'
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
// primary action lives in the footer), so click it explicitly rather than pressing
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
const modal = page.locator('.trek-modal-backdrop')
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
// button (the primary action lives in the footer), so click it explicitly
// rather than pressing Enter. The Create button is the slate primary button;
// Cancel is the bordered one.
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Trip ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
-114
View File
@@ -1,114 +0,0 @@
/**
* The run's calendar.
*
* The seeded trip ends on the day the pictures are taken, and every other
* date the seed, the fixtures and the guides use is an offset from that day.
* So a run on any date meets a trip that is running, with a last day that is
* today: the boarding pass, What's Next and the forecast all have something
* to show, and nobody has to bump dates in three files every few weeks.
*
* The day is `E2E_PICTURE_DAY` (YYYY-MM-DD), set once by `e2e/help/run.mjs`
* and `playwright.config.ts` so the seed project and every worker agree even
* when the run crosses midnight; without it, today in the machine's zone.
*/
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/
function localToday(): string {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const fromEnv = process.env.E2E_PICTURE_DAY
if (fromEnv && !ISO_DAY.test(fromEnv)) throw new Error(`E2E_PICTURE_DAY must be YYYY-MM-DD, got "${fromEnv}"`)
/** The picture day as YYYY-MM-DD: the trip's last day. */
export const PICTURE_DAY_ISO: string = fromEnv || localToday()
/** Noon UTC on the picture day, a safe anchor for calendar arithmetic. */
function anchor(offset: number): Date {
const d = new Date(`${PICTURE_DAY_ISO}T12:00:00Z`)
d.setUTCDate(d.getUTCDate() + offset)
return d
}
/** `YYYY-MM-DD` of the picture day plus `offset` days. */
export function day(offset: number): string {
return anchor(offset).toISOString().slice(0, 10)
}
/** `YYYY-MM-DDTHH:mm:00`, the shape reservations and expenses take. */
export function at(offset: number, hhmm: string): string {
return `${day(offset)}T${hhmm}:00`
}
/** The calendar year of the picture day plus `years`. */
export function year(years = 0): number {
return anchor(0).getUTCFullYear() + years
}
/** A day `months` months after the picture day, on the given day of the month. */
export function monthsAhead(months: number, dayOfMonth: number): string {
const d = anchor(0)
d.setUTCDate(1)
d.setUTCMonth(d.getUTCMonth() + months)
d.setUTCDate(dayOfMonth)
return d.toISOString().slice(0, 10)
}
const en = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts })
// The labels the English UI prints for a date, for the guides that have to
// find a button by its calendar text.
/** `Sat, Sep 12`, the day filter's chip. */
export function short(offset: number): string {
return en({ weekday: 'short', month: 'short', day: 'numeric' }).format(anchor(offset))
}
/** `Saturday, September 12`, the day filter's long form. */
export function long(offset: number): string {
return en({ weekday: 'long', month: 'long', day: 'numeric' }).format(anchor(offset))
}
/** `September 25, 2026`, a date picker's cell label. */
export function pickerLabel(offset: number): string {
return pickerLabelOn(day(offset))
}
/** The same label for any `YYYY-MM-DD`, e.g. one from `monthsAhead`. */
export function pickerLabelOn(iso: string): string {
return en({ month: 'long', day: 'numeric', year: 'numeric' }).format(new Date(`${iso}T12:00:00Z`))
}
/** `October`: the English name of the month `months` months after the picture day's. */
export function monthName(months: number): string {
return en({ month: 'long' }).format(new Date(`${monthsAhead(months, 1)}T12:00:00Z`))
}
/** `19.09.2026`, what a date input takes when typed. */
export function dotted(offset: number): string {
const [y, m, d] = day(offset).split('-')
return `${d}.${m}.${y}`
}
/** `13th`, for prose that names a day of the month. */
export function ordinal(offset: number): string {
const n = anchor(offset).getUTCDate()
const suffix = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'
return `${n}${suffix}`
}
/** `Wednesday 16 September 2026`, the date line of a booking mail. */
export function emlDate(offset: number): string {
return en({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(anchor(offset)).replace(/,/g, '')
}
/** `Tue, 1 Sep 2026 09:12:00 +0900`, an RFC 2822 mail header for a fixed offset. */
export function mailHeaderDate(offset: number, hhmm: string, tz: string): string {
const d = anchor(offset)
const weekday = en({ weekday: 'short' }).format(d)
const month = en({ month: 'short' }).format(d)
return `${weekday}, ${d.getUTCDate()} ${month} ${d.getUTCFullYear()} ${hhmm}:00 ${tz}`
}
-180
View File
@@ -1,180 +0,0 @@
# 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, never larger than the largest 16:10 frame the viewport holds (1728 × 1080). A target wider than that, a full-width row for instance, is therefore cut at the sides, and that is accepted: the narrow frame is the format of the whole library, and a picture that widens to fit one row is at a different magnification from the one beside it. A target taller than the frame is anchored at its top, so a long section shows its heading |
| 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 white hairline goes outside the ring and the badge only where the surface measured behind them has too little contrast against the accent, which is the lightbox and the other near-black overlays |
| The step's number | `guide.ts` `ring` | a 28 px disc in the accent on the ring's top-left corner, outside it. Where that spot is not inside the frame, which is every target wider than the frame, it moves inside far enough to clear the frame's edge. The number is part of the picture; the panel numbers the step again beside it and puts nothing on top of the picture |
| Raster tiles | `guide.ts` `settle` → `showLoadedTiles` | loaded tiles are forced visible before every shot. Leaflet fades a tile in over 200 ms and takes the progress from `+new Date()`, and the clock is pinned, so without this the satellite imagery and every raster basemap are a grey rectangle although each tile came back 200 |
| Drag & drop | `guide.ts` `ringDrag` (a step with `dropTo`) | source ringed, 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 every other locale (`i18n:parity:strict` is the gate) and
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
## What the fixtures may look like
The documents and pictures the guides upload are drawn in `fixtures.ts`, because
no real person's paperwork and no photograph may live in this repository. They
still have to look like something a traveller would really put into a trip, or
the picture teaches the reader that the feature is a toy.
- **A document is a document.** `drawPdf` takes an issuer band, a title, a
reference line, a table of labelled fields and a footer, which is the shape of
every confirmation, ticket and voucher. Four lines of Helvetica on white is not
a document; it was the first attempt and it was thrown out.
- **A picture is a graphic, not a painting of a photograph.** A hand-drawn
market or landscape reads as clip art at any size, however it is shaded, and it
was tried three times. What works is the kind of image people actually upload:
a map screenshot with a pin and a search field, a booking confirmation in a
browser window, a printed network map. Those are drawings in reality too, so a
drawing of them is honest.
- **Every date comes from `e2e/dates.ts`**, never from a calendar typed into a
fixture. A confirmation whose check-in is two days before the trip's last day
keeps making sense next month.
- The names of the files are part of the seed: guides find their row by name, so
renaming a fixture means renaming it in the guide too.
## Changing the style
`guide.ts` is shared by every picture, so anything changed there is a property of
the whole library, not of the guide in front of you. Before changing it, work out
which pictures actually come out different and re-render exactly those, one
`--grep` away:
```bash
npm run help:media -- --grep "pictures: (files-trash|files-preview)$|hero: trip-files"
```
The staging directory answers most of those questions without a browser, because
the frame is in the file:
```bash
# which pictures were taken at the frame's maximum, i.e. cut at the sides
python -c "import struct,os;[print(r,f,struct.unpack('>II',open(os.path.join(r,f),'rb').read(24)[16:24])) for r,_,fs in os.walk('e2e/.tmp/help-media') for f in fs if f.endswith('.png')]"
```
A change that alters every picture (the frame, the ring's geometry, the viewport)
is a full run of about four hours on one worker. It is not a thing to do twice,
so look at three or four pictures from a subset run before starting it.
## Rules learned the hard way
- Never run `npx prettier` over `client/` sources: `client/.prettierrc` says
`semi: true` and the sources are written without semicolons, so it rewrites
whole files. Nothing in CI formats the client.
- A killed run leaves its server holding 3011 and 5183, and the next run dies
with "port is already used". Free them first:
`Get-NetTCPConnection -LocalPort 3011 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`.
- The browser's clock is pinned to the picture day (`PICTURE_DAY`). Anything in
the app or a library that animates from the wall clock therefore never
finishes: Leaflet's tile fade is the one that bit, and the fix is in `settle`.
A new picture that comes out empty or grey is worth suspecting of the same.
- `promote.mjs` converts the whole staging directory, so a guide that was not
re-run keeps the picture it had; staging is the source of truth for what gets
published, not the last run.
- 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.
- Several runs at once, each on its own `E2E_WEB_PORT`/`E2E_API_PORT`, share
`server/dist`: build it once and set `E2E_SERVER_PREBUILT=1` on every run, or
each run rebuilds it under the servers of the others. Give each run its own
`TREK_PLUGINS_DIR` and `TREK_PLUGINS_DATA_DIR` as well (the seed installs a
plugin), and its own Vite `cacheDir` when it starts a Vite of its own.
- A plugin installed by a run lives in `server/data/plugins/`; the dev server
imports it on its next restart. Guides that install one uninstall it in `cleanup`.
- The trip's last day is the run day by construction (`e2e/dates.ts`: every
seeded date is an offset from `E2E_PICTURE_DAY`, which `run.mjs` fixes to today
once for the whole run), so the plan opens on today, the trip's last and empty
day; `openTrip` in `trip-shared.ts` selects day 1 and closes its details panel.
A guide that has to find a date by its label builds it with the same helpers
(`short`, `long`, `pickerLabel`, `dotted`) instead of writing a calendar date.
## How the panel shows them
`src/components/Help/HelpGuideView.tsx` and `HelpHome.tsx` hold the other half of
the style, and the two have to agree:
- A step picture goes into a 16:10 box, a hero and a result picture into a 16:9
one, and all three with `object-contain`. Never `object-cover`: the pictures
are already framed, and covering crops them a second time, which took the
sides off every wide one.
- Nothing is drawn over a picture in the panel. The step is numbered beside the
text and the number is in the picture itself.
- A click opens the lightbox, which shows the picture at its own size.
## Before you promote
Look at three or four of the new pictures, not the list of passing tests:
- The ring is in the picture and visible against what is behind it.
- The number is in the picture.
- What the step's text names is in the frame.
- A map shows a map, not a grey rectangle.
- An uploaded document looks like a document someone would upload.
## The guides that show another service
A few guides picture TREK talking to something outside it: AirTrail (flights
into the Transports tab), Dawarich (the recorded route on the map, countries
and wishes in the Atlas), a document store (Nextcloud on the Files tab) and the
booking extractor (confirmations into Bookings and Transports). Their fixtures
in `external.ts` fill each service themselves through its API, so the pictures
do not depend on what happens to be in it; they need its address and key from
`e2e/help/media.env` (copy `media.env.example`, not committed). Without the
file those guides fail loudly at their first step and every other guide runs
as before.
- Every address has to be the machine's LAN address: the server's SSRF guard
refuses loopback whatever `ALLOW_INTERNAL_NETWORK` says, and that flag has to
be `true` in the same file for a private network to be reachable at all.
- AirTrail: any instance with one user and an API key from Settings > Security.
The fixture puts four flights into that account.
- Dawarich: any 1.15 instance with a user and their API key; add the LAN address
to its `APPLICATION_HOSTS`, and give it a reverse geocoder (Nominatim works)
or the Atlas dialog finds no countries. The fixture uploads a synthetic
recording of the trip and of the year before it (`dawarich-track.ts`).
- Nextcloud: an app password. The fixture keeps a folder under `/Reisen` with
two documents, and the guide binds the trip to it.
- The extractor: `KITINERARY_EXTRACTOR_PATH` must point at a program the
server can run with one file argument. On Linux that is the package's binary
(`libkitinerary-bin`); on Windows, a small executable that runs it inside the
TREK Docker image (`docker run --rm -v <dir>:/in:ro --entrypoint
/usr/local/bin/kitinerary-extractor <image> /in/<file>`) and answers
`--version` by itself.
-601
View File
@@ -1,601 +0,0 @@
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 { toggleAddon } from './external'
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, a <section> headed by an h3. */
const block = (page: Page, title: string) => page.locator('h3').filter({ hasText: new RegExp(`^${title}$`) }).first().locator('xpath=ancestor::section[1]')
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').getByRole('button', { name: 'Edit User' }),
act: async p => {
await userRow(p, 'mara').getByRole('button', { name: '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').getByRole('button', { name: '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)
},
// The new category opens in a dialog, its name in the dialog's head.
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).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')),
],
},
'document-providers': {
guide: guide('document-providers'),
start: opener('admin-addons'),
steps: [
// The tile with its shelf: Documents is on in the seed, so the five rows are drawn.
only(p => tile(p, 'Documents')),
{
// The row's switch is a ToggleSwitch whose aria-label is the row's title.
target: p => tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true }),
act: async p => {
const toggle = tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
await settle(p)
},
},
],
// Back to the seed, where every store is off; the files run switches this one on for itself.
cleanup: p => toggleAddon(p.request, 'nextcloud', false),
},
// ── 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,"group") and contains(@class,"px-3.5")][1]'),
act: async p => {
const row = p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][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(modal(p).getByText('Add backend', { exact: true })).toBeVisible()
await typeInto(p, modal(p).locator('input').first(), 'archive')
await settle(p)
},
// The new backend opens in a dialog.
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p).getByText('Add backend', { exact: true })).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('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
],
},
// ── 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])
})
}
-642
View File
@@ -1,642 +0,0 @@
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 { year } from '../dates'
import { ensureAtlasFixtures } from './fixtures'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { modal } from './trip-shared'
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: the panel of the shared dialog frame, inside
* the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the
* two are never open at once.
*/
const popup = (page: Page) => modal(page).getByRole('dialog')
/** The glass panel at the bottom. */
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */
const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first()
const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first()
/** 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=../..')
/**
* Dawarich's own glass panel, left of the statistics: the one panel with its
* name in it. Scoped this way because its two tiles, Wishlist and Countries,
* have namesakes in the dialog's tabs once it is open.
*/
const dawarichPanel = (page: Page) =>
page.locator('div.hidden.md\\:flex').filter({ has: page.getByText('Dawarich', { exact: true }) }).first()
const tile = (page: Page, label: 'Wishlist' | 'Countries') =>
dawarichPanel(page).getByRole('button', { name: label, exact: true })
/** The answers in the dialog: every row is one checkbox named after the country or the wish. */
const rows = (page: Page) => modal(page).getByRole('checkbox')
/** The countries the Atlas paints as visited right now, by code. */
async function visitedCodes(page: Page): Promise<Set<string>> {
const res = await page.request.get('/api/addons/atlas/stats')
const { countries } = (await res.json()) as { countries: { code: string; status: string }[] }
return new Set(countries.filter(c => c.status === 'visited').map(c => c.code))
}
/** What was visited before the countries guide confirmed anything, so `cleanup` takes exactly its additions off again. */
let visitedBefore = new Set<string>()
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 with the button in its head band. Not with Escape:
* after a key press the browser draws its focus ring on the next popup, which
* takes the focus as it opens, and that ring would be in the picture.
*/
async function closePopup(page: Page): Promise<void> {
await popup(page).getByRole('button', { name: 'Close', exact: true }).click()
await expect(popup(page)).toHaveCount(0)
}
/**
* The next unset select of the Add place form in the Bucket List tab: month
* and year both read a long dash until picked. The popup's bucket step labels
* its two selects instead, so there they are found by Month and Year.
*/
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)
}
type Box = { x: number; y: number; width: number; height: number }
/**
* An invisible box laid over several elements, for a step whose text names
* things that have no parent of their own to ring. The runner rings whatever
* box the target has, so this gives it one. It takes no pointer events, so the
* hover and the click the step makes go to the app underneath, and a step
* that uses it needs its own `hover` for the same reason.
*/
async function spanOver(page: Page, boxes: Box[]): Promise<void> {
await page.evaluate(boxes => {
document.getElementById('trek-help-span')?.remove()
const left = Math.min(...boxes.map(b => b.x))
const top = Math.min(...boxes.map(b => b.y))
const right = Math.max(...boxes.map(b => b.x + b.width))
const bottom = Math.max(...boxes.map(b => b.y + b.height))
const el = document.createElement('div')
el.id = 'trek-help-span'
Object.assign(el.style, {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${right - left}px`,
height: `${bottom - top}px`,
pointerEvents: 'none',
})
document.body.appendChild(el)
}, boxes)
}
const span = (page: Page) => page.locator('#trek-help-span')
async function clearSpan(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-span')?.remove())
}
async function boxOf(locator: Locator): Promise<Box> {
const box = await locator.boundingBox()
if (!box) throw new Error(`no box for ${locator}`)
return box
}
/**
* The name the Atlas shows beside the pointer over a region. It is one fixed
* div the map moves after the pointer, with no role or name of its own; the
* country under the region name is how it is told apart.
*/
async function regionTooltipBox(page: Page, country: string): Promise<Box> {
const box = await page.evaluate(country => {
const tip = Array.from(document.querySelectorAll('div')).find(d =>
d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country))
if (!tip) return null
const r = tip.getBoundingClientRect()
return { x: r.x, y: r.y, width: r.width, height: r.height }
}, country)
if (!box) throw new Error(`no region tooltip naming ${country} on screen`)
return box
}
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()
// The step promises the card's flag, which is a Twemoji image from a
// CDN. Waited for here so the result shows the flag rather than an
// empty square; should the CDN be out of reach, the card falls back to
// two letters and the image never loads, which fails this step.
const flag = detailCard(p, 'Japan').locator('img[alt="JP"]')
await expect(flag).toBeVisible({ timeout: 15_000 })
await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true)
},
},
],
},
'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)
},
},
{
// The text says hovering names the region. That name floats right of
// the pointer and reaches past the region's own box, so a ring round
// the region alone ran straight through it. The ring goes round both.
prepare: async p => {
await hoverMapCentre(p)
await settle(p)
await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')])
},
target: span,
hover: hoverMapCentre,
act: async p => {
await clearSpan(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, popup(p).getByLabel('Month', { exact: true }), 'June')
await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1)))
},
// The whole popup: the step names both selects and the button under them.
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() },
},
{
// The row, not the field alone: the text has the reader press the
// search button beside it as well.
target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'),
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)), String(year(1)))
},
// The whole form: framed on the small Add button alone, the picture lost
// the name and the month, and looked like Add on an empty form.
target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'),
hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(),
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() },
{
// The text covers the continents, the streak and this year's trips.
// Those are two groups side by side with no box of their own around
// both, so the ring gets one laid over them.
prepare: async p => {
await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))])
},
target: span,
hover: p => continentStats(p).hover(),
act: clearSpan,
},
],
},
'dawarich-countries': {
guide: guide('dawarich-countries'),
start: async p => {
await openAtlas(p)
visitedBefore = await visitedCodes(p)
},
steps: [
{
target: p => tile(p, 'Countries'),
act: async p => {
await tile(p, 'Countries').click()
await expect(modal(p).getByRole('button', { name: 'Look for countries' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The pitch with its button rather than the button alone: a frame
// centred on the button cut off the dialog's head band, and with it the
// name of the service this dialog asks.
target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'),
hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: 'Look for countries' }).click()
// A year in thirteen 30-day reads, one after another, each of which
// Dawarich computes on the spot; the client gives it four minutes.
// An empty answer shows a sentence instead of rows and fails here,
// which is right: a dialog with nothing to add is no picture.
await expect(rows(p).first()).toBeVisible({ timeout: 240_000 })
await settle(p)
// The flags come from a CDN and land after the rows.
await p.waitForTimeout(800)
},
},
{
// The whole dialog: the list step 2 describes (flag, cities, ticked
// rows) is only ever pictured here, and a frame centred on the button
// cut the rows in half. The button sits in the footer, in the ring too.
target: p => modal(p).getByRole('dialog'),
hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click()
await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 })
// Closed with the button in its head band, not Escape: the focus goes
// back to the Countries tile, and after a key press the browser draws
// its focus ring there, into the result picture.
await modal(p).getByRole('button', { name: 'Close', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
// The Atlas re-reads itself after a write; the colours land with the answer.
await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size)
await settle(p)
await p.waitForTimeout(900)
},
},
],
cleanup: async p => {
// Off the map again, so the next run and the guides after this one start
// from the seed. Unmarking tombstones a country; confirming it again from
// the dialog lifts that, so a re-run offers the same rows.
const after = await visitedCodes(p)
for (const code of after) {
if (!visitedBefore.has(code)) await p.request.delete(`/api/addons/atlas/country/${code}/mark`)
}
},
},
'dawarich-wishes': {
guide: guide('dawarich-wishes'),
start: openAtlas,
steps: [
{
target: p => tile(p, 'Wishlist'),
act: async p => {
await tile(p, 'Wishlist').click()
await expect(modal(p).getByRole('button', { name: 'Check wishlist' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// As in the countries guide: the pitch with its button, so the head band
// stays in the frame.
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'),
hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: 'Check wishlist' }).click()
// One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra),
// fifteen seconds each at worst; New Zealand and Iceland have none and
// are counted in the note instead.
await expect(rows(p).first()).toBeVisible({ timeout: 120_000 })
await settle(p)
},
},
{
// The whole dialog, as in the countries guide: the rows with distance,
// stay and day and the note with the 250 m rule are what step 2 quotes.
target: p => modal(p).getByRole('dialog'),
hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(),
act: async p => {
await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click()
await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 })
await p.keyboard.press('Escape')
await expect(modal(p)).toHaveCount(0)
// The result is the list itself: the ticked date under each wish is a
// button labelled Undo, there once the list has re-read itself.
await p.getByRole('button', { name: 'Bucket List' }).click()
await expect(panel(p).getByRole('button', { name: 'Undo' }).first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
// Every tick that came from a recording goes back, by the route the Undo
// button uses, so the wishes are open again for the next run.
const res = await p.request.get('/api/addons/atlas/bucket-list')
const { items } = (await res.json()) as { items: { id: number; visited_source: string | null }[] }
for (const item of items) {
if (item.visited_source === 'dawarich') await p.request.delete(`/api/integrations/dawarich/bucket-list/${item.id}/visit`)
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureAtlasFixtures(request)
// After the Atlas fixtures: the connection adds two wishes with coordinates
// (Fushimi Inari Taisha, Wadi Rum) to the list the fixtures create.
await ensureDawarichConnection(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide: while the addon is on, every Atlas carries the Dawarich
// panel left of Stats. This file's pictures show it on purpose; the files
// after it do not, so it goes off again here, whatever happened in between.
await disconnectDawarich(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 }) => {
// The Dawarich reads are a year of countries in thirteen pieces and one
// look-up per wish: more waiting on another service than four minutes
// may hold on a slow instance.
if (id.startsWith('dawarich-')) test.slow()
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-790
View File
@@ -1,790 +0,0 @@
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
import path from 'node:path'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, 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 the Japan trip's three Kyoto places on it; the guides add a
* second list, a Kyoto place found by search, three more Kyoto temples taken
* from the trip, a label, a member and a file round trip, in the order the
* context lists them, and leave the list side of it in place. Whatever they put
* into a trip comes out again in that guide's cleanup.
*/
const KYOTO = 'Kyoto shortlist'
const LISBON = 'Lisbon coffee'
const JAPAN = 'Autumn in Japan'
/**
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
* holds Tokyo and the three Kyoto places the list already has, so an import from
* it could only add Tokyo to a list called "Kyoto shortlist", and every later
* picture would carry that. None of these is on a day, which is also what the
* import's third step says starts out selected.
*/
const KYOTO_EXTRAS = [
{ name: 'Kinkaku-ji Temple', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
description: 'The Golden Pavilion, mirrored in its pond.' },
{ name: 'Tenryu-ji Temple', lat: 35.0158, lng: 135.6737, address: '68 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto',
description: 'Zen garden next to the bamboo grove.' },
{ name: 'Kiyomizu-dera Temple', lat: 34.9949, lng: 135.785, address: '1-294 Kiyomizu, Higashiyama Ward, Kyoto',
description: 'The wooden stage over the maples.' },
]
/** The two places in Arashiyama, which the labels guide groups under that name. */
const ARASHIYAMA_PLACES = ['Arashiyama Bamboo Grove', 'Tenryu-ji Temple']
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 rounded panel inside the backdrop, for framing a whole dialog. */
const modalCard = (page: Page) => modal(page).locator('> [role="dialog"]').first()
/** A trip's tile on the first step of the import from a trip. */
const importTrip = (page: Page, title: string) => modal(page).getByRole('button').filter({ hasText: title })
/**
* The trip tiles on the first step of that import, and the rows of a trip's
* places on its second step: each is the dialog's only staggered block while it
* is on screen.
*/
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
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()
/** The map beside the list, with its floating controls. */
const mapShell = (page: Page) => page.locator('.col-map-shell')
/** The map's search pill: the magnifier and the field together. */
const mapSearch = (page: Page) => mapShell(page).locator('.col-map-search').first()
// ── Trip data over the API ────────────────────────────────────────────────────
async function tripIdOf(page: Page, title: string): Promise<number> {
const res = await page.request.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 trip = trips.find(t => t.title === title)
if (!trip) throw new Error(`trip "${title}" is missing`)
return trip.id
}
async function tripPlaces(page: Page, tripId: number): Promise<{ id: number; name: string }[]> {
const res = await page.request.get(`/api/trips/${tripId}/places`)
if (!res.ok()) throw new Error(`GET places of trip ${tripId}: ${res.status()}`)
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
return Array.isArray(body) ? body : (body.places ?? [])
}
async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Promise<void> {
for (const id of ids) {
const res = await page.request.delete(`/api/trips/${tripId}/places/${id}`)
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE place ${id} of trip ${tripId}: ${res.status()}`)
}
}
/** The places the import guide put into the Japan trip, removed again in its cleanup. */
let addedToTrip: number[] = []
/** The Japan trip's places before the copy guide ran; everything else is the copy's. */
let placesBeforeCopy: Set<number> | null = null
// ── Waiting for the page to look finished ─────────────────────────────────────
/**
* Take focus off whatever holds it. A focused field or a button that got focus
* back from a closed dialog draws an outline in the accent, which in a picture
* reads as a second step ring.
*/
async function blurFocus(page: Page): Promise<void> {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.())
}
/**
* Let a Leaflet camera move that is under way arrive.
*
* Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to
* the picture day (guide.ts), so a pan started for a picture never gets past its
* first frame: the map stays where it was and the place the text says it pans
* to is not in view. Moving the fixed time on for a moment lets the running
* animation see that its time is up and finish, exactly where it would have
* ended; the clock goes back to the picture day straight after.
*/
async function finishMapMotion(page: Page): Promise<void> {
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
await page.clock.setFixedTime(PICTURE_DAY)
}
/**
* Wait until the map has stopped moving and shows its pins in the part of it
* that is on screen: every pin, or with `every: false` at least one, for a map
* that has just panned to one place and may have left others behind its edge.
* Nothing to wait for where the list has no map.
*/
async function mapReady(page: Page, { every = true } = {}): Promise<void> {
if (!(await mapShell(page).count())) return
await finishMapMotion(page)
await expect
.poll(
() =>
mapShell(page).evaluate((el, every) => {
if (el.querySelector('.leaflet-zoom-anim, .leaflet-pan-anim, .leaflet-cluster-anim')) return 'moving'
const map = el.getBoundingClientRect()
const box = {
left: Math.max(map.left, 0),
top: Math.max(map.top, 0),
right: Math.min(map.right, window.innerWidth),
bottom: Math.min(map.bottom, window.innerHeight),
}
const icons = Array.from(el.querySelectorAll('.leaflet-marker-icon')).map(i => i.getBoundingClientRect())
const inside = icons.filter(r => r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom).length
if (!inside) return 'no pins in view'
return every && inside < icons.length ? `${icons.length - inside} of ${icons.length} pins out of view` : 'ready'
}, every, { timeout: 2_000 }), // short: a map on its way off the page is asked again, not waited for
{ message: 'the map shows the pins of the list', timeout: 15_000 },
)
.toBe('ready')
}
/**
* Frame the map on the list that is open.
*
* The map opens on a camera worked out for the whole window, not for the pane it
* sits in (MapView's opening frame), so on this page it starts about one zoom
* level too close and every pin is off its edges. It frames the pane properly
* whenever the number of places on it changes, so the search pill is used to
* empty it and fill it again, which is what a reader who searches and clears the
* search sees. The pill ends empty and the page as it was.
*/
async function frameMap(page: Page): Promise<void> {
if (!(await mapShell(page).count())) return
const field = mapSearch(page).locator('input')
const rows = await placeRows(page).count()
await field.fill('zzz')
await expect(placeRows(page)).toHaveCount(0)
await field.fill('')
await expect(placeRows(page)).toHaveCount(rows)
await blurFocus(page)
await mapReady(page)
}
/** Photo requests of each page still in flight: the lookups and the images they point at. */
const photoRequests = new WeakMap<Page, Set<Request>>()
function trackPhotos(page: Page): void {
if (photoRequests.has(page)) return
const pending = new Set<Request>()
photoRequests.set(page, pending)
const isPhoto = (r: Request) => r.url().includes('/api/maps/place-photo/')
page.on('request', r => { if (isPhoto(r)) pending.add(r) })
page.on('requestfinished', r => pending.delete(r))
page.on('requestfailed', r => pending.delete(r))
// A cached photo whose file has gone answers with an empty 204, and the avatar
// falls back to its pin. Say so in the log, because that is not the app.
page.on('response', r => {
if (isPhoto(r) && r.url().endsWith('/bytes') && r.status() === 204) console.warn(`[collections] empty place photo: ${r.url()}`)
})
}
/**
* Wait for the place photos. Each avatar looks its photo up first and loads the
* image after that, so the lookups can be quiet for a moment while an image is
* still to come; quiet has to hold for a while before it counts. Bounded, so a
* lookup that hangs costs time, not the run.
*/
async function photosReady(page: Page): Promise<void> {
const pending = photoRequests.get(page)
if (pending) {
const until = Date.now() + 30_000
let quietSince = 0
while (Date.now() < until) {
if (pending.size) quietSince = 0
else if (!quietSince) quietSince = Date.now()
else if (Date.now() - quietSince > 800) break
await page.waitForTimeout(100)
}
if (pending.size) console.warn(`[collections] place photos still loading: ${[...pending].map(r => r.url()).join(', ')}`)
}
await page
.waitForFunction(() => Array.from(document.querySelectorAll<HTMLImageElement>('.col-lrow img')).every(img => img.complete), undefined, { timeout: 10_000 })
.catch(() => {})
}
/** Map framed, pins drawn, photos in, nothing focused: a page ready for its picture. */
async function pageReady(page: Page, { reframe = false, every = true } = {}): Promise<void> {
if (reframe) await frameMap(page)
else await mapReady(page, { every })
await photosReady(page)
await blurFocus(page)
await settle(page)
}
async function openCollections(page: Page): Promise<void> {
trackPhotos(page)
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 })
// All saved, which this lands on, always has places, and the map comes with them.
await expect(placeRows(page).first()).toBeVisible({ timeout: 20_000 })
await pageReady(page, { reframe: true })
}
async function openList(page: Page, name: string): Promise<void> {
await openCollections(page)
await listRow(page, name).click()
await expect(hero(page)).toContainText(name)
// Until its places are in, the page can still show the map of the list before.
await settle(page)
await expect(page.locator('.col-loading')).toHaveCount(0)
await pageReady(page, { reframe: true })
}
/** Turn on select mode and tick the named places. */
async function selectPlaces(page: Page, names: string[]): Promise<void> {
await filterBar(page).getByRole('button', { name: 'Select', exact: true }).click()
await expect(selectionBar(page)).toBeVisible()
for (const name of names) await placeRow(page, name).click()
await expect(selectionBar(page)).toContainText(`${names.length} selected`)
}
/** 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()
}
/** The place the add guide finds by search: not on the list yet, with a real street address. */
const SEARCH_FOR = 'Nijo Castle'
const FOUND = /Nij[oō]/
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)
// The description keeps focus after typing, and its focus border sits
// right next to the ring on Create in the next picture.
await blurFocus(p)
},
},
{
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)
// The new list is empty: its two ways to fill it replace the list and the map.
await expect(p.locator('.col-cta').first()).toBeVisible()
await expect(mapShell(p)).toHaveCount(0)
await pageReady(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)
},
},
{
// The whole pill, magnifier included, not the bare input inside it.
target: p => modal(p).getByPlaceholder('Search for a place…').locator('xpath=..'),
act: async p => {
const search = modal(p).getByPlaceholder('Search for a place…')
await typeInto(p, search, SEARCH_FOR)
await modal(p).getByRole('button', { name: 'Search', exact: true }).click()
// The OpenStreetMap hit, whose address runs from the street to the
// country; the index can answer the same name with only a ward.
const hit = modal(p).locator('button').filter({ hasText: FOUND }).filter({ hasText: /Kyoto.*Japan/ }).first()
await expect(hit).toBeVisible({ timeout: 20_000 })
await hit.click()
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(FOUND)
await blurFocus(p)
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, 'Nij')).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 pageReady(p)
},
},
],
},
'import-from-trip': {
guide: guide('import-from-trip'),
start: async p => {
const tripId = await tripIdOf(p, JAPAN)
const have = new Set((await tripPlaces(p, tripId)).map(x => x.name))
addedToTrip = []
for (const place of KYOTO_EXTRAS) {
if (have.has(place.name)) continue
const res = await p.request.post(`/api/trips/${tripId}/places`, { data: place })
if (!res.ok()) throw new Error(`could not add "${place.name}" to ${JAPAN}: ${res.status()} ${await res.text()}`)
const body = (await res.json()) as { place?: { id: number }; id?: number }
const id = body.place?.id ?? body.id
if (id != null) addedToTrip.push(id)
}
await 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(importTrip(p, JAPAN)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// All the trip tiles, not the one: a tile in the left column puts the
// frame's centre there, and the dialog's right half falls off the picture.
target: importStagger,
act: async p => {
await importTrip(p, JAPAN).click()
await expect(modal(p).getByPlaceholder('Search places…')).toBeVisible()
await expect(importStagger(p).getByRole('button').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole dialog: the rows with their toolbar are taller than the
// smallest frame, so a frame centred on them cuts the head band off.
target: modalCard,
act: async p => {
// Nothing to tick: the three temples no day holds are what the dialog
// selects by itself, which is what the step's text says.
for (const place of KYOTO_EXTRAS) {
await expect(importStagger(p).getByRole('button', { name: new RegExp(place.name) })).toHaveAttribute('aria-pressed', 'true')
}
await expect(modal(p).getByRole('button', { name: `Import ${KYOTO_EXTRAS.length}` })).toBeVisible()
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 })
// The head band has a Close of its own; the one meant here ends the dialog in its footer.
await modal(p).locator('footer').getByRole('button', { name: 'Close' }).click()
await expect(modal(p)).toHaveCount(0)
for (const place of KYOTO_EXTRAS) await expect(placeRow(p, place.name)).toBeVisible()
await pageReady(p)
},
},
],
cleanup: async p => {
// The list keeps its copies; the trip goes back to what the seed made of it.
await deleteTripPlaces(p, await tripIdOf(p, JAPAN), addedToTrip)
addedToTrip = []
},
},
'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 pageReady(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()
// The map pans to the place; let that pan arrive before the next picture.
// Places at the far side of the list can end up past the map's edge.
await pageReady(p, { every: false })
},
},
{
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)
// Escape hands focus back to the label control, outlined.
await blurFocus(p)
await settle(p)
},
},
{
// The two places that are in Arashiyama, so the label means what it says.
prepare: p => selectPlaces(p, ARASHIYAMA_PLACES),
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)
for (const name of ARASHIYAMA_PLACES) await expect(placeRow(p, name).locator('.col-lrow-label')).toContainText('Arashiyama')
await blurFocus(p)
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 expect(placeRows(p)).toHaveCount(ARASHIYAMA_PLACES.length)
await pageReady(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()
await blurFocus(p)
},
target: selectionBar,
act: pageReady,
},
],
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: async p => {
placesBeforeCopy = new Set((await tripPlaces(p, await tripIdOf(p, JAPAN))).map(x => x.id))
await openList(p, KYOTO)
},
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
// Two places the Japan trip does not have yet: the one found by search
// and a temple whose trip copy the import guide took out again.
await selectPlaces(p, ['Nij', 'Kinkaku-ji Temple'])
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: JAPAN }),
act: async p => {
await modal(p).locator('button').filter({ hasText: JAPAN }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await pageReady(p)
},
},
],
cleanup: async p => {
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
// The copies leave the trip again, so the trip screens keep the seed's trip.
if (placesBeforeCopy) {
const tripId = await tripIdOf(p, JAPAN)
const before = placesBeforeCopy
await deleteTripPlaces(p, tripId, (await tripPlaces(p, tripId)).filter(x => !before.has(x.id)).map(x => x.id))
placesBeforeCopy = null
}
},
},
'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)
},
},
{
// The invite row: the user picker, the role picker and Send invite. Each
// picker sits in a wrapper that sizes it, so the row is three levels up.
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)
// Escape gives focus back to Share, and its outline would read as a ring.
await pageReady(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)')
// A new list mounts a new map, which opens on the window's camera.
await pageReady(p, { reframe: true })
},
},
],
},
'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. For the long weekend in Lisbon.')
await modal(p).getByRole('button', { name: 'Save' }).click()
await expect(modal(p)).toHaveCount(0)
await pageReady(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 pageReady(p)
},
},
{
// The whole pill, magnifier included, not the bare input inside it.
target: mapSearch,
act: async p => {
await typeInto(p, mapSearch(p).locator('input'), '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])
})
}
-421
View File
@@ -1,421 +0,0 @@
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 { monthName, monthsAhead, pickerLabelOn } from '../dates'
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}"]`)
/** The trip the create guide makes, eight months out, named after the month it lands in. */
const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
/** The trip search above the cards (#2190), by its accessible name. */
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
const JAPAN = 'Autumn in Japan'
const SCRIPTS: Record<string, GuideScript> = {
'search-trips': {
guide: guide('search-trips'),
start: openDashboard,
steps: [
{
target: p => tripSearch(p).locator('xpath=..'),
act: async p => {
await typeInto(p, tripSearch(p), 'Nishiki')
await expect(card(p, JAPAN)).toBeVisible({ timeout: 15_000 })
await expect(card(p, JAPAN).getByText('Nishiki Market')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => card(p, JAPAN),
act: async p => {
await p.getByRole('button', { name: 'Clear search' }).click()
await expect(card(p, LISBON)).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
if (await tripSearch(p).count()) await tripSearch(p).fill('')
},
},
'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), NEW_TRIP) },
},
{
target: p => datePicker(p, 'Start Date'),
act: async p => {
await pickDate(p, datePicker(p, 'Start Date'), 8, pickerLabelOn(monthsAhead(8, 8)))
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, pickerLabelOn(monthsAhead(8, 15)))
},
},
{
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, NEW_TRIP)).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. By title, not by position: the list came back in an order that
// left the copy standing, and a trip nobody expected then broke a guide
// three files later, in a screen that has nothing to do with this one.
const res = await p.request.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 ?? [])
for (const t of trips.filter(x => x.title !== LISBON && x.title.startsWith(LISBON))) {
const dropped = await p.request.delete(`/api/trips/${t.id}`)
if (!dropped.ok()) throw new Error(`could not drop the copy "${t.title}": ${dropped.status()}`)
}
},
},
'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, NEW_TRIP)).toBeVisible()
},
steps: [
{
target: p => cardAction(p, NEW_TRIP, 'Delete'),
act: async p => { await cardAction(p, NEW_TRIP, '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, NEW_TRIP)).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 })
},
},
{
// The subscription dialog sits in the shared dialog frame.
target: p => p.locator('.trek-modal-backdrop').last().locator('> [role="presentation"], > div').first(),
},
],
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::section[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])
})
}
-357
View File
@@ -1,357 +0,0 @@
/**
* A synthetic Dawarich recording for the help-center pictures.
*
* TREK only ever reads from Dawarich, so the media run has to put a recording
* there itself. This module makes one: a phone track that follows the seeded
* demo trip through Tokyo and Kyoto, day by day, plus five short trips in the
* year before it so the Atlas has countries to offer. Everything is relative to
* the day the pictures are taken, so it stays inside the trip's dates whenever
* the run happens.
*
* The points go through Dawarich's own batch endpoint as GeoJSON features;
* see `ensureDawarichFixtures` in fixtures.ts for the upload.
*/
export interface TrackFeature {
type: 'Feature'
geometry: { type: 'Point'; coordinates: [number, number] }
properties: {
timestamp: string
horizontal_accuracy: number
altitude: number
speed: number
device_id: string
}
}
/** One place on a day: when the phone gets there (local time) and how long it stays. */
interface Waypoint {
t: string
lat: number
lng: number
stay?: number
}
interface DayPlan {
offset: number
utcOffset: number
points: Waypoint[]
}
export const DAWARICH_DEVICE = 'trek-help-fixture'
const JST = 9
// Every place the trail visits. The trip's own places come first and match the
// seed's coordinates, so the dashed line ends exactly on the markers.
const P = {
sensoji: [35.7148, 139.7967],
nakamise: [35.7118, 139.7963],
asakusaStation: [35.7109, 139.7975],
toyosuStation: [35.6551, 139.7961],
teamlab: [35.6486, 139.79],
shinjukuStation: [35.6896, 139.7006],
gracery: [35.6946, 139.7012],
shibuya: [35.6595, 139.7005],
harajuku: [35.6702, 139.7027],
meiji: [35.6764, 139.6993],
ueno: [35.7146, 139.7714],
akihabara: [35.6984, 139.7731],
ginza: [35.6717, 139.765],
tsukiji: [35.6654, 139.7707],
tokyoTower: [35.6586, 139.7454],
roppongi: [35.6605, 139.7292],
tokyoStation: [35.6812, 139.7671],
shinYokohama: [35.5075, 139.6174],
nagoya: [35.1709, 136.8815],
kyotoStation: [34.9858, 135.7588],
kanra: [34.9942, 135.7592],
fushimi: [34.9671, 135.7727],
arashiyama: [35.017, 135.6716],
togetsukyo: [35.013, 135.6776],
nishiki: [35.005, 135.7649],
kiyomizu: [34.9949, 135.785],
gion: [35.0037, 135.7752],
yasaka: [35.0037, 135.7785],
todaiji: [34.689, 135.8398],
naraPark: [34.6851, 135.843],
ginkakuji: [35.027, 135.7982],
philosophersPath: [35.0227, 135.7948],
nanzenji: [35.0114, 135.7939],
heian: [35.0163, 135.7824],
} as const
type Place = readonly [number, number]
const at = (t: string, [lat, lng]: Place, stay?: number): Waypoint => ({ t, lat, lng, stay })
/** The trip itself: the last day is the picture day (offset 0), the first is nine days before. */
function japanDays(): DayPlan[] {
const day = (offset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset: JST, points })
return [
day(-9, [
at('08:40', P.gracery, 10),
at('09:30', P.sensoji, 90),
at('11:05', P.nakamise, 40),
at('11:50', P.asakusaStation, 5),
at('12:40', P.ginza, 70),
at('13:50', P.toyosuStation, 5),
at('14:00', P.teamlab, 120),
at('16:10', P.toyosuStation, 5),
at('17:00', P.shinjukuStation, 10),
at('17:15', P.gracery, 60),
at('19:00', P.shinjukuStation, 90),
at('21:00', P.gracery, 30),
]),
day(-8, [
at('09:00', P.gracery, 10),
at('09:40', P.shibuya, 45),
at('10:40', P.harajuku, 30),
at('11:20', P.meiji, 75),
at('13:00', P.harajuku, 60),
at('14:30', P.shibuya, 90),
at('16:40', P.shinjukuStation, 10),
at('17:00', P.gracery, 240),
]),
day(-7, [
at('09:10', P.gracery, 10),
at('10:00', P.ueno, 120),
at('12:30', P.akihabara, 100),
at('14:40', P.ginza, 120),
at('17:20', P.shinjukuStation, 10),
at('17:40', P.gracery, 200),
]),
day(-6, [
at('08:30', P.gracery, 10),
at('09:20', P.tsukiji, 90),
at('11:30', P.tokyoTower, 80),
at('13:30', P.roppongi, 150),
at('16:40', P.shinjukuStation, 10),
at('17:00', P.gracery, 240),
]),
day(-5, [
at('07:40', P.gracery, 10),
at('08:15', P.tokyoStation, 15),
at('08:48', P.shinYokohama, 1),
at('10:05', P.nagoya, 1),
at('10:45', P.kyotoStation, 20),
at('11:20', P.kanra, 120),
at('14:00', P.gion, 90),
at('16:20', P.kanra, 30),
at('17:00', P.fushimi, 150),
at('20:00', P.kanra, 60),
]),
day(-4, [
at('08:30', P.kanra, 10),
at('09:30', P.togetsukyo, 30),
at('10:10', P.arashiyama, 60),
at('11:20', P.togetsukyo, 40),
at('12:30', P.nishiki, 90),
at('14:20', P.gion, 60),
at('15:40', P.kanra, 240),
]),
day(-3, [
at('09:00', P.kanra, 10),
at('09:50', P.kiyomizu, 100),
at('11:50', P.yasaka, 40),
at('12:40', P.gion, 120),
at('15:00', P.heian, 60),
at('16:30', P.kanra, 240),
]),
day(-2, [
at('08:20', P.kanra, 10),
at('08:45', P.kyotoStation, 15),
at('09:45', P.naraPark, 60),
at('11:00', P.todaiji, 90),
at('12:40', P.naraPark, 120),
at('15:30', P.kyotoStation, 10),
at('16:00', P.kanra, 200),
]),
day(-1, [
at('09:00', P.kanra, 10),
at('09:50', P.nanzenji, 60),
at('11:00', P.philosophersPath, 45),
at('11:50', P.ginkakuji, 60),
at('13:20', P.nishiki, 90),
at('15:20', P.kanra, 240),
]),
day(0, [
at('08:30', P.kanra, 30),
at('09:30', P.gion, 60),
at('11:00', P.nishiki, 60),
at('12:30', P.kanra, 60),
at('14:00', P.kyotoStation, 120),
]),
]
}
/**
* Five short trips in the year before the pictures, in countries the Atlas does
* not have yet: Portugal, Jordan, Czechia, South Korea and Taiwan. Jordan
* dwells at Petra long enough for the wishlist rule (250 m, 20 minutes).
*/
function worldDays(): DayPlan[] {
const day = (offset: number, utcOffset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset, points })
return [
// Portugal, spring
day(-158, 1, [
at('09:30', [38.7077, -9.1366], 60),
at('11:00', [38.7114, -9.13], 120),
at('14:00', [38.6979, -9.2065], 90),
at('15:50', [38.6916, -9.216], 60),
at('18:00', [38.7077, -9.1366], 120),
]),
day(-157, 1, [
at('10:00', [41.1407, -8.611], 90),
at('12:00', [41.147, -8.6149], 60),
at('13:30', [41.1403, -8.6094], 120),
at('16:00', [41.1496, -8.6109], 120),
]),
// Jordan, late winter
day(-205, 3, [
at('09:00', [31.9544, 35.9344], 90),
at('11:00', [31.9539, 35.9106], 120),
at('15:00', [31.717, 35.587], 150),
]),
day(-204, 3, [
at('08:30', [30.3285, 35.4444], 90),
at('10:20', [30.3222, 35.4517], 120),
at('13:00', [30.3285, 35.4444], 60),
at('15:30', [29.576, 35.42], 180),
]),
// Czechia, early summer
day(-100, 2, [
at('09:30', [50.0875, 14.4213], 90),
at('11:15', [50.0865, 14.4114], 45),
at('12:30', [50.0911, 14.4016], 150),
at('16:00', [50.0645, 14.4183], 90),
]),
day(-99, 2, [
at('10:00', [50.0793, 14.4275], 120),
at('13:00', [50.0865, 14.4114], 60),
at('15:00', [50.0875, 14.4213], 120),
]),
// South Korea and Taiwan, one journey last autumn
day(-300, 9, [
at('09:30', [37.5796, 126.977], 120),
at('12:00', [37.5826, 126.9831], 90),
at('14:30', [37.5636, 126.9856], 120),
at('17:30', [37.5512, 126.9882], 90),
]),
day(-299, 9, [
at('10:00', [37.5563, 126.9236], 150),
at('14:00', [37.5796, 126.977], 90),
]),
day(-297, 8, [
at('10:00', [25.0372, 121.4999], 60),
at('11:30', [25.0421, 121.5074], 120),
at('15:00', [25.0339, 121.5645], 120),
at('18:30', [25.088, 121.5245], 120),
]),
day(-296, 8, [
at('10:30', [25.1096, 121.8446], 240),
at('16:00', [25.0421, 121.5074], 120),
]),
]
}
/** Deterministic jitter, so two runs produce the same recording. */
function noise(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
return x - Math.floor(x)
}
function isoAt(dayIso: string, hhmm: string, utcOffset: number, plusSeconds = 0): string {
const [h, m] = hhmm.split(':').map(Number)
const local = Date.UTC(+dayIso.slice(0, 4), +dayIso.slice(5, 7) - 1, +dayIso.slice(8, 10), h, m)
return new Date(local - utcOffset * 3600_000 + plusSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
function minutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number)
return h * 60 + m
}
function shiftDay(baseDay: string, offset: number): string {
const d = new Date(`${baseDay}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + offset)
return d.toISOString().slice(0, 10)
}
function feature(lng: number, lat: number, timestamp: string, speed: number, seed: number): TrackFeature {
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [+lng.toFixed(6), +lat.toFixed(6)] },
properties: {
timestamp,
horizontal_accuracy: 4 + Math.round(noise(seed) * 8),
altitude: 20 + Math.round(noise(seed + 1) * 30),
speed: +speed.toFixed(2),
device_id: DAWARICH_DEVICE,
},
}
}
/** Turns one day's waypoints into a track: a point a minute while moving, one every five while staying. */
function dayFeatures(dayIso: string, plan: DayPlan, seedBase: number): TrackFeature[] {
const out: TrackFeature[] = []
const pts = plan.points
let seed = seedBase
for (let i = 0; i < pts.length; i++) {
const here = pts[i]
const stay = here.stay ?? 0
const arrive = minutes(here.t)
for (let k = 0; k <= stay; k += 5) {
seed++
const wobble = 0.00012
out.push(
feature(
here.lng + (noise(seed) - 0.5) * wobble,
here.lat + (noise(seed + 7) - 0.5) * wobble,
isoAt(dayIso, here.t, plan.utcOffset, k * 60),
0,
seed,
),
)
}
const next = pts[i + 1]
if (!next) break
const leaveAt = arrive + stay
const travel = minutes(next.t) - leaveAt
if (travel <= 0) continue
// Moving: straight line with a little sway, a point a minute. Long hops
// (the Shinkansen) get thinned out so a day never exceeds Dawarich's page.
const step = travel > 60 ? 2 : 1
const km = Math.hypot((next.lat - here.lat) * 111, (next.lng - here.lng) * 91)
const speed = (km * 1000) / (travel * 60)
for (let m = step; m < travel; m += step) {
seed++
const f = m / travel
const sway = Math.sin(f * Math.PI) * 0.0008 * (noise(seed) - 0.5)
out.push(
feature(
here.lng + (next.lng - here.lng) * f + sway,
here.lat + (next.lat - here.lat) * f + sway,
isoAt(dayIso, here.t, plan.utcOffset, (stay + m) * 60),
speed,
seed,
),
)
}
}
return out
}
/** The whole recording for a picture day: the trip's ten days plus the year before. */
export function dawarichRecording(baseDay: string): { trip: TrackFeature[]; world: TrackFeature[] } {
const build = (plans: DayPlan[], seedBase: number) =>
plans.flatMap((plan, i) => dayFeatures(shiftDay(baseDay, plan.offset), plan, seedBase + i * 10_000))
return { trip: build(japanDays(), 1), world: build(worldDays(), 500_000) }
}
/** The UTC window one part of the recording covers, for checking whether it is already there. */
export function recordingWindow(baseDay: string, part: 'trip' | 'world'): { from: string; to: string } {
const plans = part === 'trip' ? japanDays() : worldDays()
const offsets = plans.map(p => p.offset)
return { from: shiftDay(baseDay, Math.min(...offsets) - 1), to: shiftDay(baseDay, Math.max(...offsets) + 1) }
}
-364
View File
@@ -1,364 +0,0 @@
import type { APIRequestContext } from '@playwright/test'
import { day, PICTURE_DAY_ISO } from '../dates'
import { DAWARICH_DEVICE, dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track'
/**
* The services outside TREK that a few guides need: an AirTrail with flights,
* a Dawarich with a recording, a Nextcloud with a folder of documents. Each is
* reached through its own API with the address and key from the environment
* (`e2e/help/media.env`, see README), and each fixture puts the service into
* the state the guide expects before TREK is connected to it. Nothing here is
* silent: a missing variable or an unreachable service fails the run rather
* than producing a picture of an empty panel.
*
* TREK's SSRF guard refuses loopback whatever the flags say, so every address
* has to be the machine's LAN address, and the media backend needs
* ALLOW_INTERNAL_NETWORK=true to reach it at all.
*/
function env(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`)
return value.replace(/\/+$/, '')
}
async function expectOk(res: Response, what: string): Promise<Response> {
if (!res.ok) throw new Error(`${what}: HTTP ${res.status} ${await res.text()}`)
return res
}
// ── AirTrail ────────────────────────────────────────────────────────────────
interface AirtrailWanted {
flightNumber: string
from: string
to: string
offset: number
departureTime: string
arrivalTime: string
airline: string
aircraft: string
seat: 'window' | 'aisle'
seatNumber: string
}
/**
* Two flights home on the trip's last day that connect in Tokyo, so the picker
* offers to import them as one flight with a layover, and the flights of the
* Lisbon weekend in spring for its "Other flights" list. LH716 out is already
* in the seed and would only be skipped as a duplicate, so it stays out.
*/
export const AIRTRAIL_FLIGHTS: AirtrailWanted[] = [
{ flightNumber: 'NH16', from: 'ITM', to: 'HND', offset: 0, departureTime: '08:00', arrivalTime: '09:10', airline: 'ANA', aircraft: 'B789', seat: 'window', seatNumber: '12A' },
{ flightNumber: 'LH717', from: 'HND', to: 'FRA', offset: 0, departureTime: '12:35', arrivalTime: '18:25', airline: 'DLH', aircraft: 'A359', seat: 'aisle', seatNumber: '31C' },
{ flightNumber: 'TP573', from: 'FRA', to: 'LIS', offset: -158, departureTime: '10:20', arrivalTime: '12:25', airline: 'TAP', aircraft: 'A320', seat: 'window', seatNumber: '7F' },
{ flightNumber: 'TP574', from: 'LIS', to: 'FRA', offset: -156, departureTime: '13:10', arrivalTime: '17:05', airline: 'TAP', aircraft: 'A320', seat: 'aisle', seatNumber: '9C' },
]
interface AirtrailFlightRow {
id: number
flightNumber: string | null
date: string
passengers?: { id: number; userId: string | null }[]
seats?: { id: number; userId: string | null }[]
}
/** Puts the wanted flights into the AirTrail account the key belongs to, once. */
export async function ensureAirtrailFlights(): Promise<void> {
const base = env('HELP_MEDIA_AIRTRAIL_URL')
const headers = { Authorization: `Bearer ${env('HELP_MEDIA_AIRTRAIL_API_KEY')}`, 'Content-Type': 'application/json' }
const listed = await expectOk(await fetch(`${base}/api/flight/list`, { headers }), 'AirTrail flight list')
const { flights } = (await listed.json()) as { flights: AirtrailFlightRow[] }
for (const wanted of AIRTRAIL_FLIGHTS) {
const date = day(wanted.offset)
const existing = flights.find(f => f.flightNumber === wanted.flightNumber)
if (existing && existing.date === date) continue
const owner = (existing?.passengers ?? existing?.seats ?? []).find(p => p.userId)
const body = {
...(existing ? { id: existing.id } : {}),
from: wanted.from,
to: wanted.to,
departure: date,
departureTime: wanted.departureTime,
arrival: date,
arrivalTime: wanted.arrivalTime,
airline: wanted.airline,
flightNumber: wanted.flightNumber,
aircraft: wanted.aircraft,
passengers: [{ ...(owner ? { id: owner.id } : {}), userId: '<USER_ID>', seat: wanted.seat, seatNumber: wanted.seatNumber, seatClass: 'economy', flightReason: 'leisure' }],
}
await expectOk(await fetch(`${base}/api/flight/save`, { method: 'POST', headers, body: JSON.stringify(body) }), `AirTrail save ${wanted.flightNumber}`)
}
}
/** Switches the addon on and connects the signed-in user to the AirTrail, loudly. */
export async function ensureAirtrailConnection(api: APIRequestContext): Promise<void> {
await ensureAirtrailFlights()
await toggleAddon(api, 'airtrail', true)
const saved = await api.put('/api/integrations/airtrail/settings', {
data: { url: env('HELP_MEDIA_AIRTRAIL_URL'), apiKey: env('HELP_MEDIA_AIRTRAIL_API_KEY') },
})
if (!saved.ok()) throw new Error(`could not save the AirTrail connection: ${saved.status()} ${await saved.text()}`)
const status = await api.get('/api/integrations/airtrail/status')
const body = (await status.json()) as { connected: boolean; error?: string }
if (!body.connected) throw new Error(`AirTrail is not reachable from the media backend: ${body.error ?? status.status()} (is ALLOW_INTERNAL_NETWORK=true?)`)
}
export async function disconnectAirtrail(api: APIRequestContext): Promise<void> {
await api.put('/api/integrations/airtrail/settings', { data: { url: '' } })
await toggleAddon(api, 'airtrail', false)
}
// ── Dawarich ────────────────────────────────────────────────────────────────
/** The ids of the fixture's own points between two instants (ISO), every page of them. */
async function fixturePointIds(base: string, key: string, from: string, to: string): Promise<number[]> {
const ids: number[] = []
for (let page = 1; ; page++) {
const res = await expectOk(
await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1000&page=${page}`, { headers: { Authorization: `Bearer ${key}` } }),
`Dawarich points ${from}..${to}`,
)
const points = (await res.json()) as { id: number; tracker_id?: string | null }[]
// Only what this fixture uploaded: whatever else the account holds is not ours to touch.
ids.push(...points.filter(p => p.tracker_id === DAWARICH_DEVICE).map(p => p.id))
if (points.length === 0 || page >= Number(res.headers.get('x-total-pages') ?? 1)) return ids
}
}
const shiftIso = (iso: string, days: number) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
const aroundInstant = (iso: string, seconds: number) => new Date(Date.parse(iso) + seconds * 1000).toISOString()
/**
* Uploads the synthetic recording, one part at a time, unless this run's copy
* of it is already there.
*
* The recording moves with the picture day, so the copy an earlier run left
* sits a few days off this run's and overlaps its window. Asking whether the
* window held any point at all found that copy and uploaded nothing, and the
* trip map then drew the earlier run's last days, in Kyoto, on this run's
* first days, which are in Tokyo. So the probe asks for this run's first and
* last point, and a part that lacks either is cleared of the fixture's own
* points, a month either side of its window, before it goes up again.
*/
export async function ensureDawarichRecording(): Promise<void> {
const base = env('HELP_MEDIA_DAWARICH_URL')
const key = env('HELP_MEDIA_DAWARICH_API_KEY')
const recording = dawarichRecording(PICTURE_DAY_ISO)
for (const part of ['world', 'trip'] as const) {
const features = recording[part]
const ends = [features[0], features[features.length - 1]].map(f => f.properties.timestamp)
let present = true
for (const at of ends) {
if ((await fixturePointIds(base, key, aroundInstant(at, -1), aroundInstant(at, 1))).length === 0) present = false
}
if (present) continue
const { from, to } = recordingWindow(PICTURE_DAY_ISO, part)
const stale = await fixturePointIds(base, key, shiftIso(from, -30), shiftIso(to, 30))
for (let i = 0; i < stale.length; i += 1000) {
await expectOk(
await fetch(`${base}/api/v1/points/bulk_destroy`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ point_ids: stale.slice(i, i + 1000) }),
}),
`Dawarich clear ${part}`,
)
}
for (let i = 0; i < features.length; i += 500) {
const batch: TrackFeature[] = features.slice(i, i + 500)
await expectOk(
await fetch(`${base}/api/v1/points?api_key=${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: batch }),
}),
`Dawarich upload ${part} batch ${i / 500 + 1}`,
)
}
}
}
/** Two wishes the recording reaches, with coordinates so the scan can match them. */
export const DAWARICH_WISHES = [
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, country_code: 'JP' },
{ name: 'Wadi Rum', lat: 29.576, lng: 35.42, country_code: 'JO' },
]
/**
* Switches the addon on, connects the signed-in user to the Dawarich and adds
* the wishes. Automatic syncing stays off: a sync would seed suggestion rows
* that head the Places column of every later picture.
*/
export async function ensureDawarichConnection(api: APIRequestContext): Promise<void> {
await ensureDawarichRecording()
await toggleAddon(api, 'dawarich', true)
const saved = await api.put('/api/integrations/dawarich/settings', {
data: { url: env('HELP_MEDIA_DAWARICH_URL'), apiKey: env('HELP_MEDIA_DAWARICH_API_KEY'), allowInsecureTls: false, syncEnabled: false },
})
if (!saved.ok()) throw new Error(`could not save the Dawarich connection: ${saved.status()} ${await saved.text()}`)
// The probe wants the address in its body; the key it takes from the connection just saved.
const test = await api.post('/api/integrations/dawarich/test', { data: { url: env('HELP_MEDIA_DAWARICH_URL'), allowInsecureTls: false } })
const body = (await test.json()) as { connected: boolean; error?: string; errorDetail?: string }
if (!body.connected) throw new Error(`Dawarich is not reachable from the media backend: ${body.error ?? ''} ${body.errorDetail ?? ''} (is ALLOW_INTERNAL_NETWORK=true?)`)
const listRes = await api.get('/api/addons/atlas/bucket-list')
const { items } = (await listRes.json()) as { items: { name: string }[] }
for (const wish of DAWARICH_WISHES) {
if (items.some(i => i.name === wish.name)) continue
const created = await api.post('/api/addons/atlas/bucket-list', { data: wish })
if (!created.ok()) throw new Error(`could not add the ${wish.name} wish: ${created.status()} ${await created.text()}`)
}
}
export async function disconnectDawarich(api: APIRequestContext): Promise<void> {
await api.delete('/api/integrations/dawarich/settings')
await toggleAddon(api, 'dawarich', false)
}
// ── Nextcloud ───────────────────────────────────────────────────────────────
export const NEXTCLOUD_BASE_PATH = '/Reisen'
export const NEXTCLOUD_FOLDER = 'Autumn in Japan'
export const NEXTCLOUD_DOCUMENTS = ['ryokan-invoice.pdf', 'shinkansen-eticket.pdf']
/** What `drawPdf` in fixtures.ts takes; repeated here so this file needs nothing from it. */
export interface StoreDocument {
issuer: string
title: string
reference: string
fields: readonly (readonly [string, string])[]
footer: string
}
function davRoot(): { url: string; auth: string } {
const url = `${env('HELP_MEDIA_NEXTCLOUD_URL')}/remote.php/dav/files/${env('HELP_MEDIA_NEXTCLOUD_USER')}`
const auth = 'Basic ' + Buffer.from(`${env('HELP_MEDIA_NEXTCLOUD_USER')}:${env('HELP_MEDIA_NEXTCLOUD_PASSWORD')}`).toString('base64')
return { url, auth }
}
async function dav(method: string, path: string, body?: BodyInit, extra: Record<string, string> = {}): Promise<Response> {
const { url, auth } = davRoot()
const target = url + path.split('/').map(encodeURIComponent).join('/')
return fetch(target, { method, headers: { Authorization: auth, ...extra }, body })
}
/**
* The store side of the document-sync guide: a base folder with one trip
* folder holding two documents and nothing else, however the last run left it.
* `pdf` draws the documents, so this file stays free of binary fixtures.
*/
export async function ensureNextcloudFolder(pdf: (doc: StoreDocument) => Buffer): Promise<void> {
const folder = `${NEXTCLOUD_BASE_PATH}/${NEXTCLOUD_FOLDER}`
for (const dir of [NEXTCLOUD_BASE_PATH, folder]) {
const made = await dav('MKCOL', `${dir}/`)
if (![201, 405].includes(made.status)) throw new Error(`Nextcloud MKCOL ${dir}: HTTP ${made.status} ${await made.text()}`)
}
const listing = await dav('PROPFIND', `${folder}/`, undefined, { Depth: '1' })
if (listing.status !== 207) throw new Error(`Nextcloud PROPFIND ${folder}: HTTP ${listing.status}`)
const names = [...(await listing.text()).matchAll(/<d:href>([^<]+)<\/d:href>/g)]
.map(m => decodeURIComponent(m[1]).replace(/\/$/, '').split('/').pop() ?? '')
.filter(n => n && n !== NEXTCLOUD_FOLDER)
for (const name of names) {
if (NEXTCLOUD_DOCUMENTS.includes(name)) continue
const gone = await dav('DELETE', `${folder}/${name}`)
if (![204, 404].includes(gone.status)) throw new Error(`Nextcloud DELETE ${name}: HTTP ${gone.status}`)
}
const documents: Record<string, StoreDocument> = {
'ryokan-invoice.pdf': {
issuer: 'Hakone Ginyu',
title: 'Invoice',
reference: 'Invoice 2026-0918, confirmation RY-4471',
fields: [
['Guests', '2 adults, one room'],
['Nights', 'Two nights, half board'],
['Room and board', 'JPY 84,000'],
['Onsen tax', 'JPY 12,000'],
['Total, paid by card', 'JPY 96,000'],
],
footer: 'Paid in full. Thank you for staying with us.',
},
'shinkansen-eticket.pdf': {
issuer: 'JR Central',
title: 'Reserved seat ticket',
reference: 'Nozomi 21, Tokyo to Kyoto',
fields: [
['Date', 'Day 6 of the trip'],
['Departure / arrival', 'Tokyo 08:30, Kyoto 10:45'],
['Car and seats', 'Car 7, seats 12A and 12B'],
['Class', 'Ordinary, reserved, non-smoking'],
],
footer: 'Keep the ticket until you leave the station; the gate reads it twice.',
},
}
for (const name of NEXTCLOUD_DOCUMENTS) {
if (names.includes(name)) continue
const bytes = pdf(documents[name])
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const put = await dav('PUT', `${folder}/${name}`, body, { 'Content-Type': 'application/pdf' })
if (![201, 204].includes(put.status)) throw new Error(`Nextcloud PUT ${name}: HTTP ${put.status} ${await put.text()}`)
}
}
export const NEXTCLOUD_CONNECTION = () => ({
providerId: 'nextcloud',
baseUrl: env('HELP_MEDIA_NEXTCLOUD_URL'),
credentials: { login_name: env('HELP_MEDIA_NEXTCLOUD_USER'), app_password: env('HELP_MEDIA_NEXTCLOUD_PASSWORD'), base_path: NEXTCLOUD_BASE_PATH },
allowInsecureTls: false,
})
/** Takes every binding and connection off the trip, keeping the documents. */
export async function resetDocSync(api: APIRequestContext, tripId: number): Promise<void> {
const links = await api.get(`/api/trips/${tripId}/docsync/links`)
if (links.ok()) {
const body = (await links.json()) as { links?: { id: number }[] } | { id: number }[]
for (const link of Array.isArray(body) ? body : (body.links ?? [])) await api.delete(`/api/trips/${tripId}/docsync/links/${link.id}`)
}
const connections = await api.get(`/api/trips/${tripId}/docsync/connections`)
if (connections.ok()) {
const body = (await connections.json()) as { connections?: { id: number }[] } | { id: number }[]
for (const c of Array.isArray(body) ? body : (body.connections ?? [])) await api.delete(`/api/trips/${tripId}/docsync/connections/${c.id}`)
}
}
// ── Booking extractor ────────────────────────────────────────────────────────
/**
* Import from file is rendered only when the server can read a confirmation.
* The media run gets that from the extractor named in media.env
* (KITINERARY_EXTRACTOR_PATH, see README). The AI Parsing addon would show
* the button too, but with no model behind it every parse ends in a red card,
* so the guides ask for the extractor and nothing else.
*/
export async function requireExtractor(api: APIRequestContext): Promise<void> {
const res = await api.get('/api/health/features')
const body = (await res.json()) as { bookingImport?: boolean }
if (!body.bookingImport) {
throw new Error('the booking extractor is not available to the media backend: set KITINERARY_EXTRACTOR_PATH in e2e/help/media.env (see README)')
}
}
/**
* The review attaches the confirmation to the booking it made through the
* ordinary file upload, and that upload honours the administrator's Allowed
* File Types, which do not include a mail out of the box: the booking is
* saved and the .eml dropped without a word. So the list gets `eml` for the
* import guides and loses it again in their cleanup. Idempotent either way.
*/
export async function allowEmlUploads(api: APIRequestContext, allowed: boolean): Promise<void> {
const current = await api.get('/api/auth/app-settings')
const { allowed_file_types = '' } = (await current.json()) as { allowed_file_types?: string }
const types = allowed_file_types.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
if (types.includes('eml') === allowed) return
const next = allowed ? [...types, 'eml'] : types.filter(t => t !== 'eml')
const res = await api.put('/api/auth/app-settings', { data: { allowed_file_types: next.join(',') } })
if (!res.ok()) throw new Error(`could not ${allowed ? 'allow' : 'disallow'} .eml uploads: ${res.status()} ${await res.text()}`)
}
// ── shared ──────────────────────────────────────────────────────────────────
export async function toggleAddon(api: APIRequestContext, id: string, enabled: boolean): Promise<void> {
const res = await api.put(`/api/admin/addons/${id}`, { data: { enabled } })
if (!res.ok()) throw new Error(`could not switch addon ${id} ${enabled ? 'on' : 'off'}: ${res.status()} ${await res.text()}`)
}
File diff suppressed because it is too large Load Diff
-464
View File
@@ -1,464 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { PICTURE_DAY_ISO } from '../dates'
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 }
/**
* The instant every picture is taken at: nine in the morning, UTC, on the
* picture day (`e2e/dates.ts`), which is the seeded trip's last day.
*
* A picture has to look the same whoever runs this and whenever, and the seed
* is relative to that day, so the browser's clock is pinned to it: were it
* left on the wall clock, a run late in the evening would see the trip's last
* stops in the past, and What's Next would have nothing left to list. Only
* the browser's `Date` is pinned, so timers keep running and nothing that
* waits stops waiting; the server keeps its own clock, which none of the
* pictures read.
*/
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
/** Put the page on the day the pictures are taken on. Call before navigating. */
async function pinClock(page: Page): Promise<void> {
await page.clock.setFixedTime(PICTURE_DAY)
}
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'
await pinClock(page)
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 {
const frame = frameFor(box)
await ring(page, box, n, frame)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frame })
}
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'
await pinClock(page)
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, dim everything else, number it.
*
* The ring is the app's accent, which stands out on nearly every surface the
* guides walk over. Over the lightbox and the other near-black overlays it does
* not: a dark accent on a dark ground is a ring nobody can see. So the surface
* behind the ring is measured, and only when it has too little contrast against
* the accent does a white hairline go around the outside of the ring and the
* badge. Everywhere else the ring keeps the single line it always had.
*/
async function ring(
page: Page,
box: { x: number; y: number; width: number; height: number },
n: number,
frame: { x: number; y: number; width: number; height: number },
): Promise<void> {
await page.evaluate(
({ box, n, frame }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const pad = 6
const parse = (value: string): [number, number, number, number] | null => {
const text = value.trim()
if (text.startsWith('#')) {
const hex = text.length < 7 ? text.slice(1, 4).replace(/./g, c => c + c) : text.slice(1, 7)
const n = Number.parseInt(hex, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]
}
const inside = /rgba?\(([^)]+)\)/.exec(text)
if (!inside) return null
const parts = inside[1]
.split(/[\s,/]+/)
.filter(Boolean)
.map(Number)
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]
}
const luminance = (colour: [number, number, number, number]): number => {
const channel = (v: number) => {
const c = v / 255
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(colour[0]) + 0.7152 * channel(colour[1]) + 0.0722 * channel(colour[2])
}
// What lies just outside the ring, through any transparent layer.
const behind = (x: number, y: number): [number, number, number, number] => {
let node = document.elementFromPoint(
Math.max(1, Math.min(window.innerWidth - 2, x)),
Math.max(1, Math.min(window.innerHeight - 2, y)),
)
while (node) {
const colour = parse(getComputedStyle(node).backgroundColor)
if (colour && colour[3] > 0.2) return colour
node = node.parentElement
}
return [255, 255, 255, 1]
}
// The dim layer darkens that surface before the ring is drawn over it.
const ground = luminance(behind(box.x - pad - 4, box.y + box.height / 2)) * 0.7
const ink = luminance(parse(accent) || [17, 24, 39, 1])
const hairline = (Math.max(ground, ink) + 0.05) / (Math.min(ground, ink) + 0.05) < 3
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}${hairline ? ', 0 0 0 5px rgba(255,255,255,0.92)' : ''}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
pointerEvents: 'none',
zIndex: '2147483647',
})
// Outside the corner where that is in shot, and otherwise inside, far
// enough in to clear the frame's edge: a row wider than the picture has
// no outside left of it, and the ring's own corner is off the picture too.
const ringLeft = box.x - pad
const ringTop = box.y - pad
const left = ringLeft - 14 >= frame.x + 2 ? -14 : Math.max(10, frame.x + 10 - ringLeft)
const top = ringTop - 14 >= frame.y + 2 ? -14 : Math.max(10, frame.y + 10 - ringTop)
const badge = document.createElement('div')
Object.assign(badge.style, {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
width: '28px',
height: '28px',
borderRadius: '999px',
background: accent,
color: accentText,
font: '700 13px/28px Poppins, system-ui, sans-serif',
textAlign: 'center',
boxShadow: hairline
? '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)'
: '0 2px 8px rgba(0,0,0,0.25)',
})
badge.textContent = String(n)
el.appendChild(badge)
document.body.appendChild(el)
},
{ box, n, frame },
)
}
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 showLoadedTiles(page)
await page.waitForTimeout(450)
}
/**
* Show the raster tiles that have arrived.
*
* Leaflet fades a tile in over 200 ms and works out how far it has got from
* `+new Date()`. The clock is pinned to the picture day (see PICTURE_DAY), so
* that difference never grows, the tile keeps the opacity 0 it was created
* with, and the map is a grey rectangle in the picture even though every tile
* came back with a 200. Nothing else on the page fades from the wall clock, so
* this only ever touches tiles that are already loaded.
*/
async function showLoadedTiles(page: Page): Promise<void> {
await page.evaluate(() => {
// A rule, not an inline style: Leaflet re-runs the fade on every frame and
// writes the opacity back, so the only thing that holds is a stylesheet
// that outranks it. `leaflet-tile-loaded` is Leaflet's own class, put on a
// tile the moment its image arrives, so a tile still in flight stays hidden.
if (!document.getElementById('trek-help-tiles')) {
const style = document.createElement('style')
style.id = 'trek-help-tiles'
style.textContent = '.leaflet-tile-loaded { opacity: 1 !important; }'
document.head.appendChild(style)
}
for (const tile of document.querySelectorAll('img.leaflet-tile')) {
const img = tile as HTMLImageElement
if (img.complete && img.naturalWidth > 0) img.classList.add('leaflet-tile-loaded')
}
})
}
File diff suppressed because it is too large Load Diff
-30
View File
@@ -1,30 +0,0 @@
# Copy to e2e/help/media.env (not committed). Only the guides that show
# another service need these; every other guide runs without the file.
#
# The addresses must be the machine's LAN address: TREK's SSRF guard refuses
# loopback whatever the flags say, and the two lines at the bottom let the
# media backend reach a private network and find the booking extractor.
# AirTrail (Transports: Import from AirTrail). Key from Settings > Security.
HELP_MEDIA_AIRTRAIL_URL=http://192.168.1.10:3020
HELP_MEDIA_AIRTRAIL_API_KEY=
# Dawarich (Map: recorded route; Atlas: countries and wishes). Key from Account > API key.
HELP_MEDIA_DAWARICH_URL=http://192.168.1.10:3000
HELP_MEDIA_DAWARICH_API_KEY=
# Nextcloud (Files: Document sync). An app password, not the account password.
HELP_MEDIA_NEXTCLOUD_URL=http://192.168.1.10:8803
HELP_MEDIA_NEXTCLOUD_USER=admin
HELP_MEDIA_NEXTCLOUD_PASSWORD=
# For the media backend (client/e2e/server-launch.mjs passes the environment on).
ALLOW_INTERNAL_NETWORK=true
# The booking extractor. On Linux the package's binary; on Windows a small
# program that runs it in the TREK Docker image (see README).
KITINERARY_EXTRACTOR_PATH=/usr/lib/x86_64-linux-gnu/libexec/kf6/kitinerary-extractor
# Optional: the day the seed and the pictures are relative to (YYYY-MM-DD).
# Unset, every run takes today. Pin it while a picture set is made over
# several runs, so every run shows the same calendar.
# E2E_PICTURE_DAY=
-61
View File
@@ -1,61 +0,0 @@
// 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)}`)
-58
View File
@@ -1,58 +0,0 @@
// `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 { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
// The guides that show another service (AirTrail, Dawarich, a document store,
// the booking extractor) take its address and key from e2e/help/media.env,
// KEY=VALUE per line, which is not committed. media.env.example lists them.
// The file is read here so the backend inherits it too (server-launch.mjs
// spreads this environment): ALLOW_INTERNAL_NETWORK and the extractor path
// live in the same place.
function mediaEnv() {
const file = path.join(process.cwd(), 'e2e', 'help', 'media.env')
if (!existsSync(file)) return {}
const out = {}
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq < 1) continue
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"(.*)"$/, '$1')
}
return out
}
// The day the pictures are taken on, fixed once for the whole run so the seed
// project and every worker agree even across midnight (see e2e/dates.ts).
function today() {
const d = new Date()
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const fromFile = mediaEnv()
const env = {
...fromFile,
...process.env,
E2E_WEB_PORT: process.env.E2E_WEB_PORT || '5183',
E2E_API_PORT: process.env.E2E_API_PORT || '3011',
// The shell wins, then media.env (a day pinned there keeps a picture set
// consistent across several runs), then today.
E2E_PICTURE_DAY: process.env.E2E_PICTURE_DAY || fromFile.E2E_PICTURE_DAY || today(),
}
// 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)
-344
View File
@@ -1,344 +0,0 @@
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::section[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('h2, h3').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
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, 'Ntfy')),
only(p => section(p, 'Webhook URL')),
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"], > div').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, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
only(p => section(p, 'Change Password').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 {
// The per-event matrix is the whole Notifications card in the redesigned tab.
return section(p, 'Notifications')
}
// ── 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])
})
}
-770
View File
@@ -1,770 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { dotted } from '../dates'
import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures'
import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
import { requireExtractor, allowEmlUploads } from './external'
import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Bookings tab of a trip, keyed by the ids in
* `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan"
* with `ensureBookingsFixtures` in front of them, because the seed's only
* reservation is a flight and a flight is a transport: without the fixture this
* tab is its empty state. What a guide creates or changes it puts back in
* `cleanup`, mostly by deleting the booking and letting the fixture build it
* again, so the next one starts from the same seven cards.
*/
const guide = (id: string): HelpGuide => {
const g = tripBookingsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The tab id in the address is the legacy German one; its label is Bookings. */
const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' })
/** A booking's card in the Cards view: an article named after the booking. */
const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
/** The pencil and the bin in a card's head band, named by their tooltips. */
const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') =>
bookingCard(page, title).getByRole('button', { name, exact: true })
/** Open a booking's editor from its card, the way the texts tell the reader to. */
async function editFromCard(page: Page, title: string): Promise<void> {
await cardAction(page, title, 'Edit').click()
await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
await settle(page)
}
// ── The bar ───────────────────────────────────────────────────────────────────
/** The tab's one bar: its heading, then search, filter, the views and the add buttons. */
const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..')
const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true })
/** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */
const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ })
const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true })
/** The filter panel under the funnel: Status, Type and Travelers. */
const filterMenu = (page: Page) => page.getByRole('menu')
const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true })
const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true })
// ── Timeline and detail ───────────────────────────────────────────────────────
/** Trip | Day above the chart. */
const zoom = (page: Page) => page.getByRole('group', { name: 'Zoom', exact: true })
/** A day's heading on the Trip zoom; a click opens that day by the hour. */
const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) })
/** A bar of the timeline, named after its booking. */
const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true })
/** The booking's detail popup, labelled by its title. */
const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The booking the views guide opens: a tour on day 5, with a place and travellers. */
const VIEWED = { title: 'Fushimi Inari night walk', day: 5 }
// ── The editor ────────────────────────────────────────────────────────────────
/** A field of the booking form, by its own label; the block around it is the label's parent. */
const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first()
const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..')
/** The two-column or three-column row a field shares with its neighbours. */
const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..')
/** A labelled block of a dialog's body whose label is not a `<label>` (Who paid?). */
const section = (page: Page, name: string) =>
modal(page).locator('section').filter({ has: page.getByText(name, { exact: true }) }).first()
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
/** The title is typed into the head band; the field keeps the old placeholder. */
const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')
const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345')
const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00')
const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true })
/** The card's delete question is its own portal, with no backdrop class to find it by. */
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
/** The Booking Type is a pill in the head band; its name reads the type after the label. */
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
/** The pill's list is its own portal, named after the pill's label. */
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
/** The status pill beside it says what a click turns it into. */
const statusPill = (page: Page) => modal(page).getByRole('button', { name: /^Set to / })
async function openTypeMenu(page: Page): Promise<void> {
await typePill(page).click()
await expect(typeMenu(page)).toBeVisible()
await beat(page, 300)
}
async function pickType(page: Page, type: RegExp): Promise<void> {
await typeMenu(page).getByRole('button', { name: type }).first().click()
await expect(typeMenu(page)).toHaveCount(0)
}
async function openManualBooking(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Manual Booking' }).click()
await expect(modal(page).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
await settle(page)
}
/** The Travelers field opens its members as a list under it, inside the form. */
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
/** Open the CustomSelect that belongs to a label and take one option out of it. */
async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise<void> {
await block(page, field).getByRole('button').first().click()
await expect(selectMenu(page)).toBeVisible()
if (search) await selectMenu(page).locator('input').fill(search)
await beat(page, 300)
await selectMenu(page).getByRole('button', { name: option }).first().click()
await expect(selectMenu(page)).toHaveCount(0)
}
/**
* Set a date without touching the calendar: every date field has a keyboard
* button beside it that swaps in a DD.MM.YYYY box, which is one fill instead of
* a month of grid cells that move with the trip's dates.
*/
async function setDate(page: Page, nth: number, value: string): Promise<void> {
await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click()
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
await box.fill(value)
await box.press('Enter')
await expect(box).toHaveCount(0)
}
interface Booking {
id: number
title: string
}
async function bookingsOf(page: Page): Promise<Booking[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: Booking[] } | Booking[]
return Array.isArray(body) ? body : (body.reservations ?? [])
}
/**
* Delete bookings by title. The route takes the stay of an accommodation and
* the expense linked to a booking with it, so this undoes those too.
*/
async function deleteBookings(page: Page, ...titles: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const booking of (await bookingsOf(page)).filter(b => titles.includes(b.title))) {
await page.request.delete(`/api/trips/${tripId}/reservations/${booking.id}`)
}
}
/** Put a changed booking back: drop it, then let the fixture build it again. */
async function restore(page: Page, ...titles: string[]): Promise<void> {
await deleteBookings(page, ...titles)
await ensureBookingsFixtures(page.request)
}
/**
* What the tab remembers: the filters per trip for the browser tab, the view,
* its grouping, sorting and timeline zoom, and the folded sections in the
* browser. Every test has a context of its own, so this is only tidiness for a
* guide that runs another after it in the same page.
*/
async function clearPanelState(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.evaluate(id => {
try {
sessionStorage.removeItem(`trek-bookings-filters-bookings-${id}`)
for (const name of ['view', 'group', 'sort', 'timeline', 'transitApart']) localStorage.removeItem(`trek:bookings-bookings-${name}`)
localStorage.removeItem(`trek:bookings-bookings-collapsed:${id}`)
localStorage.removeItem('trek.bg-import-tasks')
} catch {
/* a browser that refuses storage has nothing to clear */
}
}, tripId)
}
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> = {
'booking-views': {
guide: guide('booking-views'),
start: openBookings,
steps: [
{
// The three icons are the subject; the pointer rests on List so its name shows.
target: viewSwitch,
hover: async p => { await viewButton(p, 'List').hover() },
act: async p => {
await viewButton(p, 'List').click()
await expect(viewButton(p, 'List')).toHaveAttribute('aria-pressed', 'true')
await expect(p.getByRole('button', { name: VIEWED.title, exact: true })).toBeVisible()
await settle(p)
},
},
{
target: p => viewButton(p, 'Timeline'),
act: async p => {
await viewButton(p, 'Timeline').click()
await expect(zoom(p)).toBeVisible()
await expect(zoom(p).getByRole('button', { name: 'Trip', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
target: p => dayHeading(p, VIEWED.day),
act: async p => {
await dayHeading(p, VIEWED.day).click()
await expect(zoom(p).getByRole('button', { name: 'Day', exact: true })).toHaveAttribute('aria-pressed', 'true')
await expect(timelineBar(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
only(zoom),
{
// Hovered for the picture: the bar's card of facts stands beside it.
target: p => timelineBar(p, VIEWED.title),
act: async p => {
await timelineBar(p, VIEWED.title).click()
await expect(detail(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
only(p => detail(p, VIEWED.title)),
],
cleanup: async p => {
await closeModal(p)
await clearPanelState(p)
},
},
'create-booking': {
guide: guide('create-booking'),
start: openBookings,
steps: [
{
target: p => p.getByRole('button', { name: 'Manual Booking' }),
act: openManualBooking,
},
{
prepare: async p => {
await openTypeMenu(p)
},
target: typeMenu,
act: async p => {
await pickType(p, /^Event$/)
await expect(typePill(p)).toHaveAccessibleName(/^Booking Type:\s*Event$/)
await settle(p)
},
},
{
prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') },
target: titleBox,
act: async p => {
await expect(saveButton(p, 'Add')).toBeEnabled()
await settle(p)
},
},
{
prepare: async p => {
await setDate(p, 0, dotted(-2))
await timeBox(p).nth(0).fill('16:30')
await setDate(p, 1, dotted(-2))
await timeBox(p).nth(1).fill('20:00')
await settle(p)
},
target: p => row(p, /^Date$/),
act: async p => {
await expect(saveButton(p, 'Add')).toBeEnabled()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, codeBox(p), 'MZ-5521')
await statusPill(p).click()
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
},
target: p => row(p, /^Booking Code$/),
act: settle,
},
{
target: p => saveButton(p, 'Add'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'Kabuki at the Minamiza')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteBookings(p, 'Kabuki at the Minamiza'),
},
'booking-hotel': {
guide: guide('booking-hotel'),
start: openBookings,
steps: [
{
prepare: async p => {
await openManualBooking(p)
await openTypeMenu(p)
},
target: typeMenu,
act: async p => {
await pickType(p, /^Accommodation$/)
await expect(label(p, /^Check-in until$/)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await chooseIn(p, /^Accommodation$/, /^Hotel Granvia Kyoto$/, 'Granvia') },
target: p => block(p, /^Accommodation$/),
act: async p => {
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
await settle(p)
},
},
{
prepare: async p => {
await chooseIn(p, /^From$/, /^Day 5 /)
await chooseIn(p, /^To$/, /^Day 8 /)
},
target: p => row(p, /^To$/),
act: settle,
},
{
prepare: async p => {
await timeBox(p).nth(0).fill('15:00')
await timeBox(p).nth(1).fill('23:00')
await timeBox(p).nth(2).fill('11:00')
await typeInto(p, codeBox(p), 'GRK-40218')
},
target: p => row(p, /^Check-in$/),
act: settle,
},
{
target: p => saveButton(p, 'Add'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteBookings(p, HOTEL_TO_BOOK.name),
},
'link-booking': {
guide: guide('link-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'teamLab Planets timed entry', 'Edit'),
act: p => editFromCard(p, 'teamLab Planets timed entry'),
},
{
prepare: async p => {
await block(p, /^Link to day assignment$/).getByRole('button').first().click()
await expect(selectMenu(p)).toBeVisible()
await beat(p, 300)
},
target: selectMenu,
act: async p => {
await selectMenu(p).getByRole('button', { name: /^2\. teamLab Planets/ }).click()
await expect(selectMenu(p)).toHaveCount(0)
await expect(block(p, /^Link to day assignment$/).getByRole('button', { name: /teamLab Planets/ })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await chooseIn(p, /^Place \/ Activity$/, /^teamLab Planets$/, 'teamLab') },
target: p => block(p, /^Place \/ Activity$/),
act: settle,
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Linked to', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => restore(p, 'teamLab Planets timed entry'),
},
'booking-travelers': {
guide: guide('booking-travelers'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'Fushimi Inari night walk')
await expect(label(p, /^Travelers$/)).toBeVisible()
},
target: p => block(p, /^Travelers$/),
act: settle,
},
{
prepare: async p => {
await block(p, /^Travelers$/).getByRole('button', { expanded: false }).click()
await expect(travelerList(p)).toBeVisible()
await beat(p, 300)
},
target: travelerList,
act: async p => {
const person = travelerList(p).getByRole('button', { name: /jonas/ })
await person.click()
await expect(person).toHaveAttribute('aria-pressed', 'true')
// A click beside the field folds the list away; Escape would close the whole form.
await label(p, /^Travelers$/).click()
await expect(travelerList(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await filterButton(p).click()
await expect(filterMenu(p)).toBeVisible()
await settle(p)
},
// The people are a row of chips under the panel's Travelers caption.
target: p => filterMenu(p).getByRole('button', { name: /jonas$/ }).locator('xpath=..'),
act: async p => {
const person = filterMenu(p).getByRole('button', { name: /jonas$/ })
await person.click()
await expect(person).toHaveAttribute('aria-pressed', 'true')
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
await clearPanelState(p)
await restore(p, 'Fushimi Inari night walk')
},
},
'booking-files': {
guide: guide('booking-files'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'teamLab Planets timed entry')
await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible()
},
target: p => modal(p).getByRole('button', { name: 'Attach file' }),
act: async p => {
// Set the hidden input directly: clicking the button opens the file
// picker of the operating system, which Playwright cannot photograph.
await modal(p).locator('input[type="file"]').setInputFiles(bookingPdfFixture('teamlab-tickets'))
await expect(modal(p).getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => modal(p).getByText('teamlab-tickets.pdf').locator('xpath=..')),
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Link existing file' }).click()
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Link existing file' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true }).click()
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toHaveCount(0)
await expect(modal(p).getByText(UNATTACHED_FILE)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
// Deleting the booking takes the link row to the voucher with it, so the
// voucher is unattached again and Link existing file offers it next time.
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/files`)
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of files.filter(f => f.original_name === 'teamlab-tickets.pdf')) {
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
}
await restore(p, 'teamLab Planets timed entry')
},
},
'booking-cost': {
guide: guide('booking-cost'),
start: openBookings,
steps: [
{
prepare: async p => {
await editFromCard(p, 'Kyoto Cycling Tour')
await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible()
},
target: p => block(p, /^Costs$/),
act: settle,
},
{
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create expense' }).click()
await expect(p.getByRole('heading', { name: 'Add expense' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await block(p, /^Total amount$/).locator('input').first().fill('12000')
await settle(p)
},
target: p => row(p, /^Total amount$/),
act: settle,
},
only(p => section(p, 'Who paid?')),
{
target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }),
act: async p => {
const save = modal(p).getByRole('button', { name: 'Add expense', exact: true })
// The name comes prefilled from the booking; without it the button is dead.
await expect(save).toBeEnabled()
await save.click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
// The block only reads Linked expenses once the booking is opened again.
await editFromCard(p, 'Kyoto Cycling Tour')
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
// The block sits at the foot of a long form: bring it into the picture.
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await restore(p, 'Kyoto Cycling Tour')
},
},
'filter-bookings': {
guide: guide('filter-bookings'),
start: openBookings,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(resultsChip(p)).toBeVisible()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
await settle(p)
},
// The box around the field, with its magnifier.
target: p => searchBox(p).locator('xpath=..'),
act: async p => {
await searchBox(p).press('Escape')
await expect(searchBox(p)).toHaveValue('')
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
await settle(p)
},
},
{
target: filterButton,
act: async p => {
await filterButton(p).click()
await expect(filterMenu(p)).toBeVisible()
await settle(p)
},
},
{
// The three choices sit on one grey track under the Status caption.
target: p => filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).click()
await expect(bookingCard(p, 'Fushimi Inari night walk')).toHaveCount(0)
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
await settle(p)
},
},
{
target: p => filterMenu(p).getByRole('button', { name: /^Tour\b/ }),
act: async p => {
await filterMenu(p).getByRole('button', { name: /^Tour\b/ }).click()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
await settle(p)
},
},
{
target: resultsChip,
act: async p => {
await resultsChip(p).click()
await expect(resultsChip(p)).toHaveCount(0)
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'import-booking-file': {
guide: guide('import-booking-file'),
// The extractor, and no addon: with it answering, the dialog sends mode
// no-ai and no model is asked. The mail has to be an allowed file type or
// the review would save the booking and drop the document without a word.
start: async p => {
await requireExtractor(p.request)
await allowEmlUploads(p.request, true)
await openBookings(p)
},
steps: [
...importSteps(bookingEmlFixture, BOOKING_EML),
{
prepare: async p => {
// The card is there at once, spinning. The picture wants it finished:
// the tick and the Import it offers. The extractor has 30 s in the
// backend, and on a Windows media host that is a docker run, so the
// wait is generous rather than tight.
await expect(importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
await settle(p)
},
target: p => importTask(p, BOOKING_EML),
act: async p => {
await importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible({ timeout: 20_000 })
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
await expect(codeBox(p)).toHaveValue('GRK-40218')
await expect(modal(p).getByText(BOOKING_EML)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('dialog'),
act: async p => {
await saveButton(p, 'Add').click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await dismissImportTask(p, BOOKING_EML)
await allowEmlUploads(p.request, false)
await clearPanelState(p)
// The route takes the stay and the expense with the booking; the mail it
// attached stays behind in Files like any unlinked document.
await deleteBookings(p, HOTEL_TO_BOOK.name)
await deleteTripFiles(p, BOOKING_EML)
},
},
'edit-booking': {
guide: guide('edit-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'Kyoto Cycling Tour', 'Edit'),
act: p => editFromCard(p, 'Kyoto Cycling Tour'),
},
{
prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') },
target: p => block(p, /^Booking Code$/),
act: settle,
},
{
target: statusPill,
act: async p => {
await expect(statusPill(p)).toHaveAccessibleName('Set to Confirmed')
await statusPill(p).click()
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
await expect(statusPill(p)).toHaveText('Confirmed')
await settle(p)
},
},
{
target: p => saveButton(p, 'Update'),
act: async p => {
await saveButton(p, 'Update').click()
await expect(modal(p)).toHaveCount(0)
// A confirmed card's status dot offers the way back to Pending.
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Set to Pending' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => restore(p, 'Kyoto Cycling Tour'),
},
'delete-booking': {
guide: guide('delete-booking'),
start: openBookings,
steps: [
{
target: p => cardAction(p, 'Haneda Airport P4', 'Delete'),
act: async p => {
await cardAction(p, 'Haneda Airport P4', 'Delete').click()
await expect(deleteAsk(p)).toBeVisible()
await settle(p)
},
},
only(deleteAsk),
{
target: p => deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }),
act: async p => {
await deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => ensureBookingsFixtures(p.request),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureBookingsFixtures(request)
})
test('every registered bookings guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripBookingsContext.guides].sort())
})
test('hero: trip-bookings', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripBookingsContext.id, openBookings)
})
for (const id of tripBookingsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-496
View File
@@ -1,496 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ordinal, short } from '../dates'
import { SEED_CHAT } from '../screenshots/seed'
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
import { openTrip } from './trip-shared'
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Collab tab of a trip, keyed by the ids in
* `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan",
* whose collab addon already holds three notes, two polls and a seven message
* conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll
* for the one guide that closes a poll for good, and the two upcoming stops
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
* one deliberate exception: a deleted chat message cannot be undeleted, which is
* why `trip-chat` is the last guide of the screen.
*/
const guide = (id: string): HelpGuide => {
const g = tripCollabGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
const NOTE = {
title: 'Luggage forwarding',
body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
category: 'Transport',
website: 'https://www.global-yamato.com/en/hc/',
}
/** An .example host never resolves, so the chip shows its own glyph offline too. */
const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' }
const POLL = {
// Plain text on purpose: a bold run in the question would wrap the paragraph
// in one more element and move the card away from `pollCard`'s ancestor depth.
question: 'Which evening do we keep for the kaiseki dinner?',
options: [`${short(-5).split(',')[0]} ${ordinal(-5)}, after Fushimi Inari`, `${short(-4).split(',')[0]} ${ordinal(-4)}, after Arashiyama`, 'Either, decide on the day'],
}
/** The seeded poll nobody has voted on yet. */
const SEEDED_POLL = 'Ryokan or city hotel in Hakone?'
const CHAT = {
mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
emoji: 'Two weeks to go ',
flag: '🎌',
theirs: 'Drop bags. I want to be at Senso-ji before the crowds.',
answer: 'Agreed, bags at the hotel first, then Asakusa.',
reactTo: SEED_CHAT.teamlab,
reaction: '👍',
}
// ── Locators ─────────────────────────────────────────────────────────────────
/**
* One of the five cards of the tab, found from the name in its head band. The
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
* the match ignores case; the card is the nearest rounded ancestor (the cards
* inside a panel, notes and polls, are never an ancestor of that heading).
*/
const panel = (page: Page, name: RegExp) =>
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
const linksPanel = (page: Page) => panel(page, /^Links$/i)
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
/** The row that holds the emoji button, the image button, the box and the arrow. */
const composer = (page: Page) => chatBox(page).locator('xpath=..')
/**
* The quoted message above the box while a reply is being written. With no reply
* pending the same position is the composer row itself, which is what the step
* after the send asserts against.
*/
const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first()
/** The send button and the emoji button carry no text and no label at all. */
const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)')
const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)')
/** The emoji picker is a portal; its three category tabs are the sure thing in it. */
const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..')
/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */
const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last()
/** One message, by the relative wrapper that also carries its Reply and Delete. */
const bubble = (page: Page, text: string) =>
page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]')
/**
* Put the pointer on a message's Reply or Delete. The row holding them is
* `pointer-events: none` until its own bubble is hovered, and it sits at
* `top: -14px`, outside that bubble's box: hovering the button alone would land
* on whatever is behind it. `captureGuide` scrolls the target to the middle of
* the frame between `prepare` and the shot, and that scroll can take the bubble
* out from under the pointer, so the hover is made again here, bubble first.
*/
const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise<void> => {
await page.getByText(text).first().hover()
await beat(page, 300)
await bubble(page, text).getByRole('button', { name }).hover()
}
/**
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
* field only each of them has. The whole panel is the dialog: the head band
* with the title typed into it, the body, and the footer with the buttons.
*/
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
/** A field of an editor with the eyebrow label over it: the box's parent. */
const field = (box: Locator) => box.locator('xpath=..')
/** A note card is an article named by the note's title. */
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
/** A poll card is an article too, without a name: found from its question. */
const pollCard = (page: Page, question: string) =>
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
/** A What's Next row, from the place name in it: name → details → row. */
const nextRow = (page: Page, place: string) =>
nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..')
// ── Start and cleanup ────────────────────────────────────────────────────────
const start = async (page: Page): Promise<void> => {
await openTrip(page, { tab: 'collab' })
// The Collab panel is a lazy chunk: everything below needs it mounted.
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
async function listOf<T>(page: Page, path: string, key: string): Promise<T[]> {
const res = await page.request.get(`${collab()}/${path}`)
const body = (await res.json()) as Record<string, T[] | undefined>
return body[key] ?? []
}
async function deleteNote(page: Page, title: string): Promise<void> {
const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes')
for (const note of notes.filter(n => n.title === title)) {
await page.request.delete(`${collab()}/notes/${note.id}`)
}
}
async function deleteLink(page: Page, title: string): Promise<void> {
const links = await listOf<{ id: number; title: string }>(page, 'links', 'links')
for (const link of links.filter(l => l.title === title)) {
await page.request.delete(`${collab()}/links/${link.id}`)
}
}
async function deletePoll(page: Page, question: string): Promise<void> {
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
for (const poll of polls.filter(p => p.question === question)) {
await page.request.delete(`${collab()}/polls/${poll.id}`)
}
}
/**
* Voting for an option you already chose deletes the row, so one call puts the
* seeded, unvoted poll back the way the next guide expects it.
*/
async function resetVote(page: Page, question: string, optionIndex: number): Promise<void> {
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
const poll = polls.find(p => p.question === question)
if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } })
}
const only = (target: (page: Page) => Locator) => ({ target })
// ── Scripts ──────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'write-note': {
guide: guide('write-note'),
start,
steps: [
{
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
act: async page => {
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
await expect(noteDialog(page)).toBeVisible()
await settle(page)
},
},
{
// The title is typed into the dialog's head band, under the New Note eyebrow.
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
},
{
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
// The Content eyebrow and the box under it.
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
},
{
// Picked before the shot, so the pill and the head band already wear its colour.
prepare: async page => {
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
await beat(page, 300)
},
// The Category eyebrow and the row of pills: pill, row, field.
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
},
{
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
},
{
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
act: async page => {
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
cleanup: page => deleteNote(page, NOTE.title),
},
'shared-links': {
guide: guide('shared-links'),
start,
steps: [
{
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
act: async page => {
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
await expect(linkDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: async page => {
// The title sits in the head band, the address in the Link field under it.
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
await beat(page, 300)
},
target: linkDialog,
act: async page => {
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => { await linkChip(page, LINK.title).hover() },
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'),
},
{
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'),
act: async page => {
// Exact: an accessible name is matched as a substring, and "Unpin link"
// holds "Pin link", so the loose form would find both buttons.
await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click()
await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/)
await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title)
await settle(page)
},
},
],
cleanup: page => deleteLink(page, LINK.title),
},
'create-poll': {
guide: guide('create-poll'),
start,
steps: [
{
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
act: async page => {
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
await expect(pollDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
// The Question eyebrow, the box and the Markdown supported hint under it.
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
},
{
prepare: async page => {
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
},
// The Options field: box, its row, the column of rows, the field with its eyebrow.
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
},
{
// The label drops the string's own "+"; the button draws a plus icon instead.
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
act: async page => {
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
await beat(page, 300)
},
},
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
{
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
act: async page => {
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
cleanup: page => deletePoll(page, POLL.question),
},
'vote-poll': {
guide: guide('vote-poll'),
start,
steps: [
{
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }),
act: async page => {
await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click()
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
// Both bars and both percentages, so the step about reading them has them.
only(page => pollCard(page, SEEDED_POLL).locator('> div').last()),
{
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }),
act: async page => {
await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click()
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 })
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible()
await settle(page)
},
},
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
],
cleanup: page => resetVote(page, SEEDED_POLL, 0),
},
'close-poll': {
guide: guide('close-poll'),
start,
steps: [
{
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
act: async page => {
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
{
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
act: async page => {
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
],
// The last step normally already removed it; this is for a run that stopped short.
cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question),
},
'whats-next': {
guide: guide('whats-next'),
start,
steps: [
only(nextPanel),
// The time column is the first child of a row; the name, the address and the chips are its last.
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
],
// Read-only panel: the guide writes nothing.
},
'trip-chat': {
guide: guide('trip-chat'),
start,
steps: [
{
prepare: page => typeInto(page, chatBox(page), CHAT.mine),
target: composer,
act: async page => {
await chatBox(page).press('Enter')
await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await typeInto(page, chatBox(page), CHAT.emoji)
await emojiButton(page).click()
await expect(emojiPicker(page)).toBeVisible()
await beat(page, 300)
},
target: emojiPicker,
act: async page => {
await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click()
await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click()
await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`)
await sendButton(page).click()
await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
// The hover row is pointer-events:none until the bubble under it is hovered,
// so the bubble has to be hovered before the button can be reached at all.
prepare: async page => {
await page.getByText(CHAT.theirs).first().hover()
await beat(page, 300)
},
target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }),
hover: hoverMessageAction(CHAT.theirs, 'Reply'),
act: async page => {
await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click()
await expect(replyQuote(page)).toContainText(CHAT.theirs)
await settle(page)
},
},
{
prepare: page => typeInto(page, chatBox(page), CHAT.answer),
target: replyQuote,
act: async page => {
await chatBox(page).press('Enter')
await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 })
await expect(replyQuote(page)).not.toContainText(CHAT.theirs)
await settle(page)
},
},
{
prepare: async page => {
await page.getByText(CHAT.reactTo).first().click({ button: 'right' })
await expect(reactionMenu(page)).toBeVisible()
await beat(page, 300)
},
target: reactionMenu,
act: async page => {
await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click()
await expect(
bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }),
).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await page.getByText(CHAT.mine).first().hover()
await beat(page, 300)
},
target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }),
hover: hoverMessageAction(CHAT.mine, 'Delete'),
act: async page => {
await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click()
// The bubble shrinks for 400 ms before the request goes out.
await page.waitForTimeout(1200)
await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
],
// No cleanup, deliberately: deleting a message is a soft delete and there is
// no undelete over the API, so undoing anything here would only add more
// tombstones. This is why trip-chat is the last guide of the screen.
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureCollabFixtures(request)
})
test('every registered collab guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort())
})
test('hero: trip-collab', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripCollabContext.id, start)
})
for (const id of tripCollabContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-736
View File
@@ -1,736 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { day, long, short } from '../dates'
import { seededTrip, ensureCostsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Costs tab of a trip, keyed by the ids in
* `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan",
* which keeps its books in JPY with one expense in EUR and one transfer already
* recorded, so the frozen rate and the settle-up column both have something
* real to show. What a guide writes it takes away again in `cleanup`, the
* settlements included, because the next guide reads the same balances.
*/
const guide = (id: string): HelpGuide => {
const g = tripCostsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */
const UNPAID = 'Airport transfer (Narita Express)'
/** The seeded expense in euros, the one carrying a frozen rate. */
const SEEDED_EUR = 'Flights FRA → HND'
/** The seeded flight booking, on the Transports tab because its type is flight. */
const BOOKING = 'LH716 FRA → HND'
/** A seeded expense costs-table gives its days to. */
const JR_PASS = 'JR Pass (14 days)'
const DINNER = 'Kaiseki dinner in Gion'
const UPGRADE = 'Shinkansen seat upgrade'
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: day(-4) }
/** The row costs-table adds to Food & drink; the table names a new row this until it is renamed. */
const MATCHA = 'Matcha in Uji'
const NEW_ROW = 'New Entry'
/** Where the tab keeps the list/table choice (CostsPanel's COSTS_VIEW_KEY). */
const VIEW_KEY = 'trek:costs-view'
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
const openCosts = async (p: Page): Promise<void> => {
await openTrip(p, { tab: 'finanzplan' })
await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 })
await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 })
await settle(p)
}
/** A row of the ledger by the expense's name. */
const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first()
/**
* The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child,
* so a locator scoped to the row itself would never find it.
*/
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
/**
* The transfer settle-up records for jonas, in the display currency. The seed
* already holds one from jonas, in euros, which carries the converted line;
* leaving that one out keeps the guide off the seed's own payment.
*/
const newPayRow = (page: Page) =>
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: 'jonas' }).filter({ hasNotText: '€' }).first()
/**
* A block of the expense editor, found by its own label: the <section> of
* Who paid? and Split, the grey group that holds Total amount, Currency and Day.
*/
const block = (page: Page, label: string) =>
modal(page)
.getByText(label, { exact: true })
.first()
.locator('xpath=ancestor::*[self::section or contains(@class,"rounded-[16px]")][1]')
/** A card of the right-hand column: each is a <section> named by its heading. */
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
/** The name, typed into the head of the expense editor. */
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
/** The category pill under the name, and the list it opens (a portal named after it). */
const categoryPill = (page: Page) => modal(page).getByRole('button', { name: /^Category:/ })
const categoryList = (page: Page) => page.getByRole('group', { name: 'Category', exact: true })
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
/**
* One suggested transfer in the Settle up card, by the initial on its first
* avatar ("J" for jonas; the names are only in the tooltip). The card lists the
* flows in whatever order the settlement endpoint returns them, so taking the
* first Settle button would settle whichever transfer happened to come first.
*/
const settleRow = (page: Page, initial: string) =>
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
// ── The bar's filters ────────────────────────────────────────────────────────
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
const filterButton = (page: Page) => page.getByRole('button', { name: 'Filter', exact: true })
/** The popover the funnel opens: the owner switch, the categories and the days. */
const filterMenu = (page: Page) => page.getByRole('menu')
async function openFilter(page: Page): Promise<void> {
if (!(await filterMenu(page).isVisible().catch(() => false))) await filterButton(page).click()
await expect(filterMenu(page)).toBeVisible()
await beat(page, 300)
}
async function closeFilter(page: Page): Promise<void> {
if (await filterMenu(page).isVisible().catch(() => false)) await filterButton(page).click()
await expect(filterMenu(page)).toHaveCount(0)
}
// ── The table view ───────────────────────────────────────────────────────────
const table = (page: Page) => page.getByRole('table')
/** A category's group of the table: a <tbody> opened by a button with its name and count. */
const group = (page: Page, category: string) =>
table(page)
.locator('tbody')
.filter({ has: page.getByRole('button', { name: new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\d+$`) }) })
const tableRow = (page: Page, name: string) => table(page).getByRole('row').filter({ hasText: name }).first()
const summary = (page: Page) => page.getByRole('region', { name: 'Summary', exact: true })
const only = (target: (p: Page) => Locator) => ({ target })
/**
* A dialog that closes hands the focus back to the button that opened it, and
* a focused button wears its outline and its tooltip, the tooltip at wherever
* the button stood before the next picture scrolled: both would stand in that
* picture as if they were part of it.
*/
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
const closeModal = async (page: Page): Promise<void> => {
if (await modal(page).isVisible().catch(() => false)) {
await page.keyboard.press('Escape')
await expect(modal(page)).toHaveCount(0)
await dropFocus(page)
}
}
type BudgetRow = { id: number; name: string; days?: number | null }
/** What the JR Pass's Days held before costs-table typed into it; cleanup puts it back. */
let passDaysBefore: number | null = null
async function budgetItems(page: Page): Promise<BudgetRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/budget`)
return ((await res.json()) as { items: BudgetRow[] }).items
}
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const item of (await budgetItems(page)).filter(i => names.includes(i.name))) {
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
}
}
async function createExpense(
page: Page,
item: { name: string; category: string; total_price: number; currency: string; expense_date: string },
): Promise<void> {
const { tripId, memberIds } = seededTrip()
const created = await page.request.post(`/api/trips/${tripId}/budget`, {
data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] },
})
if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`)
}
async function settlementIds(page: Page): Promise<number[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`)
const { settlements } = (await res.json()) as { settlements: { id: number }[] }
return settlements.map(s => s.id)
}
/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */
let transfersBefore: number[] = []
/** Open the expense editor's currency list, search for a code and take it. */
async function pickCurrency(page: Page, code: string): Promise<void> {
await modal(page).getByRole('button', { name: /^JPY/ }).first().click()
await beat(page, 300)
await dropdown(page).locator('input').first().fill(code)
await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click()
await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible()
await settle(page)
}
const SCRIPTS: Record<string, GuideScript> = {
'add-expense': {
guide: guide('add-expense'),
start: openCosts,
steps: [
{
target: addExpense,
act: async p => {
await addExpense(p).click()
await expect(nameBox(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, nameBox(p), DINNER)
await settle(p)
},
target: nameBox,
},
{
// The list open under the pill, so the picture shows what there is to pick.
prepare: async p => {
await categoryPill(p).click()
await expect(categoryList(p)).toBeVisible()
await beat(p, 300)
},
target: categoryPill,
act: async p => {
await categoryList(p).getByRole('button', { name: 'Food & drink' }).click()
await expect(categoryList(p)).toHaveCount(0)
await expect(categoryPill(p)).toHaveAccessibleName(/Food & drink/)
await settle(p)
},
},
{
prepare: async p => {
await amounts(p).first().fill('18600')
await expect(saveExpense(p)).toBeEnabled()
await settle(p)
},
// The grey group: Total amount, then Currency and Day beside it.
target: p => block(p, 'Total amount'),
},
only(p => block(p, 'Who paid?')),
{
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => deleteExpenses(p, DINNER),
},
'expense-payers': {
guide: guide('expense-payers'),
start: openCosts,
steps: [
{
prepare: async p => {
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Who paid?')).toBeVisible()
await settle(p)
},
target: p => block(p, 'Who paid?'),
},
// Ringed, not clicked: the Ryokan keeps its payer.
only(p => modal(p).getByRole('radio', { name: 'No one paid yet' })),
{
prepare: async p => {
await block(p, 'Who paid?').getByRole('button', { name: 'Multiple people paid' }).click()
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
await modal(p).getByTestId('payer-toggle').nth(1).click()
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
await settle(p)
},
target: p => block(p, 'Who paid?'),
act: async p => {
await block(p, 'Who paid?').getByRole('button', { name: 'One person paid' }).click()
await closeModal(p)
await settle(p)
},
},
{
target: p => expRow(p, UNPAID),
// On the flag, whose tooltip says what it means; the row's centre is the note.
hover: async p => {
await expRow(p, UNPAID).getByText('Unfinished').hover()
},
act: async p => {
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
},
},
],
},
'split-expense': {
guide: guide('split-expense'),
start: async p => {
await createExpense(p, LUNCH)
await openCosts(p)
},
steps: [
{
prepare: async p => {
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Split')).toBeVisible()
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'false')
await settle(p)
},
target: p => block(p, 'Split'),
act: async p => {
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
// The badges under the list: how many share it, and what one share is.
only(p => block(p, 'Split').getByText(/ per person$/).locator('xpath=..')),
{
prepare: async p => {
await block(p, 'Split').getByRole('button', { name: 'Custom', exact: true }).click()
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(0).fill('1000')
await expect(block(p, 'Split').getByText(/^Sum of splits: /)).toBeVisible()
await settle(p)
},
target: p => block(p, 'Split').getByText(/^Sum of splits: /),
act: async p => {
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(1).fill('1900')
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(2).fill('1900')
await expect(block(p, 'Split').getByText('Split matches total')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const split = block(p, 'Split')
await split.getByRole('button', { name: 'Ticket', exact: true }).click()
await split.getByRole('button', { name: 'Add item' }).click()
await typeInto(p, split.getByPlaceholder('Item name').first(), 'Tamagoyaki')
await split.locator('input[inputmode="decimal"]').nth(0).fill('1200')
await split.getByRole('button', { name: 'Add item' }).click()
await typeInto(p, split.getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
await split.locator('input[inputmode="decimal"]').nth(1).fill('900')
// The second line is shared by two of the three travelers.
await split
.getByPlaceholder('Item name')
.nth(1)
.locator('xpath=ancestor::div[2]')
.getByRole('checkbox', { name: /jonas/ })
.click()
await expect(amounts(p).first()).toHaveValue('2100.00')
await settle(p)
},
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
},
{
target: p => modal(p).getByText('Individual shares', { exact: true }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
],
cleanup: p => deleteExpenses(p, LUNCH.name),
},
'expense-currency': {
guide: guide('expense-currency'),
start: openCosts,
steps: [
{
prepare: async p => {
await addExpense(p).click()
await expect(nameBox(p)).toBeVisible()
await typeInto(p, nameBox(p), UPGRADE)
await amounts(p).first().fill('48')
// The editor opens on Food & drink; a seat upgrade filed under it would
// read wrong on the ledger row this guide's last picture is of.
await categoryPill(p).click()
await categoryList(p).getByRole('button', { name: 'Transport', exact: true }).click()
await expect(categoryPill(p)).toHaveAccessibleName(/Transport/)
await settle(p)
},
target: p => block(p, 'Total amount'),
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: /^JPY/ }).first().click()
await beat(p, 300)
await dropdown(p).locator('input').first().fill('EUR')
await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible()
},
target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(),
act: async p => {
await dropdown(p).getByRole('button', { name: /^EUR/ }).click()
await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible()
await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'),
act: async p => {
// Both sides have to be there: an offline run would show the amount unconverted.
await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/)
},
},
{
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: p => expRow(p, UPGRADE),
act: async p => {
await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
},
},
],
cleanup: p => deleteExpenses(p, UPGRADE),
},
'filter-costs': {
guide: guide('filter-costs'),
start: openCosts,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'pass')
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
await settle(p)
},
target: p => searchBox(p).locator('xpath=..'),
act: async p => {
await searchBox(p).fill('')
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
await settle(p)
},
},
{
// The owner switch at the top of the popover, on Paid by me.
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).click()
await expect(filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
target: p => filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'All', exact: true }).click()
await settle(p)
},
},
{
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Transport', exact: true }).click()
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
await settle(p)
},
// The category list, with the pick marked and the funnel's count above it.
target: p => filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).locator('xpath=..'),
act: async p => {
await filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).click()
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: short(-9), exact: true }).click()
// Folded away, or the popover would sit over the banner it produced.
await closeFilter(p)
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toBeVisible()
await settle(p)
},
target: p => p.getByText(new RegExp(`^${long(-9)}$`)).locator('xpath=../..'),
act: async p => {
await openFilter(p)
await filterMenu(p).getByRole('button', { name: 'Reset filters' }).click()
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toHaveCount(0)
await closeFilter(p)
await settle(p)
},
},
// Clicking it would start a download; the button is what the step is about.
only(p => p.getByRole('button', { name: 'Export CSV' })),
],
},
'settle-up': {
guide: guide('settle-up'),
start: async p => {
await openCosts(p)
transfersBefore = await settlementIds(p)
},
steps: [
only(p => card(p, 'Settle up')),
{
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
act: async p => {
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(newPayRow),
{
target: p => newPayRow(p).locator('xpath=..').locator('.exp-actions'),
act: async p => {
await newPayRow(p).locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
await settle(p)
},
target: p => p.getByRole('dialog', { name: 'Add payment' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add payment' }).click()
await expect(modal(p)).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
// Ringed and left alone: one click would record every open transfer and
// flatten the balances every later picture is taken against.
only(p => p.getByRole('button', { name: 'Settle up', exact: true })),
],
cleanup: async p => {
const { tripId } = seededTrip()
for (const id of await settlementIds(p)) {
if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`)
}
},
},
'final-budget': {
guide: guide('final-budget'),
start: openCosts,
steps: [
only(p => card(p, 'Balances')),
only(p => card(p, 'Final budget')),
{
prepare: async p => {
await card(p, 'Final budget').getByRole('button').first().click()
await expect(p.getByText('Expenses paid').first()).toBeVisible()
await settle(p)
},
target: p => card(p, 'Final budget').locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
},
{
// The second "Expenses paid" is the heading of the list the figure is made of.
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
act: async p => {
await card(p, 'Final budget').getByRole('button').first().click()
await expect(card(p, 'Final budget').locator('[aria-expanded="true"]')).toHaveCount(0)
},
},
],
},
'expense-from-booking': {
guide: guide('expense-from-booking'),
start: async p => {
await openTrip(p, { tab: 'transports' })
await expect(p.getByRole('article', { name: BOOKING }).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
},
steps: [
{
target: p => p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
await settle(p)
},
},
only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')),
{
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create expense' }).click()
await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await amounts(p).first().fill('890')
await pickCurrency(p, 'EUR')
},
target: saveExpense,
act: async p => {
await saveExpense(p).click()
await expect(modal(p)).toHaveCount(0)
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
// The block sits at the foot of a long form: bring it into the picture.
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await deleteExpenses(p, BOOKING)
},
},
'costs-table': {
guide: guide('costs-table'),
start: async p => {
// What the pass's Days cell holds before the guide types into it.
const pass = (await budgetItems(p)).find(i => i.name === JR_PASS)
if (!pass) throw new Error(`the seed has no "${JR_PASS}"`)
passDaysBefore = pass.days ?? null
await openCosts(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Table', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Table', exact: true }).click()
await expect(table(p)).toBeVisible({ timeout: 15_000 })
await expect(tableRow(p, SEEDED_EUR)).toBeVisible()
await settle(p)
},
},
// One category as a whole: its head with the count and the subtotal, its rows, its add button.
only(p => group(p, 'Transport')),
{
// The pass is good for fourteen days, and its Days cell is still empty:
// typed in, Per Day and P. p / Day fill in beside it.
prepare: async p => {
await tableRow(p, JR_PASS).getByRole('button', { name: /^Days/ }).click()
const field = tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' })
await expect(field).toBeFocused()
await field.fill('14')
await settle(p)
},
target: p => tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }),
act: async p => {
await tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }).press('Enter')
await expect(tableRow(p, JR_PASS).getByRole('button', { name: 'Days: 14' })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// Hovered for the picture, so the lock's reason stands above it.
target: p => tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }),
act: async p => {
await tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }).click()
await expect(p.getByRole('dialog', { name: 'Edit expense' })).toBeVisible()
await closeModal(p)
await settle(p)
},
},
{
target: p => group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }),
act: async p => {
await group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }).click()
const name = table(p).getByRole('textbox', { name: 'Name' })
await expect(name.or(tableRow(p, NEW_ROW)).first()).toBeVisible({ timeout: 15_000 })
if (!(await name.isVisible().catch(() => false))) {
await tableRow(p, NEW_ROW).getByRole('button', { name: /^Name/ }).click()
}
await name.fill(MATCHA)
await name.press('Enter')
await expect(tableRow(p, MATCHA)).toBeVisible({ timeout: 15_000 })
// A total too, the same way, so the row reads like a real one.
await tableRow(p, MATCHA).getByRole('button', { name: /^Total/ }).click()
const total = tableRow(p, MATCHA).getByRole('textbox', { name: 'Total' })
await total.fill('1800')
await total.press('Enter')
await expect(tableRow(p, MATCHA).getByRole('button', { name: /^Total: .*1,800/ })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await summary(p).getByRole('button', { name: 'Payer', exact: true }).click()
await expect(summary(p).getByText('No payer yet')).toBeVisible()
await settle(p)
},
target: summary,
},
],
// Back to the ledger, so every other guide finds it, and the seed as it was.
cleanup: async p => {
const list = p.getByRole('button', { name: 'List', exact: true })
if (await list.isVisible().catch(() => false)) await list.click()
await p.evaluate(key => { try { localStorage.setItem(key, 'list') } catch { /* no storage: nothing to put back */ } }, VIEW_KEY)
await deleteExpenses(p, MATCHA, NEW_ROW)
const { tripId } = seededTrip()
for (const item of (await budgetItems(p)).filter(i => i.name === JR_PASS)) {
await p.request.put(`/api/trips/${tripId}/budget/${item.id}`, { data: { days: passDaysBefore } })
}
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureCostsFixtures(request)
})
test('every registered costs guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort())
})
test('hero: trip-costs', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripCostsContext.id, openCosts)
})
for (const id of tripCostsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-363
View File
@@ -1,363 +0,0 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures'
import { openTrip } from './trip-shared'
import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the day details panel of a trip, keyed by the ids in
* `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in
* Japan" with the day fixtures in place, so day 1 carries the flight and the
* Tokyo hotel and day 5 the train and its evening walk; what a guide writes it
* takes back in `cleanup`, so the next one starts from the same seed.
*/
const guide = (id: string): HelpGuide => {
const g = tripDayDetailGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The name the rename guide gives day 5, and takes away again. */
const DAY_NAME = 'Arrival in Kyoto'
/** The panel itself: the only `fixed z-50` in the client. */
const panel = (page: Page) => page.locator('div.fixed.z-50')
/** A day's header in the days column; its name is number, weather, title, date. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* The hotel picker is a portal of its own with neither `trek-modal-backdrop`
* nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it;
* the card is the second `role="presentation"` and holds the day range.
*/
const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' })
/** The row of the stay's three times, with Confirmation right under it. */
const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]')
/**
* A block of the panel by its heading. Every block is a <section> whose first
* row carries the heading, so the section is the block with everything in it.
*/
const block = (page: Page, heading: string) =>
panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
/** The accommodation block: the stays and the add button. */
const stays = (page: Page) => block(page, 'Accommodation')
/** One booked stay: an <article> card inside that block. */
const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first()
/** The caption that opens the weather block. */
const forecast = (page: Page) => panel(page).getByText(/^Forecast for /)
/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */
const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]')
/** The summary row's numbers: the temperature, the low and high, the condition. */
const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]')
/** The hourly strip under its own heading. */
const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..')
/** The day's reservations block. */
const bookings = (page: Page) => block(page, 'Reservations')
/** One booking row: on the desktop a button that opens the booking, named with its title. */
const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title })
/** The booking a row opens, named by its title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/**
* Select a day by its title rather than by the middle of its header: a day with
* a night booked over it carries a hotel button there, and that button opens the
* place instead of selecting the day.
*/
const selectDay = async (page: Page, n: number): Promise<void> => {
await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click()
await expect(panel(page)).toBeVisible()
await settle(page)
}
/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */
const openDay = async (page: Page, n: number): Promise<void> => {
await openTrip(page, { day: null })
await selectDay(page, n)
}
interface Stay {
id: number
place_id: number | null
place_name?: string | null
start_day_id: number
end_day_id: number
}
async function stayOf(page: Page, name: string): Promise<Stay | undefined> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/accommodations`)
const { accommodations } = (await res.json()) as { accommodations: Stay[] }
return accommodations.find(a => a.place_name === name)
}
/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */
async function removeStay(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const stay = await stayOf(page, name)
if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`)
}
/** The update route reads every field off the body, so the whole stay goes back. */
async function restoreTokyoStay(page: Page): Promise<void> {
const { tripId } = seededTrip()
const stay = await stayOf(page, TOKYO_HOTEL)
if (!stay) return
await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, {
data: {
place_id: stay.place_id,
start_day_id: stay.start_day_id,
end_day_id: stay.end_day_id,
check_in: '15:00',
check_in_end: null,
check_out: '11:00',
confirmation: 'GRC-7731',
},
})
}
/**
* Give day 5 its number back: `selectDay` finds a day by "5 … Day 5 ", which a
* renamed day no longer answers to, and every later guide uses it.
*/
async function resetDayTitle(page: Page, index: number): Promise<void> {
const { tripId, dayIds } = seededTrip()
await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } })
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'day-panel': {
guide: guide('day-panel'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => dayHeader(p, 1),
act: async p => {
await selectDay(p, 1)
await expect(stays(p)).toBeVisible()
},
},
only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')),
{
// The forecast is asked for when the panel opens, and it is a round trip
// to Open-Meteo; without the wait the picture catches the spinner.
prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) },
target: p => forecast(p).locator('xpath=..'),
},
only(bookings),
only(stays),
{
target: p => panel(p).getByRole('button', { name: 'Collapse' }),
act: async p => {
// Every day card carries a chevron with the same label, so the panel's
// own is reached through the panel.
await panel(p).getByRole('button', { name: 'Collapse' }).click()
await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible()
await expect(stays(p)).toBeHidden()
await settle(p)
},
},
],
},
'day-weather': {
guide: guide('day-weather'),
start: async p => {
await openDay(p, 5)
// Day 5 has one located stop, so the forecast is that stop's.
await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 })
},
steps: [
only(forecast),
only(summary),
only(chips),
only(hourly),
],
},
'rename-day': {
guide: guide('rename-day'),
start: p => openDay(p, 5),
steps: [
only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')),
{
// Every booking row of the open day carries an Edit of its own in the
// days column (DayPlanSidebar), so the header's pencil is reached
// through the panel; inside it, it is the only one.
target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await panel(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) },
target: p => panel(p).getByPlaceholder('Day 5'),
act: async p => {
await p.keyboard.press('Enter')
await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible()
await settle(p)
},
},
only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })),
],
cleanup: p => resetDayTitle(p, 4),
},
'add-accommodation': {
guide: guide('add-accommodation'),
start: p => openTrip(p, { day: null }),
steps: [
// The fixture put the hotel in the trip as a place; the picker offers
// nothing else, and the rows of the column are options.
only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })),
{
prepare: async p => { await selectDay(p, 5) },
target: p => p.getByRole('button', { name: 'Add accommodation' }),
act: async p => {
await p.getByRole('button', { name: 'Add accommodation' }).click()
await expect(picker(p)).toBeVisible()
await settle(p)
},
},
{
// The two day selects are named Start and End; their options are
// portalled to the body, outside the picker.
prepare: async p => {
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
await p.getByRole('button', { name: /^Day 7/ }).last().click()
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
await settle(p)
},
// The row of Start, End and All: the All button's parent.
target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
},
{
// The three time fields normalise what is typed; the labels carry no
// `for`, so every field is found by its placeholder.
prepare: async p => {
await picker(p).getByPlaceholder('14:00').fill('15:00')
await picker(p).getByPlaceholder('22:00').fill('20:00')
await picker(p).getByPlaceholder('11:00').fill('11:00')
await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842')
await settle(p)
},
target: times,
},
{
// The category is a pill over the list; its choices open in a list of their own.
prepare: async p => {
await picker(p).getByRole('button', { name: /^Category:/ }).click()
await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click()
await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0)
await settle(p)
},
target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }),
act: async p => {
await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click()
await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled()
},
},
{
target: p => picker(p).getByRole('button', { name: 'Save' }),
act: async p => {
await picker(p).getByRole('button', { name: 'Save' }).click()
await expect(picker(p)).toHaveCount(0)
await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: p => removeStay(p, KYOTO_HOTEL.name),
},
'edit-accommodation': {
guide: guide('edit-accommodation'),
start: async p => {
// The day fixtures book the Tokyo hotel over the first nights, so day 1
// is its check-in day.
await openDay(p, 1)
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
},
steps: [
only(p => stayCard(p, TOKYO_HOTEL)),
{
// Neither the pencil nor the × carries a label; the icon is the handle.
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'),
act: async p => {
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await picker(p).getByPlaceholder('11:00').fill('10:00')
await picker(p).getByPlaceholder('ABC-12345').click()
await settle(p)
},
target: times,
},
{
target: p => picker(p).getByRole('button', { name: 'Save' }),
act: async p => {
await picker(p).getByRole('button', { name: 'Save' }).click()
await expect(picker(p)).toHaveCount(0)
// Day 1 is the arrival: the card shows the check-in window, not the new check-out.
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// Deliberately not performed: the × removes the stay without a question,
// and with it the seed's hotel booking and the stop it put on the day.
prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() },
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'),
},
],
cleanup: restoreTokyoStay,
},
'day-bookings': {
guide: guide('day-bookings'),
start: p => openDay(p, 5),
steps: [
only(bookings),
only(p => bookingRow(p, EVENING_WALK)),
only(p => bookingRow(p, 'Nozomi 21')),
{
// A row opens its booking over the plan; Edit at its foot is where it changes.
prepare: async p => {
await bookingRow(p, 'Nozomi 21').click()
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
},
],
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureDayDetailFixtures(request)
})
test('every registered day-detail guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort())
})
test('hero: trip-day-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
// Day 1 is the one day that has all four blocks at once: the forecast for its
// first stop, the flight, and the hotel it checks into.
await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1))
})
for (const id of tripDayDetailContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-550
View File
@@ -1,550 +0,0 @@
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, openTripOnDay, modal, portalDialog, selectDay, closeDayDetails, closeMenu, dayDetails } 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' }
/** The note the day-note guide writes: a name for the card, markdown underneath. */
const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' }
/** A day card's header button. */
/**
* The day the route guides read. Day 6 is the only one whose two stops sit next
* to each other: day 1's are separated by the flight, so it has no leg of its
* own and its only connector is the one to the booked night, which depends on a
* fixture. Day 6 also carries a reservation at a stop, so one card shows every
* row the guide talks about.
*/
const ROUTED_DAY = 6
/** The pencil that renames the open day, in its details panel. */
const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true })
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* A stop row. Every row of a day card is a div with `role="button"`; a stop is
* the one whose accessible name opens with the label of the lock on its
* picture, which is what tells it from the note and booking rows beside it.
*/
const stop = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first()
/** A booking row of a day: its phase (Departure / Arrival) and title make its name. */
const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name })
/** The note the day-note guide left on day 2, as a row of the card. */
const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) })
/** A row of the places column. */
const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** The "+ Day" at the end of a places row: it puts the place on the open day. */
const toOpenDay = (page: Page, name: string) => placeRow(page, name).getByRole('button', { name: '+ Day' })
/** The "+" in a day's head band; the note, the transports and the stay are in the menu it opens. */
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
/**
* The context menu of a stop, a note or a leg. Tooltip renders into the same
* `.trek-popover-enter` class and a click on a connector leaves its tooltip
* standing, so the menu is the popover that is not one.
*/
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
/** The route bar of the open day: Route, the two hand-offs, Optimize, the modes. */
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. The hotel
* bookend legs carry the same label and are not between two stops, so they are
* filtered out by the hotel icon only they have.
*/
/**
* A leg between two rows of a day, ringed by its travel time. Day 1's is the one
* to the booked night: the flight sits between its two stops, so they have no
* leg of their own, and the day fixtures put a hotel on the first four nights.
*/
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
/** The note dialog, known by its body field. */
const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…')
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) })
/** The question a timed stop asks before it moves; also a portal of its own. */
const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?'))
/** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */
const reorderPopup = (page: Page) => page.locator('.trek-modal-enter').filter({ hasText: 'Reorder days' })
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. The day stays selected with its details panel up: the panel is
* the selection, and the route tools render for the selected day only.
*/
async function openWithRoute(page: Page): Promise<void> {
await openTripOnDay(page, ROUTED_DAY)
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 })
/** The menu a right-click on a day's head opens. */
/** The question before a day is cleared (a ConfirmDialog, which carries no dialog role). */
const clearDialog = (page: Page) => page.locator('.trek-modal-enter').filter({ has: page.getByRole('heading', { name: /^Clear .+\?$/ }) }).last()
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
const SCRIPTS: Record<string, GuideScript> = {
'clear-day': {
guide: guide('clear-day'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await dayHeader(p, 2).click({ button: 'right' })
await expect(dayMenu(p)).toBeVisible()
await beat(p, 300)
},
target: dayMenu,
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Clear day' }).click()
await expect(p.getByRole('heading', { name: /^Clear .+\?$/ })).toBeVisible()
await settle(p)
},
},
{
target: clearDialog,
act: async p => {
await clearDialog(p).getByRole('button', { name: 'Clear day' }).click()
await expect(clearDialog(p)).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
// Undo puts the seed's stops back on the day for the guides after this one.
const undo = p.getByRole('button', { name: 'Undo' }).last()
if (await undo.isVisible()) await undo.click()
await settle(p)
},
},
'read-day-plan': {
guide: guide('read-day-plan'),
start: openWithRoute,
steps: [
only(p => dayHeader(p, ROUTED_DAY)),
only(p => stop(p, 'Arashiyama Bamboo Grove')),
only(p => stop(p, 'Nishiki Market')),
only(connector),
only(routeBar),
],
},
'place-onto-day': {
guide: guide('place-onto-day'),
start: async p => {
await createSpare(p)
// Day 1 stays open: the "+" of a place row and "To day" exist only while
// a day is selected, and steps 2 and 4 are about those two.
await openTripOnDay(p, 1)
},
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)
},
},
{
// The "+" offers the OPEN day, so it is still there with the place on
// day 2 — and the place stays on a day, which is what the result says.
prepare: async p => { await placeRow(p, SPARE.name).hover() },
target: p => toOpenDay(p, SPARE.name),
},
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 => {
// Only a move that breaks the day's chronology asks, and the arrows
// never do here: the timed stop is the first row of the day. Dropping
// it below the 13:05 departure does, and Cancel leaves the day alone.
const booking = bookingRow(p, /Departure.*LH716/)
const box = await booking.boundingBox()
if (!box) throw new Error('the departure row of day 1 has no box')
await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } })
await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: timeConfirm,
act: async p => {
await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByText('Remove time?')).toHaveCount(0)
await settle(p)
},
},
],
},
'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)
// The "+" puts the place on the OPEN day, so day 1 stays selected.
await openTripOnDay(p, 1)
await placeRow(p, SPARE.name).hover()
await toOpenDay(p, SPARE.name).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: [
{
// The "+" of the day's head band opens the menu; Add Note is its last entry.
prepare: async p => {
await addToDay(p, 2).click()
await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible()
await beat(p, 300)
},
target: p => menu(p).getByRole('button', { name: 'Add Note' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Add Note' }).click()
await expect(noteBody(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
// The name is typed into the head band and is what the day card shows,
// the only required field; Daily Note is the markdown the toolbar formats.
await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title)
await typeInto(p, noteBody(p), NOTE.body)
await beat(p, 300)
},
target: noteDialog,
},
{
target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'),
act: async p => {
await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(noteDialog(p)).toHaveCount(0)
await expect(noteRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
prepare: async p => {
await noteRow(p).click({ button: 'right' })
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
await beat(p, 300)
},
target: noteRow,
act: closeMenu,
},
],
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(NOTE.title))) {
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
}
},
},
'day-route': {
guide: guide('day-route'),
start: p => openTripOnDay(p, ROUTED_DAY),
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: closeMenu,
},
only(p => p.getByRole('button', { name: 'Optimize' })),
// The two hand-offs are siblings with no wrapper of their own: their
// parent is the whole route bar, which is already read-day-plan's step 5.
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
],
},
'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)
},
// The whole popup: the rows with their arrows and bins, and the two ways
// of adding a day in its footer.
target: reorderPopup,
},
{
// The question before a day goes, answered with Cancel: the picture shows
// what would go with the second day, its places and its landing, and the
// seed keeps all of its days.
prepare: async p => {
await reorderPopup(p).getByRole('button', { name: 'Delete day' }).nth(1).click()
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toBeVisible()
await settle(p)
},
target: reorderPopup,
act: async p => {
await reorderPopup(p).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toHaveCount(0)
await reorderPopup(p).getByRole('button', { name: 'Close', exact: true }).last().click()
await expect(reorderPopup(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
// The steps before leave the first day selected, and a click on a
// selected day folds its panel away: a second one brings it back.
await dayHeader(p, 1).click({ position: { x: 22, y: 20 } })
await settle(p)
if (!(await dayDetails(p).isVisible())) await selectDay(p, 1)
await expect(renameDay(p)).toBeVisible()
await settle(p)
},
// Scoped to the panel: a stop row carries an Edit of its own, so the
// bare name matches two buttons as soon as any day is unfolded. The
// ring goes round the panel's head band, title and pencil together: a
// ring round the pencil alone sits on the day's name beside it.
target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'),
hover: p => renameDay(p).hover(),
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: [
// The flight, not the train: Departure and Arrival are what a booking
// gets when it crosses days, and the train starts and ends on one.
only(p => bookingRow(p, /^LH716 FRA → HND Departure/)),
{
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=ancestor::section[1]'),
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=ancestor::section[1]')),
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')),
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')),
],
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)
// The plan as it opens: the details panel is trip-day-detail's subject and the
// route is day-route's, so the hero shows neither.
await captureHero(page, tripDaysContext.id, p => openTrip(p))
})
for (const id of tripDaysContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-616
View File
@@ -1,616 +0,0 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Files tab of a trip, keyed by the ids in
* `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so
* `ensureFilesFixtures` puts a small set of drawn ones in before the first
* picture; what a guide uploads, links, stars or throws away it undoes in
* `cleanup`, so the next one starts from that same set.
*/
const guide = (id: string): HelpGuide => {
const g = tripFilesGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The fixture documents each guide acts on, by the name their row carries. */
const STARRED = 'JR-Pass-voucher.pdf'
const LINKED = 'LH716-boarding-pass.pdf'
const DOOMED = 'old-draft-itinerary.pdf'
const PHOTO = 'nishiki-market.jpg'
/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */
const UPLOADED_PDF = 'kyoto-bus-pass.pdf'
const UPLOADED_PHOTO = 'arashiyama.jpg'
const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO]
/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */
const PLACE = 'teamLab Planets'
const FLIGHT = 'LH716 FRA → HND'
const NOTE = 'Seat 34K, boarding 12:20'
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
/**
* The file rows, in the order the list shows them. A row is the `.group` card
* whose last child holds the row's icon actions (`.file-actions`); matching on
* that keeps any other hover group on the trip page out of the count.
*/
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
/** A row's actions are icons; each takes its accessible name from its tooltip. */
const action = (page: Page, name: string, label: string) =>
row(page, name).getByRole('button', { name: label, exact: true })
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
/** The filter row, found through the one tab that is always there. */
const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..')
/** The star tab has no text: its accessible name is the count alone. */
const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first()
/**
* The number on a filter tab. Reading it rather than counting the fixture keeps
* the step honest about the one thing it is really claiming: that the tab shows
* exactly as many rows as its badge promises, and it survives another screen's
* fixture adding a file of its own.
*/
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
/**
* The trash icon at the right end of the bar, named by its tooltip. Trash and
* Empty Trash both contain "Trash", so it needs `exact`.
*/
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
/**
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
* its head band (Assign File, over the file's name) is what labels it.
*/
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
/**
* The lightbox, found by the one header button only it and the document preview
* carry, and they are never open at the same time. Its three direct children
* are the header, the picture and the strip of thumbnails.
*/
const lightbox = (page: Page) =>
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
/**
* The preview for everything that is not a picture: a DialogShell panel with
* the file's name in the head band. For a PDF it holds the browser's embedded
* viewer, the one `<object>` only it renders; the markdown preview
* (`.collab-note-md`) has no step of its own.
*/
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
/** The round × in a dialog's head band, named Close by its tooltip. */
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
/**
* A DialogShell hands the focus back to whatever opened it when it closes,
* and a Tooltip opens on focus as well as on hover. The opener of the assign
* dialog and of document sync is an icon with a tooltip, so without this the
* after-picture shows "Assign" or "Document sync" floating under the pointer
* that has long since moved away.
*/
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
/** The head band's title; the trip's name stands under it as a line of its own. */
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
/**
* A store row in the dialog's sidebar. Its accessible name runs on into the
* subtitle (Files in a folder), so it is matched by its start.
*/
const storeRow = (page: Page, name: string) => modal(page).getByRole('button', { name: new RegExp(`^${name}\\b`) })
/**
* A box of the connection form, by the placeholder the store's field carries.
* The labels are no good as locators here: the hint under Username says
* "email address", so a lookup by the label Address finds two boxes.
*/
const formBox = (page: Page, placeholder: string) => dialog(page).getByPlaceholder(placeholder, { exact: true })
/** The folder picker's row for the folder the fixture put in the store; the Create button is not in a list. */
const folderRow = (page: Page) => dialog(page).locator('li button').filter({ hasText: NEXTCLOUD_FOLDER }).first()
/** The binding card, the one <article> in the dialog once a store is bound. */
const bindingCard = (page: Page) => dialog(page).locator('article').first()
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
interface ApiFile {
id: number
original_name: string
starred?: number
}
async function listFiles(page: Page, trash = false): Promise<ApiFile[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`)
const body = (await res.json()) as { files?: ApiFile[] }
return body.files ?? []
}
/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */
async function removeFiles(page: Page, names: string[]): Promise<void> {
const { tripId } = seededTrip()
for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
}
for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
}
/** Put the note and every link the guide made back to nothing. */
async function unlinkFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page)).find(f => f.original_name === name)
if (!file) return
await page.request.put(`/api/trips/${tripId}/files/${file.id}`, {
data: { place_id: null, reservation_id: null, description: '' },
})
const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`)
const { links = [] } = (await res.json()) as { links?: { id: number }[] }
for (const link of links) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`)
}
}
/** The star route toggles, so it is only called back when the file really is starred. */
async function unstarFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page)).find(f => f.original_name === name)
if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`)
}
/** Bring a file the guide threw away back out of the trash, if it is still in there. */
async function untrashFile(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const file = (await listFiles(page, true)).find(f => f.original_name === name)
if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
'files-upload': {
guide: guide('files-upload'),
// Starts on the plan, because the first step is reaching the tab.
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => p.getByRole('button', { name: 'Files', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Files', exact: true }).click()
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
await expect(dropzone(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: dropzone,
// The hidden input takes the files directly: clicking the box would open
// the operating system's picker, which Playwright cannot answer.
act: async p => {
await dropzone(p).locator('input[type="file"]').setInputFiles([
filesPdfFixture('kyoto-bus-pass'),
await filesImageFixture('arashiyama'),
])
await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
// The whole panel: the head band with the file's name, the note and
// the two lists are what the step's text walks through.
target: assignDialog,
act: async p => {
await closeX(assignDialog(p)).click()
await expect(assignDialog(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => row(p, UPLOADED_PHOTO),
},
],
cleanup: p => removeFiles(p, UPLOADED),
},
'files-link': {
guide: guide('files-link'),
start: openFiles,
steps: [
{
target: p => action(p, LINKED, 'Assign'),
act: async p => {
await action(p, LINKED, 'Assign').click()
await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, noteBox(p), NOTE)
},
// The label above the box belongs in the picture, so the step rings the
// block rather than the input.
target: p => noteBox(p).locator('xpath=..'),
// The note is written on blur, and Enter blurs; the row behind the
// dialog picks it up, which is the proof that it was saved.
act: async p => {
await noteBox(p).press('Enter')
await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 })
},
},
{
// Exact: the bookings fixture names a booking after this place, and the
// dialog lists both under the same opening words.
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
act: async p => {
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
},
},
{
target: p => assignDialog(p).getByRole('button', { name: FLIGHT }),
act: async p => {
await assignDialog(p).getByRole('button', { name: FLIGHT }).click()
await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
},
},
{
target: p => closeX(assignDialog(p)),
act: async p => {
await closeX(assignDialog(p)).click()
await expect(assignDialog(p)).toHaveCount(0)
await dropFocus(p)
// A badge carries the name alone now; what it links to is its tooltip.
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => unlinkFile(p, LINKED),
},
'files-star': {
guide: guide('files-star'),
start: openFiles,
steps: [
{
target: p => action(p, STARRED, 'Star'),
act: async p => {
await action(p, STARRED, 'Star').click()
await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// Sorting is the server's (starred first, then newest first), so the
// list is only right once the reload has come back.
prepare: async p => {
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
await settle(p)
},
target: p => rows(p).first(),
},
{
target: starTab,
act: async p => {
const starred = await tabCount(starTab(p))
await starTab(p).click()
await expect(rows(p)).toHaveCount(starred)
await settle(p)
},
},
],
cleanup: p => unstarFile(p, STARRED),
},
'files-filter': {
guide: guide('files-filter'),
start: openFiles,
steps: [
only(tabRow),
{
target: p => p.getByRole('button', { name: /^PDFs \d+$/ }),
act: async p => {
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
await expect(rows(p)).toHaveCount(pdfs)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }),
act: async p => {
await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click()
await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^All \d+$/ }),
act: async p => {
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
await p.getByRole('button', { name: /^All \d+$/ }).click()
await expect(rows(p)).toHaveCount(all)
await settle(p)
},
},
],
},
'files-preview': {
guide: guide('files-preview'),
start: openFiles,
steps: [
{
// Two buttons in the row carry the file's name: the thumbnail and the
// name itself. The reader is told about the name, which comes second.
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
act: async p => {
await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click()
await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
target: p => lightbox(p).locator('> div').last(),
// Which way the picture can move depends on where it sits in the strip,
// and the right chevron is there only while there is a next one.
act: async p => {
const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0
await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft')
await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// The header's button group, not the whole header bar: the bar runs the
// full width of the frame, so ringing it rings mostly empty space.
target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'),
// The group's own centre falls between its buttons, where the picture
// underneath takes the pointer, so hovering it never lands. The pointer
// goes on a button instead; the ring is still round the group.
hover: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).hover(),
act: async p => {
await p.keyboard.press('Escape')
await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// A PDF this time rather than the markdown file: the browser's own
// viewer draws the page inside the card, which is what a reader sees
// for a ticket. The card gets its source only once the signed download
// link is minted (useFileManager.ts: getAuthUrl → setPreviewFileUrl),
// and no DOM event says when the viewer has painted, so the step waits
// for the file's own response and then a moment more, as the satellite
// step of the map guide does for its tiles.
prepare: async p => {
const fetched = p
.waitForResponse(r => r.url().includes('/download?token=') && r.ok(), { timeout: 30_000 })
.catch(() => null)
await row(p, LINKED).getByRole('button', { name: LINKED }).last().click()
await expect(pdfPreview(p)).toBeVisible({ timeout: 30_000 })
await expect(pdfViewer(p)).toHaveAttribute('data', /\/download\?token=.+#view=FitH$/, { timeout: 30_000 })
await fetched
// The fallback link inside the <object> is drawn only where the
// browser has no viewer; hidden, it proves the picture shows a page.
await expect(pdfPreview(p).getByRole('button', { name: 'Download PDF' })).toBeHidden()
await settle(p)
await p.waitForTimeout(1500)
},
target: pdfPreview,
act: async p => {
await closeX(pdfPreview(p)).click()
await expect(pdfPreview(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => action(p, STARRED, 'Download'),
// The button fetches the bytes and clicks a synthetic anchor, so the
// browser reports a real download.
act: async p => {
const download = p.waitForEvent('download', { timeout: 30_000 })
await action(p, STARRED, 'Download').click()
await (await download).delete()
await settle(p)
},
},
],
},
'files-trash': {
guide: guide('files-trash'),
start: openFiles,
steps: [
{
target: p => action(p, DOOMED, 'Delete'),
act: async p => {
await action(p, DOOMED, 'Delete').click()
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: trashTab,
act: async p => {
await trashTab(p).click()
await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 })
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => row(p, DOOMED)),
{
target: p => action(p, DOOMED, 'Restore'),
act: async p => {
await action(p, DOOMED, 'Restore').click()
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
// Empty Trash is there because the fixture leaves one document in the
// trash. It asks with the browser's own confirm, which Playwright
// dismisses, so clicking it would do nothing at all; the step shows the
// button and leaves the trash by the trash icon instead.
target: p => p.getByRole('button', { name: 'Empty Trash' }),
act: async p => {
await trashTab(p).click()
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => untrashFile(p, DOOMED),
},
'files-sync': {
guide: guide('files-sync'),
// The store's address and app password come from media.env (external.ts).
// They are read inside the guide rather than at import, so a run without
// the file fails this one guide and not the whole screen.
start: async p => {
const { tripId } = seededTrip()
await resetDocSync(p.request, tripId)
await ensureNextcloudFolder(drawPdf)
await openFiles(p)
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: syncButton,
act: async p => {
await syncButton(p).click()
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
// The stores arrive after a spinner; the heading over them says the list is in.
await expect(modal(p).getByText('Connect a provider', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => storeRow(p, 'Nextcloud'),
act: async p => {
await storeRow(p, 'Nextcloud').click()
// The connection dialog stacks on top, so `modal` is now that one.
await expect(modal(p).getByRole('heading', { name: 'Nextcloud', exact: true })).toBeVisible({ timeout: 20_000 })
await expect(formBox(p, 'https://cloud.example.com')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const store = NEXTCLOUD_CONNECTION()
await typeInto(p, formBox(p, 'https://cloud.example.com'), store.baseUrl)
await typeInto(p, formBox(p, 'username'), store.credentials.login_name)
await typeInto(p, formBox(p, 'app password'), store.credentials.app_password)
await typeInto(p, formBox(p, '/TREK'), store.credentials.base_path)
await beat(p, 300)
},
// The whole form, as the create-user step does: the labels and hints belong in the picture.
target: dialog,
},
{
target: p => dialog(p).getByRole('button', { name: 'Test connection' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Test connection' }).click()
await expect(
dialog(p).getByText(`Reached it, signed in as ${NEXTCLOUD_CONNECTION().credentials.login_name}`),
).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Connect saves the credentials and opens the folder picker on top of
// the dialog; the picture is the picker, with the folder the reader is
// told to click, because that is the screen the step is about.
prepare: async p => {
await dialog(p).getByRole('button', { name: 'Connect', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Where should this trip live in Nextcloud?' })).toBeVisible({ timeout: 30_000 })
await expect(folderRow(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
target: folderRow,
act: async p => {
await folderRow(p).click()
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Binding starts a run of its own, which the button cannot join: a Sync
// now while it is going answers busy and moves nothing, and the card
// only learns of that first run from the ping it sends when it is
// through. So the click is repeated until the card reports In sync,
// which is a dot named by its tooltip once the state is good.
prepare: async p => {
await expect(async () => {
await syncNow(p).click({ timeout: 15_000 })
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
}).toPass({ timeout: 120_000, intervals: [3_000] })
await settle(p)
},
target: bindingCard,
// Closed with the × in the head band, as the step's text says.
act: async p => {
await closeX(dialog(p)).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await dropFocus(p)
// The run's file:created events put the pulled documents at the top of the list.
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await resetDocSync(p.request, tripId)
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
// The run pushed the trip's own documents into the folder; back to its two.
await ensureNextcloudFolder(drawPdf)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureFilesFixtures(request)
// A store on offer puts Document sync beside the trash icon for the trip's
// owner, which is how bullet 6 of the screen text describes the bar; on for
// the whole file, so the hero and all seven guides show the same bar.
await toggleAddon(request, 'nextcloud', true)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: every store ships off, and the other
// screens' pictures must not grow the button. Here rather than in the last
// guide's cleanup, so a failed run puts it back too (as trip-roadtrip does).
await toggleAddon(request, 'nextcloud', false)
})
test('every registered files guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort())
})
test('hero: trip-files', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripFilesContext.id, openFiles)
})
for (const id of tripFilesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-758
View File
@@ -1,758 +0,0 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { pickerLabel } from '../dates'
import { seededTrip, ensureListsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { tripListsContext, tripListsGuides } from '../../src/help/contexts/tripLists'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Lists tab of a trip, keyed by the ids in
* `src/help/contexts/tripLists.ts`. They run on the seeded "Autumn in Japan"
* with the fixtures' packing template and two extra tasks in place, so Apply
* template exists and the to-do sidebar has more than one filled filter; what
* a guide creates it takes away again in `cleanup`, so the next one starts the
* same.
*/
const guide = (id: string): HelpGuide => {
const g = tripListsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** What the guides make and unmake. Nothing here is in the seed. */
const NEW_LIST = 'Toiletries'
const FIRST_ITEM = 'Toothbrush'
const RENAMED_ITEM = 'Toothbrush and paste'
const MEMBER = 'mira'
const SHARED_ITEM = 'Camera'
const BAG_ITEM = 'Rain jacket'
const BAG = 'Cabin bag'
const SAVED_TEMPLATE = 'Autumn in Japan'
const NEW_TASK = 'Pick up the pocket wifi'
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
const TASK_LIST = 'On arrival'
const OTHER_TASK_LIST = 'Before departure'
const EDITED_TASK = 'Activate JR Pass'
const ASSIGNEE = 'jonas'
/** The date picker labels a day cell with the whole date, in the run's English. */
const DUE_DAY = pickerLabel(4)
/** The fixtures' template, and the lists it brings with it. */
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] }
const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30'
const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters']
/** The seed's packing items, in the order it created them. */
const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera']
const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance']
/** The count in the label moves with what is ticked, so it is matched, not spelled. */
const CLEAR_CHECKED = /^Remove \d+ checked$/
// ── Locators ─────────────────────────────────────────────────────────────────
/** A category card: the list's name is a span in the card's header row. */
const cat = (page: Page, name: string): Locator =>
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
/**
* The header row of a card. The ⋯ has to be taken from here: every item row
* carries a ⋯ of its own, so the card as a whole holds several of them.
*/
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
const catMenu = (page: Page, name: string): Locator =>
catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)')
/** The open ⋯ menu is a fixed popover with no class of its own; its last entry names it. */
const catMenuCard = (page: Page): Locator => page.getByRole('button', { name: 'Delete List' }).locator('xpath=..')
const item = (page: Page, name: string): Locator =>
page.locator('.packing-item-row').filter({ hasText: name }).first()
/** The only item of the list the first guide builds; its name changes mid-guide. */
const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first()
/** An item's own ⋯, which holds Move to List, Sharing, Rename and Delete. */
const rowMenu = (page: Page, name: string): Locator => item(page, name).locator('.packing-row-overflow button')
/** The open item menu; Sharing is a whole line in it. */
const rowMenuSharing = (page: Page): Locator =>
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
/** The name dialog that Add list and Save as template open. */
const nameDialog = (page: Page, confirm: string): Locator => page.getByRole('button', { name: confirm, exact: true })
/** The sharing dropdown is portalled to <body>, so the hint's parent is the card itself. */
const shareMenu = (page: Page): Locator =>
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
/** Its click-catcher, the only way back out of the menu once a name is ticked. */
const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last()
/** The bulk-import dialog, a DialogShell whose head band carries its title. */
const importCard = (page: Page): Locator =>
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
/**
* The bag picker of a row and the member picker of a bag both hang off the
* body now, so they are found by their caption rather than inside the row.
*/
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
/** The calendar a date field opens, a portal of its own. */
const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Date picker' })
/** The Export button's menu: print, Markdown and CSV. */
const exportMenu = (page: Page): Locator => page.getByRole('menu')
/** The printable page inside the preview, a srcdoc frame of its own. */
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
/** The Bags card beside the lists. Total weight is a span in its foot band. */
/** The bag sidebar: the innermost box holding the guide's bag and the weight summary below the cards. */
const bagSidebar = (page: Page): Locator =>
page.locator('div')
.filter({ has: page.getByText('Total weight', { exact: true }) })
.filter({ has: page.getByText(BAG, { exact: true }) })
.last()
/** The detail pane on the right; its header reads Task. */
const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]')
/** A task row is a div with role=button whose name starts with the task's own. */
const task = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) }).first()
const sidebarFilter = (page: Page, name: string): Locator =>
page.getByRole('button', { name: new RegExp(`^${name}`) })
const only = (target: (p: Page) => Locator) => ({ target })
// ── Getting there ────────────────────────────────────────────────────────────
/** The tab id in the address is the legacy 'listen'; the tab itself reads Lists. */
async function openLists(page: Page): Promise<void> {
await openTrip(page, { tab: 'listen' })
await expect(page.getByRole('button', { name: /^Packing List/ })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
/**
* The sub-tab is remembered in sessionStorage, which Playwright never restores,
* so every guide opens on Packing List and the to-do ones switch over.
*/
async function openTodos(page: Page): Promise<void> {
await openLists(page)
await page.getByRole('button', { name: /^To-Do/ }).click()
await expect(page.getByRole('button', { name: 'Add new task' })).toBeVisible({ timeout: 15_000 })
await settle(page)
}
// ── Putting the seed back ────────────────────────────────────────────────────
interface PackingRow {
id: number
name: string
category: string | null
}
async function packingItems(page: Page): Promise<PackingRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/packing`)
const body = (await res.json()) as { items?: PackingRow[] }
return body.items ?? []
}
async function deleteItems(page: Page, match: (row: PackingRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
for (const row of (await packingItems(page)).filter(match)) {
await page.request.delete(`/api/trips/${tripId}/packing/${row.id}`)
}
}
/**
* Re-creates the three Documents items the guide really deleted, then writes
* the seed's order back: without it the re-created rows sort last, the
* Documents card slides to the end of the two-column grid, and every later
* guide's picture changes.
*/
async function restoreDocuments(page: Page): Promise<void> {
const { tripId } = seededTrip()
const present = await packingItems(page)
for (const name of DOCUMENTS) {
if (present.some(row => row.name === name)) continue
await page.request.post(`/api/trips/${tripId}/packing`, { data: { name, category: 'Documents', visibility: 'common' } })
}
const after = await packingItems(page)
const orderedIds = SEED_ORDER.map(name => after.find(row => row.name === name)?.id).filter((id): id is number => id !== undefined)
await page.request.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds } })
}
async function deleteTemplate(page: Page, name: string): Promise<void> {
const res = await page.request.get('/api/admin/packing-templates')
const body = (await res.json()) as { templates?: { id: number; name: string }[] }
for (const template of (body.templates ?? []).filter(row => row.name === name)) {
await page.request.delete(`/api/admin/packing-templates/${template.id}`)
}
}
async function deleteTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
const body = (await res.json()) as { items?: { id: number; name: string }[] }
for (const row of (body.items ?? []).filter(x => x.name === name)) {
await page.request.delete(`/api/trips/${tripId}/todo/${row.id}`)
}
}
async function unassignTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
const body = (await res.json()) as { items?: { id: number; name: string }[] }
const row = (body.items ?? []).find(x => x.name === name)
if (row) await page.request.put(`/api/trips/${tripId}/todo/${row.id}`, { data: { assigned_user_id: null } })
}
// ── Scripts ──────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'packing-categories': {
guide: guide('packing-categories'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Add list' }),
act: async p => {
await p.getByRole('button', { name: 'Add list' }).click()
const field = p.getByPlaceholder('List name (e.g. Clothing)')
await typeInto(p, field, NEW_LIST)
await nameDialog(p, 'Add').click()
await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }),
act: async p => {
await cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }).click()
const field = p.getByPlaceholder('Item name...')
await typeInto(p, field, FIRST_ITEM)
// Enter renames the list's '...' row rather than adding a second one,
// and leaves the field open; Escape is how it closes.
await field.press('Enter')
await expect(item(p, FIRST_ITEM)).toBeVisible({ timeout: 20_000 })
await field.press('Escape')
await settle(p)
},
},
{
prepare: async p => { await onlyRow(p).hover() },
// A click on the name renames it; the row has no pencil of its own.
target: p => onlyRow(p).getByRole('button', { name: FIRST_ITEM }),
act: async p => {
await onlyRow(p).getByRole('button', { name: FIRST_ITEM }).click()
// While editing, the name is the row's first input; the quantity and
// the weight come after it.
const field = onlyRow(p).locator('input').first()
await typeInto(p, field, RENAMED_ITEM)
await field.press('Enter')
await expect(item(p, RENAMED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)'),
act: async p => {
const plus = catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)')
await plus.click()
await catHeader(p, NEW_LIST).getByRole('button', { name: MEMBER }).click()
await expect(catHeader(p, NEW_LIST).locator('.assignee-chip')).toHaveCount(1, { timeout: 20_000 })
await plus.click()
await settle(p)
},
},
{
prepare: async p => {
await catMenu(p, NEW_LIST).click()
await expect(p.getByRole('button', { name: 'Delete List' })).toBeVisible()
await beat(p, 300)
},
target: catMenuCard,
// The pointer rests on the three dots that opened it, not on an entry.
// Moved by coordinates: the menu's click-catcher covers the button.
hover: async p => {
const b = await catMenu(p, NEW_LIST).boundingBox()
if (b) await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2)
},
act: async p => {
// The menu sits over a full-viewport catcher; a click in the corner
// closes it without deleting the list the picture was just taken of.
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(p.getByRole('button', { name: 'Delete List' })).toHaveCount(0)
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await deleteItems(p, row => row.category === NEW_LIST)
await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } })
},
},
'check-off-packing': {
guide: guide('check-off-packing'),
start: openLists,
steps: [
{
// The grip is a div, so the tick box is the row's first button.
target: p => item(p, 'Passport').getByRole('button').first(),
act: async p => {
await item(p, 'Passport').getByRole('button').first().click()
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
// The count and the percentage, with the bar beside them: the whole card is
// wider than any frame and would lose both at its edges.
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
{
prepare: async p => {
await catMenu(p, 'Documents').click()
await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Check All', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Check All', exact: true }).click()
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Open', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Open', exact: true }).click()
await expect(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: CLEAR_CHECKED }),
act: async p => {
// A native window.confirm, which Playwright dismisses unless something
// accepts it first. The browser's own dialog cannot be pictured, so
// the step shows the button and the state it leaves behind.
p.once('dialog', d => { void d.accept() })
await p.getByRole('button', { name: CLEAR_CHECKED }).click()
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
cleanup: restoreDocuments,
},
'apply-packing-template': {
guide: guide('apply-packing-template'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Apply template' }),
act: async p => {
await p.getByRole('button', { name: 'Apply template' }).click()
await expect(p.locator('.trek-menu-enter').last()).toBeVisible()
await settle(p)
},
},
{
target: p => p.locator('.trek-menu-enter').last(),
act: async p => {
await p.locator('.trek-menu-enter').last().getByRole('button', { name: new RegExp(`^${TEMPLATE.name}`) }).click()
await expect(cat(p, TEMPLATE.category)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => p.getByRole('button', { name: /^Shared/ })),
{
target: p => p.getByRole('button', { name: 'Save as template' }),
act: async p => {
await p.getByRole('button', { name: 'Save as template' }).click()
const field = p.getByPlaceholder('Template name')
await typeInto(p, field, SAVED_TEMPLATE)
await nameDialog(p, 'Save').click()
await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
await deleteTemplate(p, SAVED_TEMPLATE)
},
},
'import-packing-list': {
guide: guide('import-packing-list'),
start: openLists,
steps: [
{
// An icon at the right end of the bar, named Import by its label and tooltip.
target: p => p.getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Import', exact: true }).click()
await expect(importCard(p)).toBeVisible()
await settle(p)
},
},
{
// The step's text points at the grey sample, which is the textarea's
// placeholder: it is only on screen while the box is empty, so the lines
// are typed after the picture, not before it.
target: importCard,
act: async p => {
await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES)
await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 })
// Out of the box again, or its dark focus ring reads as a second ring
// in the next two pictures.
await importCard(p).locator('textarea').blur()
await settle(p)
},
},
// In the dialog's footer, on the left. Clicking it would open the operating
// system's file chooser, which is not part of the picture; the button is
// shown, not pressed.
only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })),
{
target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }),
act: async p => {
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
},
'export-packing-list': {
guide: guide('export-packing-list'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Export', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Export', exact: true }).click()
await expect(exportMenu(p)).toBeVisible()
await settle(p)
},
},
// The two file entries download at once; the step shows them, it does not press them.
only(exportMenu),
{
target: p => exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }),
act: async p => {
await exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }).click()
await expect(printFrame(p).locator('h1')).toBeVisible({ timeout: 15_000 })
// The page carries the app's own Poppins; the result picture waits for it.
await printFrame(p).locator('body').evaluate(async () => { await document.fonts.ready })
await settle(p)
},
},
// Pressing it opens the operating system's print dialog, which is not part
// of the picture; the button is shown, not pressed. The result picture is the
// preview itself.
only(p => p.getByRole('button', { name: 'Print or save as PDF' })),
],
cleanup: async p => {
await p.getByRole('button', { name: 'Close', exact: true }).first().click()
},
},
'share-packing-item': {
guide: guide('share-packing-item'),
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: /^My list/ }),
act: async p => {
await p.getByRole('button', { name: /^My list/ }).click()
await expect(p.getByText('No items match this filter')).toBeVisible({ timeout: 20_000 })
await p.getByRole('button', { name: /^Shared/ }).click()
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await item(p, SHARED_ITEM).hover()
await rowMenu(p, SHARED_ITEM).click()
await expect(rowMenuSharing(p)).toBeVisible()
await beat(p, 300)
},
target: rowMenuSharing,
act: async p => {
await rowMenuSharing(p).click()
await expect(shareMenu(p)).toBeVisible()
await settle(p)
},
},
{
target: shareMenu,
act: async p => {
await shareMenu(p).getByRole('button', { name: /^Personal/ }).click()
await expect(item(p, SHARED_ITEM)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: /^My list/ }),
act: async p => {
await p.getByRole('button', { name: /^My list/ }).click()
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await item(p, SHARED_ITEM).hover()
await rowMenu(p, SHARED_ITEM).click()
await rowMenuSharing(p).click()
await expect(shareMenu(p)).toBeVisible()
await beat(p, 300)
},
target: p => shareMenu(p).getByRole('button', { name: MEMBER }),
act: async p => {
await shareMenu(p).getByRole('button', { name: MEMBER }).click()
// Ticking a name leaves the menu open on purpose; its catcher closes it,
// and the item menu under it has one of its own.
await shareOverlay(p).click({ position: { x: 5, y: 5 } })
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(p.locator('.trek-menu-enter')).toHaveCount(0)
await expect(item(p, SHARED_ITEM).getByLabel('shared with 1')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const row = (await packingItems(p)).find(x => x.name === SHARED_ITEM)
if (row) {
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
}
},
},
'packing-bags': {
guide: guide('packing-bags'),
start: openLists,
steps: [
{
// The weight field's placeholder really is a dash: it is the app's own
// string, not prose, so it is matched exactly as rendered.
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
act: async p => {
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
await typeInto(p, field, '620')
await expect(field).toHaveValue('620', { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => { await item(p, BAG_ITEM).hover() },
// The circle is dashed with a parcel in it while the item is in no bag.
target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'),
act: async p => {
await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click()
await expect(bagPicker(p).getByRole('button', { name: 'Add bag' })).toBeVisible()
await settle(p)
},
},
{
target: p => bagPicker(p).getByRole('button', { name: 'Add bag' }),
act: async p => {
await bagPicker(p).getByRole('button', { name: 'Add bag' }).click()
const field = bagPicker(p).getByPlaceholder('Bag name...')
await typeInto(p, field, BAG)
await field.press('Enter')
await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 })
await expect(bagPicker(p)).toHaveCount(0)
await settle(p)
},
},
only(bagSidebar),
{
target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }),
act: async p => {
await bagSidebar(p).getByRole('button', { name: 'Set limit' }).click()
// Entered in kilograms, stored in grams.
// The sidebar locator needs Set limit, which the open editor replaces: look page-wide.
const field = p.locator('input[aria-label="Weight limit"]')
await typeInto(p, field, '8')
await field.press('Enter')
await expect(p.getByText('/ 8.0 kg').first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The card's member picker is the dashed plus beside the bag's name; the
// column's other plus, + Bags in its head, adds a bag.
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
act: async p => {
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
await bagMemberPicker(p).getByRole('button', { name: MEMBER }).click()
// The picker closes on a click anywhere beside it.
await p.mouse.click(20, VIEWPORT.height - 40)
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/packing/bags`)
const body = (await res.json()) as { bags?: { id: number; name: string }[] }
for (const bag of (body.bags ?? []).filter(x => x.name === BAG)) {
// bag_id is ON DELETE SET NULL, so the item is freed with it.
await p.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
}
const row = (await packingItems(p)).find(x => x.name === BAG_ITEM)
if (row) await p.request.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { weight_grams: null } })
},
},
'create-todo': {
guide: guide('create-todo'),
start: openTodos,
steps: [
{
target: p => p.getByRole('button', { name: 'Add new task' }),
act: async p => {
await p.getByRole('button', { name: 'Add new task' }).click()
await expect(modal(p).getByPlaceholder('Task name')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK)
await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE)
// Out of the field, so its dark focus border does not read as a second ring.
await modal(p).getByPlaceholder('Description (optional)').blur()
await settle(p)
},
// The name is typed into the dialog's head band, where Task name stands.
target: p => modal(p).getByPlaceholder('Task name'),
},
{
target: p => modal(p).getByRole('button', { name: 'No list' }),
act: async p => {
await modal(p).getByRole('button', { name: 'No list' }).click()
// The select's list is portalled to <body>, outside the dialog.
await p.getByRole('button', { name: TASK_LIST, exact: true }).click()
await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'P1', exact: true }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'P2', exact: true }).click()
await settle(p)
},
},
{
prepare: async p => {
// Exact: the keyboard button beside it is "Enter date manually", which a
// loose name match also finds.
await modal(p).getByRole('button', { name: 'Date', exact: true }).click()
await expect(datePicker(p)).toBeVisible()
// The calendar opens on the picture day's month, the last of the trip,
// and four days later can already be in the next one.
if (!(await datePicker(p).getByRole('button', { name: DUE_DAY }).isVisible())) {
await datePicker(p).getByRole('button', { name: 'Next month' }).click()
}
await expect(datePicker(p).getByRole('button', { name: DUE_DAY })).toBeVisible()
await beat(p, 300)
},
target: datePicker,
// The pointer on the day about to be picked, not on whatever the middle is.
hover: async p => { await datePicker(p).getByRole('button', { name: DUE_DAY }).hover() },
act: async p => {
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Create task' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Create task' }).click()
await expect(p.locator('.trek-modal-backdrop')).toHaveCount(0, { timeout: 30_000 })
await expect(task(p, NEW_TASK)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => deleteTodo(p, NEW_TASK),
},
'todo-filters': {
guide: guide('todo-filters'),
start: openTodos,
steps: [
{
target: p => sidebarFilter(p, 'Overdue'),
act: async p => {
await sidebarFilter(p, 'Overdue').click()
await expect(p.getByRole('heading', { name: 'Overdue' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => sidebarFilter(p, OTHER_TASK_LIST),
act: async p => {
await sidebarFilter(p, OTHER_TASK_LIST).click()
await expect(p.getByRole('heading', { name: OTHER_TASK_LIST })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await sidebarFilter(p, 'All').click()
await settle(p)
},
target: p => p.getByRole('button', { name: 'Priority', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Priority', exact: true }).click()
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => task(p, EDITED_TASK),
act: async p => {
await task(p, EDITED_TASK).click()
await expect(todoPane(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await settle(p)
},
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
act: async p => {
await todoPane(p).getByRole('button', { name: 'Save changes' }).click()
// The row names its assignee as an avatar, labelled with the name.
await expect(task(p, EDITED_TASK).getByLabel(ASSIGNEE)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => unassignTodo(p, EDITED_TASK),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureListsFixtures(request)
})
test('every registered lists guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripListsContext.guides].sort())
})
test('hero: trip-lists', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripListsContext.id, openLists)
})
for (const id of tripListsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-809
View File
@@ -1,809 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureMapFixtures } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the map in the middle of a trip's plan, keyed by the ids in
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
* toggle has a second route to draw inside the frame; what a guide changes it
* puts back in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripMapGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */
const map = (page: Page) => page.locator('#trek-map')
/** A cluster bubble. Its only text is the number of places folded into it. */
const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom')
/**
* A place pin. Cluster bubbles and the explore results are markers too, so both
* are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin
* carries an `aria-label` (a place marker is a bare divIcon with no name at all,
* which is why a specific place can only be found through its order badge).
*/
const pins = (page: Page) =>
page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
/** A place pin wearing the place's photo rather than its category icon. */
const photoPins = (page: Page) => pins(page).filter({ has: page.locator('img') })
/**
* The pin the markers guide works on after its bubble opened, marked when it
* was chosen: a photo pin the pointer can reach, or any reachable pin when no
* photo has landed.
*/
const markerPin = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[data-help-pin]')
/** The pin of the stop that is number `n` in the open day. */
const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first()
/** An explore result: the one kind of marker with an accessible name. */
const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]')
/** The end of a drawn booking route. */
const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker')
/** The card that follows the pointer over a pin. */
const hoverCard = (page: Page) => page.getByTestId('tooltip')
/**
* A category of the explore pill. The phone portal renders a second copy of the
* whole pill, hidden at this width, so this goes by role rather than by
* `aria-label`, which would match both.
*/
const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true })
/** The pill itself: the nearest block holding the first category and the last. */
const categoryRow = (page: Page) =>
category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]')
/** The route icon on a booking's row, and the one in the toolbar above the days. */
const connection = (page: Page, shown: boolean) =>
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`)
const allConnections = (page: Page, shown: boolean) =>
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`)
const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel')
/** The round Dawarich button under the whole-trip one; its label is its state. */
const trailPill = (page: Page) => page.getByTestId('dawarich-trail-pill')
/**
* The recorded route. Leaflet writes the dash pattern onto the path, and "6 5"
* is this layer's alone: the pending booking routes use "6, 6", the picked
* alternative "2 7", so the dashed days are the one thing on the map with it.
*/
const trail = (page: Page) => page.locator('#trek-map path[stroke-dasharray="6 5"]')
/**
* The GL renderer. There is no `#trek-map` on it: `MapViewGL` mounts a bare
* `div.w-full.h-full` and MapLibre puts its own canvas inside, so the compass
* guide goes by that class. The map itself is reached through
* `window.__trek_map`, which `MapViewGL` publishes on build for exactly this.
*/
const glCanvas = (page: Page) => page.locator('.maplibregl-canvas')
/**
* The compass button and the frosted pill around it. The planner renders the
* pill twice, once in the desktop cluster beside the category row and once for
* phones, `md:hidden` at this width; `getByRole` leaves the hidden copy out.
*/
const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' })
const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
/**
* The booking detail an endpoint opens: a DialogShell portal, so `dialog` finds
* its panel, and its Close is the round X in the head band.
*/
const bookingDetail = (page: Page) => dialog(page)
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
/** A row of the places column. */
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
/**
* The card that opens under the map when a place is selected. By its own test
* id, not by a button in it: the collection button reads Save to Collection or
* Saved depending on whether that place is already in a list, and the seed puts
* the three Kyoto places in one, so a locator on the label matches for some
* pins and for others not.
*/
const inspector = (page: Page) => page.getByTestId('inspector-scroll')
/** The details panel's collapse: it keeps the day selected, where its close would drop it. */
const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)')
/** A day card's header in the days column, the drop target of a dragged pin. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/**
* Fold the day details away and take the focus off the fold button. The
* button keeps the focus after the click, and TREK's tooltip shows on focus,
* so its Expand would otherwise stand at the foot of every later picture.
*/
async function foldDayDetails(page: Page): Promise<void> {
await collapseDayDetails(page).last().click()
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
/**
* The part of the map between the two floating columns, in page pixels. A
* pin under a column can still be hovered at its edge, but a picture of it is
* a picture of the column.
*/
async function openCorridor(page: Page): Promise<{ left: number; right: number }> {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box')
return { left: box.x + 420, right: box.x + box.width - 420 }
}
/** Like `reachable`, but the pin also has to stand in the open part of the map when one does. */
async function reachableInView(page: Page, locator: Locator, fromEnd = false): Promise<number> {
const { left, right } = await openCorridor(page)
const total = await locator.count()
for (let i = 0; i < total; i++) {
const index = fromEnd ? total - 1 - i : i
const b = await locator.nth(index).boundingBox()
if (!b || b.x < left || b.x + b.width > right || b.y < 260) continue
try {
await locator.nth(index).hover({ trial: true, timeout: 1500 })
return index
} catch {
// Covered by another marker.
}
}
return reachable(locator, fromEnd)
}
/**
* A spot on the map with nothing on it: right of the days column, left of the
* explore pill, and clear of everything the plan floats over the lower half of
* the map. Map-relative, so it is passed as a click `position`.
*/
const EMPTY = { x: 470, y: 80 }
/**
* The first marker of a set the pointer can actually reach. Leaflet stacks
* markers that overlap and the two columns float over the edges of the map, so
* `.first()` is regularly one that nothing can hover, let alone click. Explore
* results are searched from the end because Leaflet draws the southernmost
* marker on top.
*/
async function reachable(locator: Locator, fromEnd = false): Promise<number> {
const total = await locator.count()
for (let i = 0; i < total; i++) {
const index = fromEnd ? total - 1 - i : i
try {
await locator.nth(index).hover({ trial: true, timeout: 1500 })
return index
} catch {
// Covered by another marker or by one of the floating columns.
}
}
throw new Error(`no reachable marker among ${total}`)
}
/**
* Drag the map the way a reader would, so "Search this area" appears.
*
* Not the arrow keys: Leaflet only listens for them while its container has
* focus, and focusing it from here did not take, so the map never moved and the
* button that only exists once it has never appeared. The drag starts from a
* point checked to be empty, because a press on a marker drags the place onto a
* day instead of moving the map.
*/
async function panMap(page: Page, by: { x: number; y: number } = { x: -176, y: -72 }): Promise<void> {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box to drag')
const candidates = [0.3, 0.7, 0.5].flatMap(fx => [0.75, 0.25].map(fy => ({
x: Math.round(box.x + box.width * fx),
y: Math.round(box.y + box.height * fy),
})))
let from: { x: number; y: number } | undefined
for (const point of candidates) {
const clear = await page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y)
return !!el && !el.closest('.leaflet-marker-icon, button, [role="button"], .leaflet-control')
}, point)
if (clear) { from = point; break }
}
if (!from) throw new Error('found nowhere on the map to take hold of')
await page.mouse.move(from.x, from.y)
await page.mouse.down()
// In steps, so Leaflet reads a drag rather than a click.
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + (by.x * i) / 8, from.y + (by.y * i) / 8, { steps: 2 })
await page.mouse.up()
await settle(page)
}
/**
* Open the trip framed on day 1's two Tokyo stops.
*
* The frame is what the three guides starting here have in common: one
* right-clicks a fixed point on the map and needs a street under it, one asks
* the map what it can see for cafes, one puts imagery under the pins. The
* whole-trip fit spans Japan, where that fixed point is the Sea of Japan as
* often as it is a street and the address look-up comes back empty, and zooming
* into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by
* chance. Day 1 is the one frame the seed guarantees is over a city.
*
* The details panel is folded rather than closed: closing it deselects the day
* (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to
* the whole trip.
*/
async function openOnTokyo(page: Page): Promise<void> {
await openTripOnDay(page, 1)
await foldDayDetails(page)
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
await settle(page)
}
/**
* Which renderer draws the planner and the journals. On the account, not on
* the trip: `MapViewAuto` reads `settings.map_provider`, and `loadSettings`
* runs on every page load, so a `page.goto` after this sees the new map.
*/
async function setMapProvider(page: Page, provider: 'leaflet' | 'maplibre-gl'): Promise<void> {
const res = await page.request.post('/api/settings/bulk', { data: { settings: { map_provider: provider } } })
if (!res.ok()) throw new Error(`could not set map_provider=${provider}: ${res.status()} ${await res.text()}`)
}
/** The slice of the GL map the compass guide reads and moves, on `window.__trek_map`. */
interface TrekGlMap {
getBearing: () => number
getPitch: () => number
loaded: () => boolean
areTilesLoaded: () => boolean
isMoving: () => boolean
easeTo: (o: { bearing: number; pitch: number; duration: number }) => unknown
}
/** Wait until the GL map has its style, its tiles and a still camera. */
async function glMapIdle(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && m.loaded() && m.areTilesLoaded() && !m.isMoving()
},
undefined,
{ timeout: 60_000 },
)
}
/**
* Wait until the raster basemap has actually painted.
*
* `toBeVisible()` on a tile is not enough: Leaflet puts the <img> in the DOM
* with its position and its size before the byte arrives, so a picture taken on
* that signal catches the grey underneath. A tile counts only once the browser
* has decoded it, and a screenful needs several of them.
*/
async function rasterTilesPainted(page: Page, atLeast = 6): Promise<void> {
await page.waitForFunction(
min => {
const tiles = Array.from(document.querySelectorAll('#trek-map img.leaflet-tile'))
const painted = tiles.filter(t => {
const img = t as HTMLImageElement
return img.complete && img.naturalWidth > 0
})
return painted.length >= min
},
atLeast,
{ timeout: 60_000 },
)
}
async function deleteByName(page: Page, ...names: string[]): Promise<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}`)
}
}
/**
* The markers the running guide works on. Which one is reachable is decided
* once, by `reachable`, and the steps that follow reuse the answer; `pin` holds
* a place pin, an explore result or a route endpoint, whichever the guide is
* about. `found` is the name of the explore result a guide took into the trip,
* so `cleanup` can take exactly that one out again.
*/
let bubble = 0
let pin = 0
let found = ''
const NEW_PLACE = 'Lunch stop'
const SCRIPTS: Record<string, GuideScript> = {
'map-lock': {
guide: guide('map-lock'),
start: openOnTokyo,
steps: [
{
target: p => p.getByTestId('map-lock-pill'),
act: async p => {
await p.getByTestId('map-lock-pill').click()
await expect(p.getByRole('button', { name: 'Let the map follow the selection' })).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
const unlock = p.getByRole('button', { name: 'Let the map follow the selection' })
if (await unlock.count()) await unlock.click()
},
},
'map-markers': {
guide: guide('map-markers'),
start: async p => {
await openTrip(p)
await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
bubble = await reachable(clusters(p))
},
steps: [
{
target: p => clusters(p).nth(bubble),
act: async p => {
await clusters(p).nth(bubble).click()
await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2)
// The pin's own photo is a thumb cut after the page has loaded, and
// until it lands the pin is a bare icon beside a card that already
// shows the photo. Bounded: a place with no photo anywhere stays an icon.
await expect.poll(() => photoPins(p).count(), { timeout: 20_000 }).toBeGreaterThan(0).catch(() => {})
await settle(p)
// Marked rather than counted: more thumbs keep landing, and an index
// among the photo pins would point at another pin a moment later.
const photoPin = await reachable(photoPins(p)).catch(() => -1)
const chosen = photoPin >= 0 ? photoPins(p).nth(photoPin) : pins(p).nth(await reachable(pins(p)))
await chosen.evaluate(el => el.setAttribute('data-help-pin', ''))
},
},
{
// captureGuide hovers the target itself; this only makes sure the card
// is up before the shot rather than a frame after it.
prepare: async p => {
await markerPin(p).hover()
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
},
target: markerPin,
},
{
// The picture worth having is the panel the click opens, not the pin
// again, so the click is the preparation and the panel is the target.
prepare: async p => {
await markerPin(p).click()
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: p => inspector(p).locator('xpath=..'),
act: async p => {
await map(p).click({ position: EMPTY })
await expect(inspector(p)).toHaveCount(0)
await settle(p)
},
},
{
prepare: async p => {
await selectDay(p, 1)
// The panel IS the selection, so it is folded away rather than closed:
// closing it would take the numbers off the pins again.
await foldDayDetails(p)
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => badged(p, 1),
},
{
// Picture only, like the other drags in this family: HTML5 drag is what
// the map hands the day plan, and a synthesised one does not start it.
// Day 1's second stop onto day 3, the first empty day: a drop there
// plainly plans something new, where one onto a day that already holds
// the place would read as a move.
prepare: async p => {
await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => badged(p, 2),
dropTo: p => dayHeader(p, 3).locator('xpath=..'),
},
],
},
'map-nearby-places': {
guide: guide('map-nearby-places'),
// A search covers what is on screen, and day 1's fit is the two Tokyo stops
// with the city between them: enough of a district for the sixty hits to
// spread over the frame instead of piling up in the middle of it.
start: openOnTokyo,
steps: [
{ target: categoryRow },
{
target: p => category(p, 'Cafés'),
act: async p => {
await category(p, 'Cafés').click()
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
await settle(p)
},
},
{
prepare: async p => {
// Towards the right. The search covers the whole map, the part under
// the two columns included, and on day 1's frame most of the first
// answer lay under the days column; dragged this way, the west of the
// view is open map when it is searched again, and the new pins are
// where a reader can see them.
await panMap(p, { x: 440, y: 96 })
await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 })
},
target: p => p.getByRole('button', { name: 'Search this area' }),
act: async p => {
await p.getByRole('button', { name: 'Search this area' }).click()
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
await settle(p)
},
},
{
prepare: async p => {
pin = await reachableInView(p, poiPins(p), true)
},
target: p => poiPins(p).nth(pin),
act: async p => {
await poiPins(p).nth(pin).click()
const name = modal(p).getByPlaceholder('e.g. Eiffel Tower')
await expect(name).not.toHaveValue('', { timeout: 20_000 })
// Which places OpenStreetMap answers with is never the same twice, so
// the name is read off the form rather than assumed, and `cleanup`
// deletes exactly what was created.
found = await name.inputValue()
await settle(p)
},
},
// The whole dialog: the name is typed in its head band, outside the form.
{ target: p => dialog(p) },
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, found)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, found),
},
'map-add-place': {
guide: guide('map-add-place'),
start: openOnTokyo,
steps: [
{
target: map,
// The map fills the frame, so the ring would sit nowhere in particular:
// park the pointer on the spot the right-click is about to land on.
hover: async p => {
const box = await map(p).boundingBox()
if (!box) throw new Error('the map has no box')
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
},
act: async p => {
await map(p).click({ button: 'right', position: EMPTY })
await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
// The address is a look-up of the coordinates and lands after the form.
await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('')
await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 })
await settle(p)
},
target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'),
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
await settle(p)
},
target: p => dialog(p),
},
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, NEW_PLACE),
},
'map-satellite': {
guide: guide('map-satellite'),
start: async p => {
await openOnTokyo(p)
pin = await reachable(pins(p))
},
steps: [
{
target: p => p.getByRole('button', { name: 'Switch to satellite view' }),
act: async p => {
await p.getByRole('button', { name: 'Switch to satellite view' }).click()
await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible()
// The imagery is raster tiles from a third party, and the switch is
// done long before they are on screen.
await rasterTilesPainted(p)
await settle(p)
await p.waitForTimeout(1500)
},
},
{ prepare: p => rasterTilesPainted(p), target: p => pins(p).nth(pin) },
{
prepare: p => rasterTilesPainted(p),
target: p => p.getByRole('button', { name: 'Switch to map view' }),
act: async p => {
await p.getByRole('button', { name: 'Switch to map view' }).click()
await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible()
await settle(p)
},
},
],
// The layer is kept on the account, not on the trip: left on satellite it
// would be the basemap of every picture taken after this guide.
cleanup: async p => {
await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } })
},
},
'map-whole-trip': {
guide: guide('map-whole-trip'),
start: p => openTrip(p),
steps: [
{
target: p => p.getByTestId('trip-overview-pill'),
act: async p => {
await p.getByTestId('trip-overview-pill').click()
// One router request per leg, paced, so the card takes its time.
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
// The total carries an … until every leg has answered; a picture taken
// before that shows a partial sum.
prepare: async p => {
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
await settle(p)
},
target: overviewPanel,
},
{
target: p => overviewPanel(p).getByRole('button').first(),
act: async p => {
await overviewPanel(p).getByRole('button').first().click()
await settle(p)
// The text's second half: the wheel over Tokyo. With the whole trip
// shown, the camera stays on all of it after the pick (the fit that
// follows takes in every day's line), and there a day of ten
// kilometres is a few pixels under a bubble; four notches bring days
// 1 and 2 out side by side. Tokyo is the easternmost bubble of the seed.
const boxes = await clusters(p).evaluateAll(els => els.map(el => {
const r = el.getBoundingClientRect()
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
}))
const tokyo = boxes.sort((a, b) => b.x - a.x)[0]
if (!tokyo) throw new Error('no bubble on the map to zoom in on')
await p.mouse.move(tokyo.x, tokyo.y)
for (let i = 0; i < 4; i++) {
await p.mouse.wheel(0, -100)
await p.waitForTimeout(450)
}
await settle(p)
},
},
// No act: the guide ends with the whole trip on the map, which is what
// the result picture is of. `cleanup` presses the button.
{ target: p => p.getByRole('button', { name: 'Hide whole trip' }) },
],
cleanup: async p => {
const off = p.getByRole('button', { name: 'Hide whole trip' })
if (await off.isVisible().catch(() => false)) await off.click()
},
},
'map-booking-routes': {
guide: guide('map-booking-routes'),
start: p => openTrip(p),
steps: [
{ target: p => connection(p, false).first() },
{
prepare: async p => {
await connection(p, false).first().click()
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1)
await settle(p)
pin = await reachable(endpoints(p))
},
target: p => endpoints(p).nth(pin),
act: async p => {
await endpoints(p).nth(pin).click()
await expect(bookingDetailClose(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole detail, head band to footer: the text walks through all of it.
target: bookingDetail,
act: async p => {
await bookingDetailClose(p).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => allConnections(p, false),
act: async p => {
await allConnections(p, false).click()
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2)
await settle(p)
},
},
{
target: p => allConnections(p, true),
act: async p => {
await allConnections(p, true).click()
await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
// The per-trip choice lives in this browser, not on the server, so it would
// otherwise carry into every guide that runs after it in the same context.
cleanup: async p => {
const { tripId } = seededTrip()
await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId)
},
},
'map-dawarich-trail': {
guide: guide('map-dawarich-trail'),
// Day 1 in Tokyo: at the whole-trip zoom the recording is one dashed line
// along the Shinkansen and the walks through the cities vanish under the
// pins, so the map is framed on a day before the button is pressed;
// nothing fits the map to the trail.
start: openOnTokyo,
steps: [
{
target: trailPill,
act: async p => {
await trailPill(p).click()
// One read of the trip's dates from the Dawarich, thinned to 600
// points a day. The label is the reliable signal: anything but Hide
// (nothing recorded, unreachable, offline) is not a picture worth
// taking, so the wait fails on it rather than shooting an empty map.
await expect(trailPill(p)).toHaveAttribute('aria-label', 'Hide recorded route', { timeout: 120_000 })
await expect(trail(p).first()).toBeAttached({ timeout: 30_000 })
await settle(p)
await p.waitForTimeout(600)
},
},
{
// The whole map: the dashed day under the planned route, with the
// pointer parked on an empty spot so no pin's card is in the picture.
target: map,
hover: async p => {
const box = await map(p).boundingBox()
if (!box) throw new Error('the map has no box')
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
},
},
{
target: p => p.getByTestId('trip-overview-pill'),
act: async p => {
await p.getByTestId('trip-overview-pill').click()
// One router request per leg, paced; the total carries an … until
// every leg has answered, and the result picture waits for that.
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
await settle(p)
},
},
],
cleanup: async p => {
const off = p.getByRole('button', { name: 'Hide whole trip' })
if (await off.isVisible().catch(() => false)) await off.click()
if ((await trailPill(p).getAttribute('aria-pressed').catch(() => null)) === 'true') await trailPill(p).click()
// Per trip and per browser session; the test's context ends here, but
// be explicit rather than trust that.
const { tripId } = seededTrip()
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
},
},
'map-compass': {
guide: guide('map-compass'),
// The compass only exists on the GL renderers, so the guide switches the
// account to MapLibre GL (no token, OpenFreeMap tiles) before opening the
// trip, and `cleanup` puts Leaflet back: every other map guide goes by
// `#trek-map`, which the GL map does not have.
start: async p => {
await setMapProvider(p, 'maplibre-gl')
await openTrip(p)
// MapViewAuto shows Leaflet while the GL chunk loads, then swaps; WebGL
// runs on SwiftShader headless at 1920×1080×2, so the style, the vector
// tiles from tiles.openfreemap.org and the day-1 framing take their time.
await expect(glCanvas(p)).toBeVisible({ timeout: 60_000 })
await expect(resetNorth(p)).toBeVisible({ timeout: 30_000 })
await glMapIdle(p)
await settle(p)
// `settle` counts `<img>` loads; a WebGL canvas has none, so give the
// tiles a moment to paint or the picture catches the blank canvas.
await p.waitForTimeout(2000)
},
steps: [
{
// The turn itself is the map's own camera move rather than a synthetic
// right-button drag: a drag has to start on bare canvas, and the pins,
// the two floating columns and the explore pill all sit over it. 35°
// is enough for the streets to lie visibly askew and the arrow to lean.
prepare: async p => {
await p.evaluate(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
if (!m) throw new Error('no GL map on the page')
m.easeTo({ bearing: 35, pitch: 0, duration: 0 })
})
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && Math.abs(m.getBearing() - 35) < 0.5 && !m.isMoving()
})
await settle(p)
},
target: compassPill,
},
{
target: compassPill,
act: async p => {
await resetNorth(p).click()
// `easeTo` takes 300 ms; the compass is only upright once it has landed.
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && Math.abs(m.getBearing()) < 0.5 && Math.abs(m.getPitch()) < 0.5 && !m.isMoving()
})
await settle(p)
},
},
],
// The renderer is kept on the account, not on the trip: left on MapLibre it
// would be the map of every picture taken after this guide, and every
// `#trek-map` locator in the other guides would find nothing.
cleanup: p => setMapProvider(p, 'leaflet'),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureMapFixtures(request)
// The recorded route: the addon on, the signed-in user connected to the
// Dawarich from media.env, the synthetic recording of the trip uploaded.
await ensureDawarichConnection(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: while the addon is on, every trip map
// carries the round Dawarich button in its bottom-right corner. This file's
// pictures show it on purpose; the other trip screens' do not, so it goes
// off again here, whatever happened in between. The renderer likewise: the
// compass guide puts Leaflet back in its cleanup, but a guide that fails
// never reaches its cleanup, and every `#trek-map` locator in the files that
// run after this one would find a GL canvas instead.
await disconnectDawarich(request)
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
})
test('every registered map guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort())
})
test('hero: trip-map', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripMapContext.id, p => openTrip(p))
})
for (const id of tripMapContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-610
View File
@@ -1,610 +0,0 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, coverFixture, ensureTrack, ensureDaysFixtures, ensurePlaceFixtures, PLACE_WEBSITE, RATED_PLACE } from './fixtures'
import { openTrip, selectDay, modal, dialog } from './trip-shared'
import { long } from '../dates'
import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the place card that floats over the map, keyed by the ids in
* `src/help/contexts/tripPlace.ts`. They run on the seeded "Autumn in Japan"
* with the Kyoto walk imported, the day fixtures in place and this screen's own
* fixtures on top; what a guide changes it changes back in `cleanup`, so the
* next one starts from the same card.
*/
const guide = (id: string): HelpGuide => {
const g = tripPlaceGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** An unplanned waypoint of the imported walk: the place that is on no day yet. */
const SPARE = 'Ginkaku-ji'
/** The walk itself: the only place of the trip that carries a path. */
const TRACK = "Philosopher's Path"
/** What the GPX import hands the trip's first track: TRACK_COLORS[0]. */
const TRACK_IMPORT_COLOR = '#1d4ed8'
/** The colour the guide picks instead: TRACK_COLORS[1]. */
const TRACK_PICKED_COLOR = '#ea580c'
/** The booking `ensureDaysFixtures` pins to the market stop on day 6. */
const BOOKING = 'Lunch at Nishiki'
/** The only list the seed creates. */
const LIST = 'Kyoto shortlist'
/** The list's row in the picker: its name, then the status chip once the place is in it. */
const LIST_ROW = new RegExp(`^${LIST}(?: (?:Idea|Planned|Visited))?$`)
const UPLOADED = 'cover-fixture.jpg'
/** The card itself: the scroll body's parent holds head, body and footer. */
const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..')
const head = (page: Page) => card(page).locator('> header')
const footer = (page: Page) => card(page).locator('> footer')
/** A block of the card by its heading: every block is a <section> that opens with it. */
const section = (page: Page, heading: string) =>
card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
/** A row of the places column (the rows are options of a list). */
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
/** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */
const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first()
/** PlaceRating's root: the stars, the average and the voter faces on one line. */
const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..')
/** ParticipantsBox: the block under its heading. */
const participants = (page: Page) => section(page, 'Participants')
/** A member's chip, or, once the chip is gone, that member's line in the add list. */
const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) })
const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true })
/** "Files" while the place has none, "<n> files" once it has. */
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ })
/** The booking strip opens the booking; it is a button named after it. */
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
/** The booking the strip opens, named by its title. Its form is one Edit further. */
const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING })
/** The strip's lower row: Date, Time and Booking Code beside each other. */
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
// Exact: the picker's own button is named "Track color: #EA580C" once the colour is set.
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR, exact: true })
/** The seed's day 1: the trip starts nine days before the picture day (`start_date: day(-9)`). */
const DAY_ONE = -9
/** The weekday the English UI prints for a seeded day, `Saturday` on a run where day 1 is one. */
const weekday = (offset: number) => long(offset).split(',')[0]
/**
* The Opening Hours row: the one button of the card with a clock in it. Found
* by the icon rather than the label, because the label is the open day's line
* ("Saturday: 06:00-17:00") and the day moves with the run.
*/
const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)')
/** The hours block: the row and, once unfolded, the week in its place. */
const hoursBox = (page: Page) => section(page, 'Opening Hours')
/** The card's own X, the one in its head; the day panel over the map has another. */
async function closeCard(page: Page): Promise<void> {
await head(page).locator('button:has(svg.lucide-x)').click()
await expect(card(page)).toHaveCount(0)
}
/**
* Put the provider's answer for a place into the card's own cache before the
* card asks for it. The card reads `sessionStorage` under this key first
* (PlaceInspector's usePlaceDetails) and only then asks the server, with an
* eight-second limit that Overpass exceeds as often as not; asked here over
* the API there is no such limit, and a thin answer is asked again.
*/
async function primeDetails(page: Page, placeName: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: { name: string; osm_id?: string | null }[] } | { name: string; osm_id?: string | null }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
const osmId = places.find(p => p.name === placeName)?.osm_id
if (!osmId) throw new Error(`${placeName} carries no OpenStreetMap id; ensurePlaceFixtures did not run`)
type Details = { opening_hours?: string[] | null } | null
const hasWeek = (d: Details): boolean => Array.isArray(d?.opening_hours) && d.opening_hours.length === 7
let details: Details = null
for (let attempt = 1; attempt <= 3 && !hasWeek(details); attempt++) {
const answer = await page.request.get(`/api/maps/details/${encodeURIComponent(osmId)}?lang=en`, { timeout: 45_000 })
details = answer.ok() ? ((await answer.json()) as { place: Details }).place : null
}
if (!hasWeek(details)) throw new Error(`no opening hours for ${placeName} (${osmId}) from the details route`)
await page.evaluate(([key, value]) => sessionStorage.setItem(key, value), [`gdetails_${osmId}_en`, JSON.stringify(details)])
}
/**
* Open a place that carries an OpenStreetMap id and wait until the provider's
* answer is on the card.
*
* The hours, the ring and the phone number arrive one request after the card:
* the server asks Overpass and Nominatim for them, caches nothing on that path,
* and the card swallows a lookup that times out. `settle` waits five seconds
* for the network at most, which a slow mirror exceeds, and the card would then
* be pictured without the feature the text is about. So this waits for the
* hours row itself, and when it has not come, closes the card and opens the
* place again, which fires the lookup afresh (closing clears the id the hook
* watches). A third miss fails the run. With `day` the row's line is checked
* against that day's weekday, which is what the step text promises; with
* `phone` the tel link has to be there too.
*/
async function openWithHours(
page: Page,
open: (p: Page) => Promise<void>,
opts: { day?: number; phone?: RegExp; place?: string } = {},
): Promise<void> {
await primeDetails(page, opts.place ?? 'Senso-ji Temple')
for (let attempt = 1; ; attempt++) {
await open(page)
const shown = await hoursRow(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false)
if (shown) break
if (attempt === 3) throw new Error(`no opening hours on the card after ${attempt} openings`)
await closeCard(page)
}
if (opts.day !== undefined) await expect(hoursRow(page)).toHaveText(new RegExp(`^${weekday(opts.day)}: `))
if (opts.phone) await expect(card(page).getByRole('link', { name: opts.phone })).toBeVisible()
await settle(page)
}
async function openPlace(page: Page, name: string): Promise<void> {
await row(page, name).click()
await expect(card(page)).toBeVisible()
await settle(page)
}
/** Opening the card from a stop is what gives it the stop's participants and booking. */
async function openStop(page: Page, name: string): Promise<void> {
await stop(page, name).click()
await expect(card(page)).toBeVisible()
await settle(page)
}
// ── The seed over the API, for the cleanups ──────────────────────────────────
type ApiPlace = { id: number; name: string; lat?: number | null; lng?: number | null }
type ApiAssignment = { id: number; place_id: number }
async function tripPlaces(page: Page): Promise<ApiPlace[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
const body = (await res.json()) as { places?: ApiPlace[] } | ApiPlace[]
return Array.isArray(body) ? body : (body.places ?? [])
}
const findPlace = async (page: Page, name: string): Promise<ApiPlace | undefined> =>
(await tripPlaces(page)).find(p => p.name === name)
async function patchPlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
const { tripId } = seededTrip()
const place = await findPlace(page, name)
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
}
async function assignmentsOf(page: Page, dayIndex: number): Promise<ApiAssignment[]> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: ApiAssignment[] }
return body.assignments ?? []
}
async function assignmentFor(page: Page, dayIndex: number, name: string): Promise<ApiAssignment | undefined> {
const place = await findPlace(page, name)
if (!place) return undefined
return (await assignmentsOf(page, dayIndex)).find(a => a.place_id === 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 })
/**
* Take a place out of every collection it sits in.
*
* Run before the guide as well as after it: the Collections screen's own guides
* run earlier in the suite and leave places saved, and a place that is already
* in a list offers Saved where this guide's first step looks for Save to
* Collection.
*/
async function unsaveEverywhere(page: Page, name: string): Promise<void> {
const place = await findPlace(page, name)
if (!place) return
const query = new URLSearchParams({ name: place.name, lat: String(place.lat ?? ''), lng: String(place.lng ?? '') })
const res = await page.request.get(`/api/addons/collections/membership?${query.toString()}`)
if (!res.ok()) return
const body = (await res.json()) as { lists?: { place_id: number }[] }
for (const entry of body.lists ?? []) {
await page.request.delete(`/api/addons/collections/places/${entry.place_id}`)
}
}
const SCRIPTS: Record<string, GuideScript> = {
'read-place': {
guide: guide('read-place'),
// Day 1 explicitly: closing a day's detail panel clears the open day, and
// the card is a different card without one.
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
},
steps: [
{
target: p => stop(p, 'Senso-ji Temple'),
act: p => openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ }),
},
only(head),
only(ratingRow),
only(p => card(p).locator('.collab-note-md').first()),
only(participants),
{
// Unfolded before the picture: the step is about the week, and a ring
// around the collapsed row would show the reader one line of it.
prepare: async p => {
await hoursRow(p).click()
await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible()
await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible()
await settle(p)
},
target: hoursBox,
},
only(footer),
],
},
'rate-place': {
guide: guide('rate-place'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, RATED_PLACE)
},
steps: [
only(ratingRow),
{
target: p => card(p).getByRole('radio', { name: '4', exact: true }),
act: async p => {
await card(p).getByRole('radio', { name: '4', exact: true }).click()
await expect(card(p).getByRole('radio', { name: '4', exact: true }))
.toHaveAttribute('aria-checked', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
// captureGuide hovers the target before it shoots, which is the gesture
// the step describes; the tooltip is a portal, so it is asserted here.
target: ratingRow,
act: async p => { await expect(p.getByRole('tooltip')).toBeVisible() },
},
only(p => row(p, RATED_PLACE)),
],
cleanup: async p => {
const { tripId } = seededTrip()
const place = await findPlace(p, RATED_PLACE)
// Only the admin's vote: the two members' votes are the fixture's.
if (place) await p.request.delete(`/api/trips/${tripId}/places/${place.id}/rating`)
},
},
'place-image': {
guide: guide('place-image'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => row(p, 'Arashiyama Bamboo Grove'),
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
},
{
target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }),
act: async p => {
await card(p).locator('input[accept*="image/jpeg"]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(head),
only(p => row(p, 'Arashiyama Bamboo Grove')),
],
cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }),
},
'place-day-assign': {
guide: guide('place-day-assign'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => dayHeader(p, 6),
act: p => selectDay(p, 6),
},
{
target: p => row(p, SPARE),
act: async p => {
await openPlace(p, SPARE)
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible()
},
},
{
target: p => footer(p).getByRole('button', { name: 'Add to Day' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Add to Day' }).click()
await expect(footer(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
// The gesture only: dragging it here would leave the day reordered for the
// last step's picture, and the arrow already says what to do.
{ target: p => stop(p, SPARE), dropTo: p => stop(p, 'Arashiyama Bamboo Grove') },
{
target: p => footer(p).getByRole('button', { name: 'Remove from Day' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Remove from Day' }).click()
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId, dayIds } = seededTrip()
const assignment = await assignmentFor(p, 5, SPARE)
if (assignment) await p.request.delete(`/api/trips/${tripId}/days/${dayIds[5]}/assignments/${assignment.id}`)
},
},
'place-participants': {
guide: guide('place-participants'),
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
},
steps: [
{
target: p => stop(p, 'teamLab Planets'),
act: async p => {
await openStop(p, 'teamLab Planets')
await expect(participants(p)).toBeVisible()
},
},
{
target: p => member(p, 'jonas'),
act: async p => {
await member(p, 'jonas').click()
await expect(member(p, 'jonas')).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await addMember(p).click()
await expect(member(p, 'jonas')).toBeVisible()
await beat(p, 300)
},
target: participants,
},
{
target: p => member(p, 'jonas'),
act: async p => {
await member(p, 'jonas').click()
await expect(addMember(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const assignment = await assignmentFor(p, 0, 'teamLab Planets')
// An empty list is what "everybody is going" is stored as.
if (assignment) {
await p.request.put(`/api/trips/${tripId}/assignments/${assignment.id}/participants`, { data: { user_ids: [] } })
}
},
},
'place-booking': {
guide: guide('place-booking'),
start: async p => {
await openTrip(p, { day: null })
await selectDay(p, 6)
},
steps: [
{
target: p => stop(p, 'Nishiki Market'),
act: async p => {
await openStop(p, 'Nishiki Market')
await expect(card(p).getByText(BOOKING)).toBeVisible()
},
},
// The fields rather than the whole strip: step 3 rings the strip, and two
// steps pointing at one element give the reader the same picture twice.
only(bookingFields),
{
target: bookingStrip,
act: async p => {
await bookingStrip(p).click()
await expect(bookingDetail(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await bookingDetail(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
await settle(p)
},
target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'),
act: closeModal,
},
{
// The + is rendered only while its own stop is hovered, so the row is
// put in view and hovered before the target is looked up, and hovered
// again for the picture instead of the button itself.
prepare: async p => {
await stop(p, 'Nishiki Market').scrollIntoViewIfNeeded()
await stop(p, 'Nishiki Market').hover()
await expect(stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' })).toBeVisible()
},
target: p => stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }),
hover: p => stop(p, 'Nishiki Market').hover(),
act: async p => {
await stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }).click()
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
await settle(p)
},
},
],
// The new booking is never saved, so there is nothing to delete.
cleanup: closeModal,
},
'place-files': {
guide: guide('place-files'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, 'teamLab Planets')
},
steps: [
only(filesToggle),
{
target: p => card(p).getByText('Upload', { exact: true }),
act: async p => {
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The list opens itself once the upload lands.
only(p => card(p).getByRole('button', { name: '1 file', exact: true })),
// No click: openFile hands the browser a download.
only(p => card(p).getByRole('button', { name: UPLOADED })),
],
cleanup: async p => {
const { tripId } = seededTrip()
const res = await p.request.get(`/api/trips/${tripId}/files`)
const body = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of (body.files ?? []).filter(f => f.original_name === UPLOADED)) {
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
await p.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
},
},
'place-navigation': {
guide: guide('place-navigation'),
start: async p => {
await openTrip(p, { day: null })
await openWithHours(p, q => openPlace(q, PLACE_WEBSITE.place), { place: PLACE_WEBSITE.place })
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Navigation' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Navigation' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
only(p => p.getByRole('menu')),
{
// Clicking one opens an external tab, so the menu is closed instead.
target: p => p.getByRole('menuitem', { name: 'Google Maps' }),
act: async p => {
await p.keyboard.press('Escape')
await expect(p.getByRole('menu')).toHaveCount(0)
},
},
only(p => footer(p).getByRole('button', { name: 'Open Website' })),
],
},
'place-to-collection': {
guide: guide('place-to-collection'),
start: async p => {
await unsaveEverywhere(p, 'Senso-ji Temple')
await openTrip(p, { day: null })
await openWithHours(p, q => openPlace(q, 'Senso-ji Temple'))
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Save to Collection' }),
act: async p => {
await footer(p).getByRole('button', { name: 'Save to Collection' }).click()
await expect(modal(p).getByRole('heading', { name: 'Save to list' })).toBeVisible()
await settle(p)
},
},
only(dialog),
{
// Anchored, not exact: the Collections guides leave a list behind whose
// name opens with this one's, and once the place is saved the row's
// name grows the status chip (Idea, Planned, Visited).
target: p => modal(p).getByRole('button', { name: LIST_ROW }),
act: async p => {
await modal(p).getByRole('button', { name: LIST_ROW }).click()
await expect(modal(p).getByRole('button', { name: LIST_ROW })).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Close' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Close' }).click()
await expect(modal(p)).toHaveCount(0)
await expect(footer(p).getByRole('button', { name: 'Saved' })).toBeVisible({ timeout: 20_000 })
},
},
],
cleanup: p => unsaveEverywhere(p, 'Senso-ji Temple'),
},
'place-track': {
guide: guide('place-track'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => row(p, TRACK),
act: async p => {
await openPlace(p, TRACK)
await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible()
},
},
only(p => section(p, 'Track Stats')),
{
target: p => card(p).getByRole('button', { name: 'Track color' }),
act: async p => {
await card(p).getByRole('button', { name: 'Track color' }).click()
await expect(swatch(p)).toBeVisible()
await settle(p)
},
},
{
target: swatch,
act: async p => {
await swatch(p).click()
await expect(swatch(p)).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
// No click: it would undo the result picture. The reset happens in cleanup.
only(p => card(p).getByRole('button', { name: 'Automatic color' })),
],
cleanup: p => patchPlace(p, TRACK, { route_color: TRACK_IMPORT_COLOR }),
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
await ensureDaysFixtures(request)
await ensurePlaceFixtures(request)
})
test('every registered place guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlaceContext.guides].sort())
})
test('hero: trip-place', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripPlaceContext.id, async p => {
await openTrip(p, { day: null })
await selectDay(p, 1)
await openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ })
})
})
for (const id of tripPlaceContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-519
View File
@@ -1,519 +0,0 @@
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, confirmDialog } 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' }
/** What create-place looks up. See the note in its second step for why this one. */
const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ }
/** The search result create-place picks. */
const suggestion = (page: Page) =>
modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first()
/** 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 head band of the column: the add row, the search with the select switch,
* and the row of filters. Found from its import button, the one control only
* this band has, so the plain "Search" of the field is read inside it.
*/
const head = (page: Page) =>
page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]')
/** The import button's menu: Import file and the list import. */
const importMenu = (page: Page) =>
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last()
/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */
const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true })
const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..')
/** The category filter: a tag button whose list sits right after it. */
const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true })
const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]')
const searchBox = (page: Page) => head(page).getByPlaceholder('Search', { exact: true })
/** The selection bar at the foot of the column, found by its delete action. */
const selectionBar = (page: Page) => page.getByRole('button', { name: 'Delete selected' }).locator('xpath=..')
/** FileImportModal, recognised by its hidden file input. */
const importFileDialog = (page: Page) =>
page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') })
/** ListImportModal, recognised by the link box. */
const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) })
/** The drop box of the file dialog: the dashed button that opens the file picker. */
const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first()
/** 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 sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
const sortList = (page: Page) => sortTrigger(page).locator('xpath=following-sibling::div[1]')
/** A name no index knows, so the search comes back empty and offers Add by hand. */
const HANDMADE = { name: 'Kissaten Hoshizora Ura', phone: '+81 75 000 1234', email: 'hello@hoshizora.example' }
const SCRIPTS: Record<string, GuideScript> = {
'sort-places': {
guide: guide('sort-places'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await sortTrigger(p).click()
await expect(sortList(p)).toBeVisible()
await beat(p, 300)
},
target: sortList,
},
{
target: p => sortList(p).getByRole('button', { name: 'Highest rated' }),
act: async p => {
await sortList(p).getByRole('button', { name: 'Highest rated' }).click()
await expect(sortList(p)).toHaveCount(0)
await settle(p)
},
},
],
cleanup: async p => {
await sortTrigger(p).click()
await sortList(p).getByRole('button', { name: 'Recently added' }).click()
},
},
'place-by-hand': {
guide: guide('place-by-hand'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await addButton(p).click()
const box = modal(p).getByPlaceholder('Search places...')
await expect(box).toBeVisible()
await typeInto(p, box, HANDMADE.name)
await box.press('Enter')
await expect(modal(p).getByText(/^Nothing found for/)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
target: p => modal(p).getByRole('button', { name: 'Add by hand' }).locator('xpath=..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add by hand' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add by hand' }).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(HANDMADE.name)
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HANDMADE.phone)
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HANDMADE.email)
await settle(p)
},
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Add opening hours' }).click()
await expect(modal(p).getByRole('button', { name: 'Remove opening hours' })).toBeVisible()
await modal(p).getByRole('button', { name: 'Remove opening hours' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[.//input][1]'),
},
{
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, HANDMADE.name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, HANDMADE.name),
},
'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)
},
},
{
// The results are pictured before the pick: once one is taken the
// search box empties again, and a ring round an empty box shows the
// reader nothing of what the step says.
prepare: async p => {
const box = modal(p).getByPlaceholder('Search places...')
// Osaka Castle on purpose: its entry in the index carries a picture,
// opening hours and a description, so Place details shows what the
// text says it shows. Most results have none of that and the column
// would be an empty state in the picture.
await typeInto(p, box, SEARCHED.query)
await expect(suggestion(p)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => modal(p).locator('.shadow-dropdown').first(),
hover: p => suggestion(p).hover(),
act: async p => {
await suggestion(p).click()
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 })
// The details column answers after the form; wait for it or the
// picture catches the spinner.
await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The details column is an <aside> beside the form; both used to resolve to
// the whole dialog, which gave two steps the same picture.
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::aside[1]')),
// The name and the category sit in the head band, above the form, so the
// step about the fields shows the dialog rather than the form alone.
only(p => dialog(p)),
{
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(row(p, SEARCHED.query)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByName(p, SEARCHED.query),
},
'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).getByRole('button', { name: '+ Day' }),
act: async p => {
await row(p, SPARE.name).getByRole('button', { name: '+ Day' }).click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
},
{
// The other way round, and the one most readers try first: the row is
// dragged onto a day card. The place is off the day again before the
// drag so the picture shows it leaving the column rather than a stop
// that is already there.
prepare: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(row(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: p => row(p, SPARE.name),
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
act: async p => {
await row(p, SPARE.name).dragTo(p.getByRole('button', { name: /^2 .*Day 2 / }))
await expect(p.locator('.dp-row[role="button"]').filter({ hasText: SPARE.name }).first()).toBeVisible({ 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 showTrigger(p).click()
await expect(showList(p)).toBeVisible()
await beat(p, 300)
},
target: showList,
// The lists close on a second click of their trigger.
act: async p => { await showTrigger(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) },
},
{
// One category ticked, so the list shows the tick, the button its count
// and the foot of the list its Clear filter.
prepare: async p => {
await categoryTrigger(p).click()
await expect(categoryList(p)).toBeVisible()
// No Category: the seed's places carry none, so it is the choice that
// leaves rows in the list rather than an empty column.
await categoryList(p).getByRole('button', { name: /No Category/ }).click()
await expect(categoryList(p).getByRole('button', { name: 'Clear filter' })).toBeVisible()
// The list scrolls inside itself; its foot is where the tick and Clear filter are.
await categoryList(p).evaluate(el => { el.scrollTop = el.scrollHeight })
await beat(p, 300)
},
target: categoryList,
act: async p => {
await categoryList(p).getByRole('button', { name: 'Clear filter' }).click()
await categoryTrigger(p).click()
await expect(categoryList(p)).toHaveCount(0)
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(confirmDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: confirmDialog,
act: async p => {
await confirmDialog(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)
},
},
{
// Two rows next to each other, so one picture shows both ticks.
prepare: async p => {
await row(p, 'Shibuya Crossing').click()
await row(p, 'Meiji Jingu').click()
await expect(p.getByRole('status', { name: '2 selected' })).toBeVisible()
await beat(p, 300)
},
target: p => row(p, 'Meiji Jingu'),
},
only(selectionBar),
{
target: p => selectionBar(p).getByRole('button', { name: 'Done' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Done' }).click()
await expect(p.getByRole('button', { name: 'Select all' })).toHaveCount(0)
},
},
],
},
'import-places-file': {
guide: guide('import-places-file'),
start: p => openTrip(p),
steps: [
{
prepare: async p => {
await p.getByRole('button', { name: 'Import Places' }).click()
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
target: p => importMenu(p).getByRole('button', { name: 'Import file' }),
act: async p => {
await importMenu(p).getByRole('button', { name: 'Import file' }).click()
await expect(importFileDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await importFileDialog(p).locator('input[type="file"]').setInputFiles(gpxFixture('arashiyama-loop'))
await expect(importFileDialog(p).getByText('Tracks (with path geometry)')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
target: importFileDialog,
},
only(dropBox),
{
target: p => importFileDialog(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await importFileDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(importFileDialog(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: [
{
prepare: async p => {
await p.getByRole('button', { name: 'Import Places' }).click()
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
// Google List while Google is the only list provider, List Import once Naver is on too.
target: p => importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }),
act: async p => {
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
await expect(listImportDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, listImportDialog(p).getByPlaceholder(/goo\.gl|naver/), 'https://maps.app.goo.gl/kyoto-favourites')
await beat(p, 300)
},
target: listImportDialog,
},
only(p => listImportDialog(p).getByRole('button', { name: 'Import', exact: true })),
],
// The dialog has no Escape route: Cancel is the way out.
cleanup: async p => {
if (await listImportDialog(p).isVisible().catch(() => false)) {
await listImportDialog(p).getByRole('button', { name: 'Cancel' }).click()
await expect(listImportDialog(p)).toHaveCount(0)
}
},
},
}
// ── 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])
})
}
-760
View File
@@ -1,760 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for road trip mode, keyed by the ids in
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
* guide writes it takes away again in `cleanup`, and `test.afterAll` switches
* the addon off again: it is an instance-wide setting, and while it is on
* every trip's left column grows a switch the other screens never show.
*/
const guide = (id: string): HelpGuide => {
const g = tripRoadtripGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
// ── Where things are ──────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
const dayCard = (page: Page, day: number) =>
rail(page)
.locator('> section')
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
.first()
/**
* One stop's row and everything hanging off it. The kind disc sits inside the
* row button while the stop is a destination and beside it once it is a stop on
* the way, so the list item is the only handle that holds for both.
*/
const stopItem = (page: Page, name: string) => rail(page).getByRole('listitem').filter({ hasText: name }).first()
const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('button').filter({ hasText: name }).first()
const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' })
/** The corridor panel: before a search its heading is the only stable thing in it. */
const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]')
/**
* The two dropdowns of the Looking for line. Both are listbox triggers, so they
* are told apart by name: the kinds read "Looking for: Fuel", the day "Day 1".
*/
const kindPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Looking for/ })
const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first()
/** The list a dropdown opens; only one is open at a time. */
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
/** The drive band of day 4's long leg, the one with more than one road to it. */
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
/** The alternatives bar floats over the map, inside neither column. */
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
const dryBand = (page: Page) => page.getByText('Tank runs out here').first().locator('xpath=ancestor::div[3]')
const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first()
/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */
const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first()
// ── Getting there ─────────────────────────────────────────────────────────────
async function openRoadtrip(page: Page): Promise<void> {
await openTrip(page, { day: null })
await page.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(page)
}
/**
* Every leg of every day is a router round trip, and the rail says nothing at
* all until they land. The totals appear first and the partial line only while
* something is outstanding, so neither of them on its own says the drive is
* there; a drive band on a day that really has one does.
*/
async function waitForTheDrive(page: Page): Promise<void> {
await expect(page.getByText('Driving time', { exact: true })).toBeVisible({ timeout: 120_000 })
await expect(dayCard(page, 2).getByRole('button', { name: /Other ways$/ }).first()).toBeVisible({ timeout: 120_000 })
await expect(page.getByText('Still working out the rest of the drive')).toHaveCount(0, { timeout: 120_000 })
await settle(page)
}
/**
* Put a leg where the pointer can reach it. The map opens fitted to the whole
* trip, which leaves the drawn route under the two floating panels; clicking a
* stop frames that stop instead, and the card it opens is closed again at once.
*/
async function frameOnStop(page: Page, name: string): Promise<void> {
// On the name: the row's centre can land on its Stay badge, which opens its
// own dialog instead of selecting the stop.
await stopRow(page, name).click({ position: { x: 70, y: 12 } })
// The place's card opens over the map; its Close sits in the card's head band.
const head = placeCardHead(page, name)
await expect(head).toBeVisible({ timeout: 15_000 })
await finishMapPan(page)
await head.getByRole('button', { name: 'Close', exact: true }).click()
await expect(head).toHaveCount(0)
await settle(page)
}
/**
* Let the map's pan to a picked place land. Leaflet times its pan from
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
* the pan starts and never gets anywhere: the map stays where it was. Moving the
* pinned clock a few seconds on ends it on the next frame, as a second passing
* would; nothing in these pictures reads the time to the second.
*/
async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400)
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
await page.waitForTimeout(1200)
await settle(page)
}
/**
* The head band of the card a stop opens over the map (the place inspector):
* the place's name as a heading, and Close beside it. The rail prints the same
* name, but never as a heading.
*/
const placeCardHead = (page: Page, name: string) =>
page.getByRole('heading', { name, exact: true, level: 2 }).locator('xpath=ancestor::header[1]')
type RoutePoint = { leg: number; x: number; y: number }
/**
* Points that really lie on a drawn leg, with nothing over them. A bounding-box
* centre is no use here: a route is a curve, its box is mostly empty space, and
* what sits in the middle of it is usually somebody else's pin.
*/
async function routePoints(page: Page): Promise<RoutePoint[]> {
return page.evaluate(() => {
const paths = Array.from(
document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]'),
)
const found: { leg: number; x: number; y: number }[] = []
paths.forEach((path, leg) => {
const length = path.getTotalLength()
const ctm = path.getScreenCTM()
if (!ctm || length < 24) return
for (let f = 0.08; f <= 0.92; f += 0.02) {
const at = path.getPointAtLength(length * f)
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e)
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f)
// The two panels float over the map; a point under one of them is not clickable.
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
}
})
return found
})
}
/** Which leg the via guide aims at, and where; worked out once, used by three steps. */
let aimedLeg = 0
let aimedAt = { x: 0, y: 0 }
/** The day the via landed on, read back between two steps of the same guide. */
let viaDayId = 0
/** Names of what a guide created, read back by its cleanup. */
let addedStop = ''
let refuelStop = ''
/** The leg with the most reachable points: the one worth clicking and worth a picture. */
async function aimAtLeg(page: Page): Promise<void> {
const points = await routePoints(page)
if (points.length === 0) throw new Error('no drawn leg is reachable on the map')
const counts = new Map<number, number>()
for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1)
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
aimedLeg = best ? best[0] : points[0].leg
const own = points.filter(point => point.leg === aimedLeg)
const middle = own[Math.floor(own.length / 2)] ?? points[0]
aimedAt = { x: middle.x, y: middle.y }
}
const aimedLine = (page: Page) => page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').nth(aimedLeg)
/** The pointer goes on the line, not on the centre of the box around it. */
const hoverAimedLine = async (page: Page): Promise<void> => {
await page.mouse.move(aimedAt.x, aimedAt.y)
}
// ── What the guides write, and how it is taken back ───────────────────────────
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
async function listVias(page: Page): Promise<ViaRow[]> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const body = (await res.json()) as { vias?: ViaRow[] }
return body.vias ?? []
}
async function clearVias(page: Page): Promise<void> {
const { tripId } = seededTrip()
for (const via of await listVias(page)) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`)
}
// A followed track is recorded per day even when the drive needed no via to
// follow it, and deleting vias leaves that record standing: clear it too.
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
const { tracks = [] } = (await res.json()) as { tracks?: { day_id: number }[] }
for (const track of tracks) {
await page.request.post(`/api/trips/${tripId}/roadtrip/days/${track.day_id}/vias/batch`, {
data: { vias: [], track: null },
})
}
}
async function clearDayBoundaries(page: Page): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/day-boundaries`)
const body = (await res.json()) as { boundaries?: { day_number: number }[] }
for (const boundary of body.boundaries ?? []) {
await page.request.delete(`/api/trips/${tripId}/roadtrip/day-boundaries/${boundary.day_number}`)
}
}
async function listPlaces(page: Page): Promise<{ id: number; name: string }[]> {
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 }[]
return Array.isArray(body) ? body : (body.places ?? [])
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const wanted = names.filter(name => name.length > 0)
if (wanted.length === 0) return
for (const place of (await listPlaces(page)).filter(x => wanted.includes(x.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
}
}
/** Put one place back the way the seed had it. */
async function restorePlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
const { tripId } = seededTrip()
const place = (await listPlaces(page)).find(x => x.name === name)
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
}
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> = {
'roadtrip-mode': {
guide: guide('roadtrip-mode'),
start: p => openTrip(p, { day: null }),
steps: [
{
target: p => p.getByRole('tablist', { name: 'Plan view' }),
act: async p => {
await p.getByRole('tab', { name: 'Road trip' }).click()
await waitForTheDrive(p)
},
},
only(p => p.getByText('Driving time', { exact: true }).locator('xpath=ancestor::header[1]')),
only(p => dayCard(p, 2).locator('header')),
only(p => dayCard(p, 2).getByRole('list').first()),
{
// Day 2, like the two steps above: the rail leaves out a day with
// nothing to drive, and day 1 is one of them.
target: p => dayCard(p, 2).locator('header'),
act: async p => {
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'false')
await settle(p)
// Folded is not the state to leave the trip in, and the result picture
// is of the whole drive rather than of one card shut.
await dayCard(p, 2).locator('header').click()
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'true')
await settle(p)
},
},
],
},
'roadtrip-stops': {
guide: guide('roadtrip-stops'),
start: openRoadtrip,
steps: [
{
// The disc itself, by its own label: the row button around it takes the
// disc's label into its accessible name, so the role query matches both.
target: p => stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]'),
act: async p => {
await stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]').click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
},
{
target: kindDialog,
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Rest area' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(
stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }),
).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
only(p => dayCard(p, 2).locator('header')),
{
prepare: async p => {
await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }).click()
await expect(kindDialog(p)).toBeVisible()
await settle(p)
},
target: p => kindDialog(p).getByRole('button', { name: 'Back to a destination' }),
act: async p => {
await kindDialog(p).getByRole('button', { name: 'Back to a destination' }).click()
await expect(kindDialog(p)).toHaveCount(0)
await expect(stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]')).toBeVisible({
timeout: 20_000,
})
await settle(p)
},
},
{
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
act: async p => {
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: '1 h', exact: true }).click()
await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60')
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0)
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
},
},
'roadtrip-corridor': {
guide: guide('roadtrip-corridor'),
start: async p => {
addedStop = ''
await openRoadtrip(p)
},
steps: [
{
// The line reads "these kinds, along this day", so the kinds come first.
prepare: async p => {
await kindPicker(p).click()
await expect(openList(p)).toBeVisible()
await settle(p)
},
target: openList,
act: async p => {
// Fuel is what the panel opens on for a petrol car; Food is ticked beside it.
await openList(p).getByRole('option', { name: 'Food' }).click()
await expect(openList(p).getByRole('option', { name: 'Food' })).toHaveAttribute('aria-selected', 'true')
// The list stays open for a second kind; a second click on its trigger shuts it.
await kindPicker(p).click()
await expect(openList(p)).toHaveCount(0)
await expect(kindPicker(p)).toHaveAccessibleName(/Food/)
await settle(p)
},
},
{
target: dayPicker,
act: async p => {
await dayPicker(p).click()
// The rows are options named by the day's number and its label, "2 Day 2".
await openList(p).getByRole('option', { name: /\bDay 2$/ }).click()
await expect(dayPicker(p)).toContainText('Day 2')
await settle(p)
},
},
{
target: p => corridor(p).getByRole('button', { name: '5 km', exact: true }).locator('xpath=..'),
act: async p => {
// The step's sentence tells the reader to choose a width and then search,
// so the width is chosen here even though 5 km is what the panel opens on.
await corridor(p).getByRole('button', { name: '5 km', exact: true }).click()
await expect(corridor(p).getByRole('button', { name: '5 km', exact: true })).toHaveAttribute(
'aria-pressed',
'true',
)
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
await expect(corridor(p).getByText(/on the way$/).first()).toBeVisible({ timeout: 180_000 })
await settle(p)
},
},
only(p => corridor(p).getByRole('listitem').first()),
{
prepare: async p => {
const row = corridor(p).getByRole('listitem').first()
addedStop = (await row.innerText()).split('\n')[0] ?? ''
await row.hover()
await settle(p)
},
target: p => corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }),
act: async p => {
await corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible()
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === addedStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, addedStop),
},
'roadtrip-via': {
guide: guide('roadtrip-via'),
start: openRoadtrip,
steps: [
{
// A stop of day 4, the drive out of Tokyo: the one leg long enough to be
// worth bending, and once Hakone is in the middle of the map it runs
// east across the open map rather than under the right column.
target: p => stopItem(p, 'Hakone Shrine'),
act: async p => {
await frameOnStop(p, 'Hakone Shrine')
await aimAtLeg(p)
},
},
{
target: aimedLine,
hover: hoverAimedLine,
act: async p => {
await p.mouse.click(aimedAt.x, aimedAt.y)
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(1)
await expect(viaHandle(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
prepare: async p => {
viaDayId = (await listVias(p))[0]?.day_id ?? 0
await settle(p)
},
// Which day the click landed on is the router's answer, not ours.
target: p => {
const day = seededTrip().dayIds.indexOf(viaDayId) + 1
// Fall back to day 2, not day 1: the rail has no card for a day
// with nothing to drive.
return dayCard(p, day > 0 ? day : 2).locator('header')
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
const before = JSON.stringify((await listVias(p))[0] ?? {})
// A Leaflet drag, not an HTML5 one: the handle listens for the raw
// pointer, so `dragTo` would move nothing.
await p.mouse.move(from.x, from.y)
await p.mouse.down()
await p.mouse.move(from.x + 45, from.y + 40, { steps: 10 })
await p.mouse.move(from.x + 90, from.y + 80, { steps: 10 })
await p.mouse.up()
await expect
.poll(async () => JSON.stringify((await listVias(p))[0] ?? {}), { timeout: 60_000 })
.not.toBe(before)
await settle(p)
},
},
{
target: viaHandle,
act: async p => {
const box = await viaHandle(p).boundingBox()
if (!box) throw new Error('the via handle has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' })
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(0)
await settle(p)
},
},
],
cleanup: clearVias,
},
'roadtrip-alternatives': {
guide: guide('roadtrip-alternatives'),
start: openRoadtrip,
steps: [
{
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
// Shibuya the router knows one sensible way, and the bar says only that.
target: altBand,
act: async p => {
await altBand(p).click()
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
only(altBar),
{
prepare: async p => {
await altBar(p).getByRole('button', { name: /km/ }).last().hover()
await settle(p)
},
target: p => altBar(p).getByRole('button', { name: /km/ }).last(),
},
{
target: p => altBar(p).getByRole('button', { name: 'Close' }),
act: async p => {
await altBar(p).getByRole('button', { name: 'Close' }).click()
await expect(p.getByText('Ways to drive this leg')).toHaveCount(0)
await settle(p)
},
},
],
// Nothing is chosen here, but choosing an entry writes a via, so a misclick
// must not follow the run into the next guide.
cleanup: clearVias,
},
'roadtrip-limits': {
guide: guide('roadtrip-limits'),
start: openRoadtrip,
steps: [
{
target: settingsCard,
act: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-legMinutes').locator('xpath=ancestor::section[1]'),
act: async p => {
await modal(p).getByTestId('limit-legMinutes').fill('60')
await p.keyboard.press('Tab')
await expect(modal(p).getByTestId('limit-legMinutes')).toHaveValue('60')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'What you drive' }),
act: async p => {
await modal(p).getByText('Electric', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'Electric' })).toBeChecked()
await settle(p)
},
},
{
target: p => modal(p).getByTestId('limit-range').locator('xpath=ancestor::label[1]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Work it out from the car' }).click()
await expect(modal(p).getByRole('spinbutton', { name: /^Battery, / })).toBeVisible()
await settle(p)
},
},
// Not toggled on purpose: each of these switches sends every leg of the
// trip back through the avoidance router, which is minutes of waiting for
// one picture of a row of switches.
// By its title, not by a switch: without an avoidance router the switches
// are replaced by a dash and the section is all that is left of it.
only(p => modal(p).getByText('Avoid where possible', { exact: true }).locator('xpath=ancestor::section[1]')),
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
// Both halves: the badge the fixture leaves reads "1 h 30 min", which
// contains "1 h", so the shortened limit only shows in what is NOT there.
await expect(settingsCard(p)).toContainText('1 h', { timeout: 30_000 })
await expect(settingsCard(p)).not.toContainText('1 h 30 min')
await settle(p)
},
},
],
// The fixture writes the whole preference set, so re-running it is the undo.
cleanup: p => ensureRoadtripFixtures(p.request),
},
'roadtrip-day-window': {
guide: guide('roadtrip-day-window'),
start: openRoadtrip,
steps: [
{
prepare: async p => {
await settingsCard(p).click()
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
await settle(p)
},
target: p => modal(p).getByRole('group', { name: 'Daily travel times' }),
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day start time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day start time' }).fill('09:00')
await expect(modal(p).getByRole('textbox', { name: 'Day start time' })).toHaveValue('09:00')
await settle(p)
},
},
{
target: p => modal(p).getByRole('textbox', { name: 'Day end time' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Day end time' }).fill('17:30')
await p.keyboard.press('Tab')
await expect(modal(p).getByRole('textbox', { name: 'Day end time' })).toHaveValue('17:30')
await settle(p)
},
},
{
target: p => modal(p).getByRole('group', { name: 'End the day' }),
act: async p => {
await modal(p).getByText('At the last place', { exact: true }).click()
await expect(modal(p).getByRole('radio', { name: 'At the last place' })).toBeChecked()
await settle(p)
},
},
{
prepare: async p => {
await closeModal(p)
await settle(p)
},
target: settingsCard,
act: async p => {
await expect(settingsCard(p)).toContainText('09:00', { timeout: 60_000 })
await settle(p)
},
},
],
cleanup: async p => {
await clearDayBoundaries(p)
await ensureRoadtripFixtures(p.request)
},
},
'roadtrip-refuel': {
guide: guide('roadtrip-refuel'),
start: async p => {
refuelStop = ''
await openRoadtrip(p)
},
steps: [
only(dryBand),
{
target: p => p.getByRole('button', { name: 'Find fuel' }).first(),
act: async p => {
await p.getByRole('button', { name: 'Find fuel' }).first().click()
await expect(p.getByText('Looking along the route…')).toHaveCount(0, { timeout: 180_000 })
await expect(addFuelStop(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(p => dryBand(p).getByRole('list').first()),
{
prepare: async p => {
const label = (await addFuelStop(p).getAttribute('aria-label')) ?? ''
refuelStop = label.replace(/^Add /, '').replace(/ as a fuel stop$/, '')
await addFuelStop(p).hover()
await settle(p)
},
target: addFuelStop,
act: async p => {
await addFuelStop(p).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === refuelStop), { timeout: 30_000 })
.toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByName(p, refuelStop),
},
'roadtrip-track': {
guide: guide('roadtrip-track'),
start: openRoadtrip,
steps: [
{
target: p => dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }),
act: async p => {
await dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Day 7 follows a track' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('list').first(),
act: async p => {
await modal(p).getByRole('button', { name: /^Arashiyama loop/ }).click()
await expect(modal(p).getByRole('button', { name: 'Follow this track' })).toBeEnabled()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Follow this track' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Follow this track' }).click()
// One router round trip per refinement round, on a route that is
// being pulled onto a track metre by metre.
await expect(modal(p).getByText(/via points placed|already followed this track/)).toBeVisible({
timeout: 240_000,
})
await settle(p)
},
},
{
target: dialog,
act: async p => {
await closeModal(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await clearVias(p)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureRoadtripFixtures(request)
})
test.afterAll(async ({ request }) => {
// Instance-wide, not trip-scoped: left on, every trip's left column grows the
// Days / Road trip switch and every other screen's pictures change. Here
// rather than in the last guide's cleanup, so a failed run puts it back too.
await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } })
})
test('every registered road trip guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripRoadtripContext.guides].sort())
})
test('hero: trip-roadtrip', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripRoadtripContext.id, openRoadtrip)
})
for (const id of tripRoadtripContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-200
View File
@@ -1,200 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { clearNotices } from '../screenshots/shot'
import { dismissReleaseNotice, settle, type StepAction } 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()
/**
* ConfirmDialog is its own portal and carries `trek-backdrop-enter` without the
* `trek-modal-backdrop` of `Modal`, so the yes/no questions need their own
* locator: `modal` would never see them.
*/
export const confirmBackdrop = (page: Page) => page.locator('.trek-backdrop-enter:not(.trek-modal-backdrop)').last()
export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.trek-modal-enter').last()
/**
* The import dialogs of the places column are portals of their own with no
* shared backdrop class at all, so they are found by what stands inside them.
* Both the overlay and the card carry `role="presentation"`, and the card comes
* second in document order, which is the one worth a picture.
*/
export const portalDialog = (page: Page, contains: Locator): Locator =>
page.locator('div[role="presentation"]').filter({ has: contains }).last()
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)
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
await untilTranslated(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)
}
/**
* A text that is still its key: `dayplan.dayN` where "Day 1" belongs. The
* locale bundles come from `shared/dist`, and while a watcher rebuilds it a
* page can load with a part of them missing. Reloading after the rebuild has
* landed brings the strings back; a page that stays untranslated fails here
* rather than in a picture.
*/
const rawKey = (page: Page) => page.getByText(/^(common|dayplan|planner|places|reservations|trip|day|budget|files|collab|packing|todo)\.[A-Za-z0-9.]+$/)
async function untilTranslated(page: Page): Promise<void> {
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
await page.waitForTimeout(4000)
await page.reload()
await clearNotices(page)
await dismissReleaseNotice(page)
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(page)
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
}
/** Click the day's header: selects it and opens its details panel. */
export async function selectDay(page: Page, n: number): Promise<void> {
const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
// The header carries buttons of its own: the booked night in the middle, the
// transit, note and fold actions on the right. A click on the centre lands on
// the booked night as soon as a day has one, which selects nothing, so the
// click goes to the day's number at the left edge.
await header.click({ position: { x: 22, y: 20 } })
// The details panel is the selection: without it the day's route tools, the
// + on a place row and To day are all absent, and a guide fails far from here.
await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 })
await settle(page)
}
/** The day details panel: the one fixed, floating card over the map. */
export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first()
/**
* The details panel's own Close, in its head band. Found inside the panel: the
* last X of the page is not always this one once the day carries bookings.
*/
export async function closeDayDetails(page: Page): Promise<void> {
const close = dayDetails(page).getByRole('button', { name: 'Close', exact: true })
if (await close.isVisible().catch(() => false)) {
await close.click()
await settle(page)
}
}
/**
* Open the trip with day `n` selected and its details panel standing. The panel
* IS the selection — closing it deselects the day — and the day's route tools
* and the "+" at the end of a place row render for a selected day only, so a
* guide about either of them starts here rather than at `openTrip`.
*/
export async function openTripOnDay(page: Page, n = 1): Promise<void> {
await openTrip(page, { day: null })
await selectDay(page, n)
}
/**
* Dismiss a `ContextMenu`. It closes on a document click and ignores Escape,
* and a real click would land on the map or on the row underneath.
*/
export async function closeMenu(page: Page): Promise<void> {
await page.evaluate(() => document.body.click())
await settle(page)
}
// ── Import from file ──────────────────────────────────────────────────────────
/**
* Import from file, on the Bookings and the Transports tab alike. The dialog
* is another bare portal, told apart by its heading; the parse it starts is
* reported by the background widget, a fixed portal in the bottom-right
* corner (the one at z-index 50000) with a card per run.
*/
export const importDialog = (page: Page) =>
portalDialog(page, page.getByText('Import booking confirmations', { exact: true }))
export const importWidget = (page: Page) => page.locator('body > div[style*="z-index: 50000"]')
/** The widget's card for the run that parses `file`; the card is labelled with the file names. */
export const importTask = (page: Page, file: string) =>
importWidget(page).locator('.bg-surface-card').filter({ hasText: file }).last()
/**
* The first three steps of an import guide, the same on either tab: the
* toolbar button, the drop box with the file named on it, and Import.
*/
export function importSteps(fixture: () => string, file: string): StepAction[] {
return [
{
// An icon in the toolbar, named like the dialog it opens.
target: p => p.getByRole('button', { name: 'Import booking confirmations', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Import booking confirmations', exact: true }).click()
await expect(importDialog(p)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
// Set the hidden input directly: the box opens the file picker of the
// operating system, which Playwright cannot photograph.
await importDialog(p).locator('input[type="file"]').setInputFiles(fixture())
await expect(importDialog(p).getByText(file)).toBeVisible()
await settle(p)
},
target: p => importDialog(p).getByText(file).locator('xpath=ancestor::button[1]'),
act: settle,
},
{
target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }),
act: async p => {
await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
]
}
/**
* Put the widget away after an import guide. The review takes the card with
* it when it starts, so one still standing means the run stopped before
* that, and it would sit in the corner of every later guide's pictures. Close
* is only offered once the parse has stopped, and the wait is bounded so a
* card nobody can close does not fail the guide from its cleanup.
*/
export async function dismissImportTask(page: Page, file: string): Promise<void> {
const task = importTask(page, file)
if (!(await task.isVisible().catch(() => false))) return
const close = task.getByRole('button', { name: 'Close' })
await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {})
if (await close.isVisible().catch(() => false)) await close.click()
}
/**
* Take a document a guide uploaded out of the trip for good. DELETE alone is
* the trash, and the Files guides photograph the trash, so the permanent
* delete follows.
*/
export async function deleteTripFiles(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/files`)
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
for (const file of files.filter(f => names.includes(f.original_name))) {
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
}
}
-701
View File
@@ -1,701 +0,0 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureTransportFixtures, TRANSIT_JOURNEY, flightEmlFixture, FLIGHT_EML, FLIGHT_TO_IMPORT } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
import { ensureAirtrailConnection, disconnectAirtrail, AIRTRAIL_FLIGHTS, requireExtractor, allowEmlUploads } from './external'
import { tripTransportsContext, tripTransportsGuides } from '../../src/help/contexts/tripTransports'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Transports tab of a trip, keyed by the ids in
* `src/help/contexts/tripTransports.ts`. They run on the seeded "Autumn in
* Japan" with the transports `ensureTransportFixtures` adds up front, so the
* tab has a type chip per group and a planned connection to open; what a guide
* creates it takes away again in `cleanup`, so the next one starts the same.
*/
const guide = (id: string): HelpGuide => {
const g = tripTransportsGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
/**
* The connection the fixtures store on day 6, so no guide has to search for one
* first. Its card is named by its title, the two ends with an arrow between them.
*/
const JOURNEY = { title: TRANSIT_JOURNEY }
/** What plan-transit plans on day 1; the title the panel builds from the two ends. */
const PLANNED = 'Senso-ji Temple → teamLab Planets'
/** What add-transport creates. Both ends are searched, so the map has something to draw. */
const TAXI = {
title: 'Taxi to teamLab Planets',
from: { query: 'Senso-ji Temple Tokyo', match: /^Sens/ },
to: { query: 'teamLab Planets', match: /^teamLab/ },
}
/** A day card's head: number, weather, "Day n", date, its pills and the "+" with the chevron. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The "+" in a day's head; Add transport and Public transit are in the menu it opens. */
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
/** That menu: a popover of plain buttons, told apart from a tooltip by its role. */
const dayMenu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
async function openDayMenu(page: Page, n: number): Promise<void> {
await addToDay(page, n).click()
await expect(dayMenu(page).getByRole('button', { name: 'Add transport' })).toBeVisible()
await beat(page, 300)
}
/** A booking card in the tab: an <article> named by the booking's title. */
const card = (page: Page, title: string) => page.getByRole('article', { name: title }).first()
/** A card found by something it shows rather than its title, such as a booking code. */
const cardShowing = (page: Page, text: string) => page.getByRole('article').filter({ hasText: text }).first()
/** The two popovers of the tab's toolbar, each opened by its own button. */
const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getByRole('button', { name, exact: true })
const toolbarMenu = (page: Page, name: 'Filter' | 'View options') => toolbarButton(page, name).locator('xpath=../..').getByRole('menu')
/** A transport's row in the day plan; the whole row is the button that opens it. */
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
/** The booking a click on a row or a card opens, named by the booking's title. */
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
/** The Edit at the foot of that booking, which leads on to its form. */
const detailEdit = (page: Page, title: string) => bookingDetail(page, title).getByRole('button', { name: 'Edit', exact: true })
/** A block of the open dialog by its heading: every block is a <section> that opens with it. */
const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).first().locator('xpath=ancestor::section[1]')
/** The head band of the open dialog, where a booking's title is typed and its pills sit. */
const headBand = (page: Page) => modal(page).locator('header').first()
/** The Booking Type pill in that band, and the list it opens (a portal named after it). */
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */
const legMenu = (page: Page) =>
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last()
/** The travel-time connector between two stops of the open day. */
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
/** The transit search's stop boxes, and the transport form's location boxes. */
const stopBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search stop or station…').nth(n)
const locationBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search station, port, address…').nth(n)
/**
* Both pickers are an input in its own relative wrapper with the result list as
* the wrapper's second child, so one expression finds either one's suggestions.
*/
const suggestions = (box: Locator) => box.locator('xpath=../..').locator('> div').nth(1)
/** The transit stop picker's list is portaled onto the page, fixed under its input. */
const stopSuggestions = (page: Page) => page.locator('xpath=/html/body/div[contains(@class,"--z-toast")]').last()
/** A result of the transit search: the card's own toggle carries the times as its name. */
const itinerary = (page: Page, n: number) =>
modal(page).getByRole('button', { name: /^\d{1,2}:\d{2} – \d{1,2}:\d{2} / }).nth(n)
async function searchLocation(page: Page, box: Locator, query: string, match: RegExp): Promise<void> {
await box.click()
await typeInto(page, box, query)
const options = suggestions(box).locator('button')
await expect(options.first()).toBeVisible({ timeout: 25_000 })
// The suggestion that answers the query, not whatever came back first: this
// list is a live geocoder and its top hit for a landmark is as often a shop
// down the road, which then travels into the picture as the wrong address.
const wanted = options.filter({ hasText: match }).first()
await expect(wanted, `no suggestion for "${query}" matching ${match}`).toBeVisible({ timeout: 25_000 })
await wanted.click()
await expect(box).toHaveValue(match, { timeout: 15_000 })
}
/** The transit search offers the day's own stops as soon as the box has focus. */
async function pickStop(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
const option = stopSuggestions(page).getByRole('button', { name })
await expect(option).toBeVisible({ timeout: 20_000 })
await option.click()
await expect(box).toHaveValue(name, { timeout: 10_000 })
}
type ReservationRow = { id: number; title: string; confirmation_number: string | null }
async function deleteWhere(page: Page, matches: (r: ReservationRow) => boolean): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
const list = Array.isArray(body) ? body : (body.reservations ?? [])
for (const r of list.filter(matches)) {
await page.request.delete(`/api/trips/${tripId}/reservations/${r.id}`)
}
}
const deleteByTitle = (page: Page, ...titles: string[]) => deleteWhere(page, r => titles.includes(r.title))
/** The imported flight's title is the extractor's; its booking code is the fixture's. */
const deleteByCode = (page: Page, code: string) => deleteWhere(page, r => r.confirmation_number === code)
/**
* What import-transport-file reads out of its e-ticket. The title is the
* mapper's (airline and number joined), so the form and the card are checked
* by what the fixture owns: the code, and the two airports the built-in table
* resolves from their IATA codes.
*/
const IMPORTED = { code: FLIGHT_TO_IMPORT.code, from: /\(HND\)$/, to: /\(FRA\)$/ }
/** The two AirportSelect boxes of a flight's form, departure first. */
const airportBoxes = (page: Page) => modal(page).getByPlaceholder('Airport code or city (e.g. FRA)')
/**
* What the AirTrail account holds (external.ts): two flights home on the last
* day that connect in Tokyo, which the picker offers to join, and the Lisbon
* weekend in spring for its Other flights. The joined booking's title is the
* mapper's route, the airport codes joined by arrows (airtrail.mapper.ts).
*/
const [OUT_LEG, HOME_LEG, , OTHER] = AIRTRAIL_FLIGHTS
const JOINED = { title: `${OUT_LEG.from} → ${OUT_LEG.to} → ${HOME_LEG.to}`, layover: OUT_LEG.to }
/** The toolbar's AirTrail button: an icon named like the picker it opens. */
const airtrailButton = (page: Page) => page.getByRole('button', { name: 'Import from AirTrail', exact: true })
/** The flight picker is a bare portal like the booking import; its title tells it apart. */
const airtrailPicker = (page: Page) => portalDialog(page, page.getByText('Import from AirTrail', { exact: true }))
/** A flight's row in the picker: a button named by the airline and the flight number. */
const flightRow = (page: Page, flightNumber: string) =>
airtrailPicker(page).getByRole('button', { name: new RegExp(`\\b${flightNumber}\\b`) })
/** The join tick under a connection's legs, and the framed group it belongs to. */
const joinToggle = (page: Page) =>
airtrailPicker(page).getByRole('button', { name: /^Import as one flight with a layover in / })
const connectionGroup = (page: Page) => joinToggle(page).locator('xpath=..')
const importCta = (page: Page) => airtrailPicker(page).getByRole('button', { name: /^Import \d+$/ })
/** Give a day's legs back to the day's own mode, whichever stop carries the one that was set. */
async function resetLegModes(page: Page, dayIndex: number): Promise<void> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: { id: number }[] }
for (const a of body.assignments ?? []) {
await page.request.put(`/api/trips/${tripId}/assignments/${a.id}/transport`, { data: { transport_mode: null } })
}
}
/** The day route toggle stays on until it is clicked again; it is only there while a day is open. */
async function switchRouteOff(page: Page): Promise<void> {
const route = page.getByRole('button', { name: 'Route', exact: true }).first()
if (await route.isVisible().catch(() => false)) {
await route.click()
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> = {
'transports-list': {
guide: guide('transports-list'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
only(p => p.getByRole('button', { name: 'Transports', exact: true })),
{
// The menu with Train ticked, so the picture shows a filter in force.
prepare: async p => {
await toolbarButton(p, 'Filter').click()
await expect(toolbarMenu(p, 'Filter')).toBeVisible()
await toolbarMenu(p, 'Filter').getByRole('button', { name: /^Train\b/ }).click()
await expect(card(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
target: p => toolbarMenu(p, 'Filter'),
act: async p => {
await toolbarMenu(p, 'Filter').getByRole('button', { name: 'Reset filters' }).click()
await expect(card(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toBeVisible()
await settle(p)
},
target: p => toolbarMenu(p, 'View options'),
act: async p => {
await toolbarButton(p, 'View options').click()
await expect(toolbarMenu(p, 'View options')).toHaveCount(0)
await settle(p)
},
},
only(p => card(p, FLIGHT.title)),
{
prepare: async p => {
await card(p, FLIGHT.title).getByRole('button', { name: 'Delete' }).click()
await expect(p.getByText('Delete booking?')).toBeVisible()
await beat(p, 300)
},
target: p => portalDialog(p, p.getByText('Delete booking?')),
// Cancel, not Confirm: the flight has to survive for the next guide.
act: async p => {
await portalDialog(p, p.getByText('Delete booking?')).getByRole('button', { name: 'Cancel' }).click()
await expect(p.getByText('Delete booking?')).toHaveCount(0)
await expect(card(p, FLIGHT.title)).toBeVisible()
},
},
],
cleanup: async p => {
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
'add-transport': {
guide: guide('add-transport'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Add transport' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Add transport' }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typePill(p).click()
await expect(typeMenu(p)).toBeVisible()
await beat(p, 300)
},
target: typeMenu,
act: async p => {
await typeMenu(p).getByRole('button', { name: /^Taxi$/ }).first().click()
await expect(typeMenu(p)).toHaveCount(0)
await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), TAXI.title)
await settle(p)
},
target: headBand,
},
{
prepare: async p => {
await searchLocation(p, locationBox(p, 0), TAXI.from.query, TAXI.from.match)
await searchLocation(p, locationBox(p, 1), TAXI.to.query, TAXI.to.match)
await settle(p)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
},
{
prepare: async p => {
// Between the two stops of day 1, which the fixtures give 09:30 and 13:00.
await modal(p).getByPlaceholder('00:00').nth(0).fill('12:00')
await modal(p).getByPlaceholder('00:00').nth(1).fill('12:30')
await settle(p)
},
target: p => modal(p).getByText('Date', { exact: true }).locator('xpath=../..'),
},
{
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(transportRow(p, new RegExp(TAXI.title))).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, TAXI.title),
},
'import-transport-file': {
guide: guide('import-transport-file'),
// See import-booking-file: the extractor and no addon, and the mail has to
// be an allowed file type for the review to keep it under Files.
start: async p => {
await requireExtractor(p.request)
await allowEmlUploads(p.request, true)
await openTrip(p, { tab: 'transports' })
},
steps: [
...importSteps(flightEmlFixture, FLIGHT_EML),
{
prepare: async p => {
await expect(importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
await settle(p)
},
target: p => importTask(p, FLIGHT_EML),
act: async p => {
await importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible({ timeout: 20_000 })
await expect(modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')).toHaveValue(/203/)
// Both airports came out of the built-in table by their codes: the
// airport boxes are the two with an airport code in brackets.
await expect(airportBoxes(p).nth(0)).toHaveValue(IMPORTED.from)
await expect(airportBoxes(p).nth(1)).toHaveValue(IMPORTED.to)
await expect(modal(p).getByPlaceholder('e.g. ABC12345')).toHaveValue(IMPORTED.code)
await expect(modal(p).getByText(FLIGHT_EML)).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await expect(cardShowing(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await dismissImportTask(p, FLIGHT_EML)
await allowEmlUploads(p.request, false)
await p.evaluate(() => {
try { localStorage.removeItem('trek.bg-import-tasks') } catch { /* nothing was stored */ }
})
await deleteByCode(p, IMPORTED.code)
await deleteTripFiles(p, FLIGHT_EML)
},
},
'plan-transit': {
guide: guide('plan-transit'),
start: p => openTrip(p),
steps: [
{
prepare: p => openDayMenu(p, 1),
target: p => dayMenu(p).getByRole('button', { name: 'Public transit' }),
act: async p => {
await dayMenu(p).getByRole('button', { name: 'Public transit' }).click()
await expect(modal(p).getByRole('heading', { name: 'Public transit' })).toBeVisible()
await settle(p)
},
},
{
// The quick picks open on focus, so the day's own stops are in the picture.
prepare: async p => {
await stopBox(p, 0).click()
await expect(stopSuggestions(p).getByRole('button', { name: 'Senso-ji Temple' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
act: async p => {
await pickStop(p, stopBox(p, 0), 'Senso-ji Temple')
await pickStop(p, stopBox(p, 1), 'teamLab Planets')
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeEnabled()
await settle(p)
},
},
{
// Leave when Senso-ji ends. The search opens on 09:00, and a connection
// from there would land in the day before the stop it starts from.
prepare: async p => {
const time = modal(p).getByPlaceholder('00:00')
await time.fill('11:00')
await time.blur()
await expect(time).toHaveValue('11:00')
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Depart' }).locator('xpath=ancestor::div[3]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Fewer transfers' }).click()
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Tram' }).locator('xpath=ancestor::div[2]'),
act: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await settle(p)
},
},
{
prepare: async p => {
await itinerary(p, 0).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => itinerary(p, 0).locator('xpath=..'),
},
{
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await expect(transportRow(p, /Senso-ji Temple.*teamLab Planets/)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, PLANNED),
},
'change-transit-route': {
guide: guide('change-transit-route'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
target: p => card(p, JOURNEY.title),
act: async p => {
await card(p, JOURNEY.title).click()
await expect(bookingDetail(p, JOURNEY.title)).toBeVisible()
await settle(p)
},
},
only(p => bookingDetail(p, JOURNEY.title).getByText('Itinerary', { exact: true }).locator('xpath=ancestor::section[1]')),
{
target: p => bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }),
act: async p => {
await bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }).click()
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await modal(p).getByRole('button', { name: 'Search' }).click()
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
await itinerary(p, 1).click()
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
await beat(p, 300)
},
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Add to day' }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
// The connection now carries another itinerary: put the canned one back.
cleanup: async p => {
await deleteByTitle(p, JOURNEY.title)
await ensureTransportFixtures(p.request)
},
},
'leg-travel-mode': {
guide: guide('leg-travel-mode'),
// The route tools only render for the day that is open, and closing its
// details panel closes the day again, so day 1 stays open here.
start: p => openTripOnDay(p, 1),
steps: [
{
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(connector),
{
prepare: async p => {
await connector(p).click()
await expect(legMenu(p)).toBeVisible()
await beat(p, 300)
},
target: legMenu,
},
{
target: p => legMenu(p).getByRole('button', { name: 'Walking' }),
act: async p => {
await legMenu(p).getByRole('button', { name: 'Walking' }).click()
// The menu closing is the change landing. Not the icon on the first
// leg: the day re-routes when a mode changes, and which leg comes
// first is not fixed while it does.
await expect(legMenu(p)).toHaveCount(0, { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
await resetLegModes(p, 0)
},
},
'edit-transport': {
guide: guide('edit-transport'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row),
act: async p => {
await transportRow(p, FLIGHT.row).click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
},
{
target: p => detailEdit(p, FLIGHT.title),
act: async p => {
await detailEdit(p, FLIGHT.title).click()
await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible()
await settle(p)
},
},
only(p => block(p, 'Route')),
{
// Escape rather than Update: nothing about the seeded flight may change.
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
act: closeModal,
},
],
},
'transport-on-map': {
guide: guide('transport-on-map'),
start: p => openTrip(p),
steps: [
{
target: p => transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }),
act: async p => {
await transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }).click()
await expect(transportRow(p, FLIGHT.row).getByRole('button', { name: 'Hide booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
// The map is on Tokyo, so the Frankfurt end is off screen and the Haneda
// one, drawn second, is the marker the reader can actually see.
only(p => p.locator('.trek-endpoint-marker').last()),
{
prepare: async p => {
await p.locator('.trek-endpoint-marker').last().click()
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, FLIGHT.title),
act: async p => {
await p.keyboard.press('Escape')
await expect(bookingDetail(p, FLIGHT.title)).toHaveCount(0)
await settle(p)
},
},
{
target: p => p.getByRole('button', { name: 'Show all booking routes' }),
act: async p => {
await p.getByRole('button', { name: 'Show all booking routes' }).click()
await expect(p.getByRole('button', { name: 'Hide all booking routes' })).toBeVisible()
await settle(p)
await p.waitForTimeout(1500)
},
},
{
// Day 6 carries the stored connection, and it has the two located stops
// the route tools need, so its Route toggle is there to switch on.
prepare: async p => {
await selectDay(p, 6)
await expect(p.getByRole('button', { name: 'Route', exact: true }).first()).toBeVisible()
await beat(p, 300)
},
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
act: async p => {
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
await settle(p)
await p.waitForTimeout(1500)
},
},
],
cleanup: async p => {
await switchRouteOff(p)
const { tripId } = seededTrip()
await p.evaluate(id => {
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
}, tripId)
},
},
'airtrail-import': {
guide: guide('airtrail-import'),
// The connection is made here and taken away in cleanup, so the hero and
// the guides before this one keep a toolbar without the AirTrail button.
start: async p => {
await ensureAirtrailConnection(p.request)
await openTrip(p, { tab: 'transports' })
// The addon list and the connection probe are read once, when the app
// loads. One more load now that the connection exists, and the button
// is waited for, so a missing connection fails here and not at step 1.
await p.reload()
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
steps: [
{
target: airtrailButton,
act: async p => {
await airtrailButton(p).click()
await expect(airtrailPicker(p)).toBeVisible()
// The rows come from the AirTrail instance itself, over the LAN.
await expect(flightRow(p, HOME_LEG.flightNumber)).toBeVisible({ timeout: 30_000 })
await expect(flightRow(p, OTHER.flightNumber)).toBeVisible()
await settle(p)
},
},
// The whole picker: During this trip with its ticked rows, Other flights below.
only(airtrailPicker),
// A row under Other flights, unticked, as the text says those are. Not
// clicked: a spring flight imported into an autumn trip would be clamped
// onto day 1 and spoil the result picture.
only(p => flightRow(p, OTHER.flightNumber)),
{
// Joined by default: the tick under the two legs is on before anyone touches it.
prepare: async p => {
await expect(joinToggle(p)).toContainText(`Import as one flight with a layover in ${JOINED.layover}`)
await expect(joinToggle(p).locator('svg.lucide-check')).toBeVisible()
},
target: connectionGroup,
},
{
// The two flights home and nothing else are ticked.
prepare: async p => {
await expect(importCta(p)).toHaveText('Import 2')
},
target: importCta,
act: async p => {
await importCta(p).click()
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
// The toast counts flight ids, so the joined pair reports two.
const toast = p.getByText('2 flights imported', { exact: true })
await expect(toast).toBeVisible({ timeout: 15_000 })
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
// The toast fades after three seconds; the card's picture and the
// result are taken without a half-faded message in the corner.
await expect(toast).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
{
prepare: async p => {
await expect(card(p, JOINED.title).getByText('AirTrail', { exact: true })).toBeVisible()
},
target: p => card(p, JOINED.title),
},
],
cleanup: async p => {
// By every title the import can produce: the joined route, or one
// flight number per card when the join was off.
await deleteByTitle(p, JOINED.title, ...AIRTRAIL_FLIGHTS.map(f => f.flightNumber))
await disconnectAirtrail(p.request)
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureTransportFixtures(request)
})
test('every registered transports guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTransportsContext.guides].sort())
})
test('hero: trip-transports', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripTransportsContext.id, p => openTrip(p, { tab: 'transports' }))
})
for (const id of tripTransportsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}
-252
View File
@@ -1,252 +0,0 @@
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, openTripOnDay, modal } from './trip-shared'
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
* the 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
}
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
const inviteBlock = (page: Page) => block(page, /^Invite User/)
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
* column is open and Plan once it is folded, and both names are taken
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
*/
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await page.getByRole('button', { name: 'Share', exact: true }).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', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Share', exact: true }).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\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => block(p, /^Access\s*\d/)),
],
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(leftToggle(p)).toHaveAttribute('aria-label', 'Plan')
await settle(p)
},
},
{
target: leftToggle,
act: async p => {
await leftToggle(p).click()
await expect(p.getByRole('button', { name: 'Export' })).toBeVisible()
await settle(p)
},
},
{
// The divider is invisible until hovered; the hover shows it.
target: 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()}`)
// A change to undo: the spare place onto day 1, from the places column.
// The day has to stay open for that: the + at the end of a row belongs to
// the open day, and closing the day's details panel closes the day with it.
await openTripOnDay(p, 1)
const row = p.getByRole('option', { name: /^Tsukiji Outer Market/ })
await row.hover()
const add = row.getByRole('button', { name: '+ Day' })
await expect(add, 'the + that puts a place on the open day').toBeVisible({ timeout: 15_000 })
await add.click()
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Undo' }),
act: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(p.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])
})
}
-587
View File
@@ -1,587 +0,0 @@
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
import { E2E_BASE_URL } from '../../playwright.config'
import { monthName, year } from '../dates'
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 = year()
/** The month after the picture day's: the first one the planner draws after the current. */
const NEXT_MONTH = monthName(1)
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
const ENTITLEMENT = '28'
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/**
* What clearNotices in `screenshots/shot.ts` clears, less its loop over every
* button whose name contains "next". On this page that loop found the year
* card's "Add next year" and clicked it six times on every visit, so the plan
* grew by six years per guide and the Year card filled up with chips past
* 2100. A tour's own button is called just "Next", so that one is still stepped
* through.
*/
async function clearVacayNotices(page: Page): Promise<void> {
const next = page.getByRole('button', { name: 'Next', exact: true })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
async function openVacay(page: Page): Promise<void> {
await page.goto('/vacay')
await dismissReleaseNotice(page)
await clearVacayNotices(page)
await expect(toolbar(page)).toBeVisible()
await expect(monthCard(page, NEXT_MONTH)).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()
/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */
const settingRow = (page: Page, label: string): Locator =>
settings(page).getByText(label, { exact: true }).locator('xpath=../../..')
/** The switch of a settings row, found through the row's label. */
const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first()
/** The form Add calendar opens: colour, label, country, region and its Add button. */
const addCalendarForm = (page: Page): Locator =>
settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..')
/** The Days tile of the Entitlement card while it is being edited. */
const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..')
/** A year chip in the Year card. */
const yearChip = (page: Page, y: number): Locator =>
sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first()
/** A row of a sidebar card (a person, an invite, a share) by the name in it. */
const cardRow = (page: Page, card: string, name: string): Locator =>
sideCard(page, card).locator('.group').filter({ hasText: name }).first()
/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */
const parkPointer = async (page: Page): Promise<void> => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) }
/**
* Back to the top of the page. A step target further down scrolls the page a
* little, and a result picture taken there hides the top row of months under
* the header, unlike the hero and the other results.
*/
async function toTop(page: Page): Promise<void> {
// On a desktop the body is what scrolls (index.css hides the overflow of
// html), so the window's own scroll position is already 0.
await page.evaluate(() => {
for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' })
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
await settle(page)
}
/**
* Turn a settings switch on, and leave it alone when it is on already. Block
* Weekends and Carry Over start out on, and a plain click switched them off,
* so the next picture showed the opposite of what the step had just said. The
* switch has no aria state; its filled track is the only sign it is on.
*/
async function switchOn(page: Page, label: string): Promise<void> {
const sw = toggleRow(page, label)
if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click()
await settle(page)
}
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()
// The dialog hands the focus back to the Settings button, and after a key
// press that draws a thick focus outline, which in the next picture reads
// like a step ring.
await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await settle(page)
}
/**
* Click Add calendar and fill the form the way the step describes, short of
* adding it, so the picture shows the fields filled in.
*/
async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise<void> {
await addButton.click()
await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label)
await pick(page, settings(page).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(page).getByRole('button', { name: 'Select region (required)' })
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin')
await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
}
async function submitCalendarForm(page: Page): Promise<void> {
await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click()
// The form closes once the calendar is saved and turns into its row.
await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 })
await settle(page)
}
/**
* "Close Settings", the last step of the holiday guides. The ring goes round
* the head band rather than the close button alone: the 960 px dialog is as
* wide as the smallest frame, so a frame centred on the button leaves out the
* band's title and the calendar the guide just added, and the band is still
* what the step names. The pointer rests on the button, so its Close tooltip
* marks where to click inside the ring.
*/
const settingsCloseStep: StepAction = {
target: p => settings(p).locator('header').first(),
hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() },
act: closeSettings,
}
// ── A calendar shared with the admin ──────────────────────────────────────────
/**
* The seeded member whose calendar the share-calendar guide shows as shared
* with the admin. Not jonas: the admin shares with jonas in the same guide,
* and two rows named jonas would hide which one is incoming.
*/
const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' }
/**
* A request context acting as the member. Its own context with a bearer
* token: a login on the page's context would put the member's session cookie
* on the admin's requests.
*/
async function asMember(member: typeof SHARER): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined })
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } })
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined
await anon.dispose()
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`)
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
})
}
/** The member shares her calendar with the admin, as she would from her own Vacay. */
async function shareWithAdmin(page: Page): Promise<void> {
const me = await page.request.get('/api/auth/me')
const adminId = ((await me.json()) as { user: { id: number } }).user.id
const member = await asMember(SHARER)
try {
const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } })
// A retried guide finds the share from its first attempt still there.
if (!res.ok() && !(await res.text()).includes('Already shared')) {
throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`)
}
} finally {
await member.dispose()
}
}
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
async function unshareWithAdmin(page: Page): Promise<void> {
const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] }
for (const s of shares.incoming.filter(x => x.username === SHARER.username)) {
const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`)
if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`)
}
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
notifications: { id: number; title_key: string; sender_username: string | null }[]
}
for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) {
await page.request.delete(`/api/notifications/in-app/${n.id}`)
}
}
/**
* 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, NEXT_MONTH, 14),
act: async p => { await day(p, NEXT_MONTH, 14).click(); await beat(p, 600) },
},
{
target: p => day(p, NEXT_MONTH, 14),
act: async p => {
await day(p, NEXT_MONTH, 14).click()
await beat(p, 600)
// Leave the day logged for the after-picture and the guides that follow.
await day(p, NEXT_MONTH, 14).click()
await toTop(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, NEXT_MONTH, 15),
act: async p => { await day(p, NEXT_MONTH, 15).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) },
},
],
},
'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, NEXT_MONTH, 16),
act: async p => {
await day(p, NEXT_MONTH, 16).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
await toTop(p)
},
},
],
},
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() },
},
{
// The seed's entitlement is 30, so typing 30 changed nothing and the
// result showed the Left of before. A different number is typed first,
// so the picture shows it in the field and the result recalculates Left.
prepare: async p => {
const input = sideCard(p, 'Entitlement').locator('input')
await input.fill('')
await typeInto(p, input, ENTITLEMENT)
},
// The whole tile, so the ring and its number sit round the Days label
// and the field rather than on the label.
target: daysTile,
act: async p => {
await sideCard(p, 'Entitlement').locator('input').press('Enter')
await expect(sideCard(p, 'Entitlement').locator('input')).toHaveCount(0)
await toTop(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 => {
// Over to the new year and back. Staying put would leave the Entitlement
// card on the numbers of the year just added, which Add next year loads
// under the heading of the year still selected; switching reloads them.
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().click()
await settle(p)
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click()
await settle(p)
},
},
{
// The chip with its minus showing, rather than the minus alone: the step
// says to hover the chip, and a ring round the tiny minus put its number
// on top of the year it belongs to.
prepare: async p => { await yearChip(p, YEAR + 1).hover() },
target: p => yearChip(p, YEAR + 1),
// With the ring on the chip, this is what still fails the guide when the minus is gone.
act: async p => { await expect(yearChip(p, YEAR + 1).getByRole('button', { name: 'Remove year' })).toBeVisible() },
},
],
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. The ring
// takes the whole row, name and switch, which is what "check that it is
// on" reads off, and it pulls the frame over the dialog instead of the
// page beside it.
prepare: openSettings,
target: p => settingRow(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()
await toTop(p)
},
},
],
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
steps: [
{
// The whole row, as in company-holidays step 1: name and switch together.
prepare: openSettings,
target: p => settingRow(p, 'Public Holidays'),
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
},
{
// The step is about the country and the region, so the picture shows
// the form filled in rather than the button that opens it empty.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).first(), 'Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
'school-holidays': {
guide: guide('school-holidays'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settingRow(p, 'School Holidays'),
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
},
{
// The last Add calendar: the public-holiday one above it is the first.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).last(), 'Schools Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
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 switchOn(p, 'Block Weekends') },
},
{
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=../../..'),
},
{
// The step names all three choices, so the ring goes round the row that holds them.
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first().locator('xpath=..'),
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 switchOn(p, 'Carry Over'); 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) },
},
{
// Picked before the picture, so it shows mira chosen and Send Invite
// ready, which is the button the step ends on; the dialog closes on send,
// so no later picture shows the choice.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)')
await expect(dialog(p).getByRole('button', { name: 'Send Invite' })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Send Invite' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Send Invite' }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole row, with the pending chip the step is about. The pointer
// stays off it, or the row's Cancel button shows as well.
target: p => cardRow(p, 'Persons', 'mira'),
hover: parkPointer,
},
],
},
'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) },
},
{
// As in the invite guide: jonas picked and Share ready in the picture.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas')
await expect(dialog(p).getByRole('button', { name: 'Share', exact: true })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Share', exact: true }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The step also names calendars shared with you, and the seed has none,
// so mira shares hers with the admin first. The page learns of it over
// the socket; a reload is the fallback should that message not come.
prepare: async p => {
await shareWithAdmin(p)
const incoming = cardRow(p, 'Shared Calendars', SHARER.username)
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
await expect(incoming).toBeVisible()
},
target: p => sideCard(p, 'Shared Calendars'),
// On the row the admin shares, so its Stop sharing button shows.
hover: async p => { await cardRow(p, 'Shared Calendars', 'jonas').hover() },
},
],
cleanup: unshareWithAdmin,
},
}
// ── 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])
})
}
-47
View File
@@ -1,47 +0,0 @@
import type { Page } from '@playwright/test'
/**
* Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly
* seeded user on first load and cover the dashboard — the backdrop swallows
* clicks aimed at anything underneath, `.add-trip-card` included.
*
* The host renders asynchronously (after the notices fetch), so wait for the
* notice dialog before deciding there is nothing to clear. Every lookup is
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
* (carousel arrows) and satisfy the wait before the modal even mounts.
*
* A notice closes one of three ways depending on its shape:
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
* X button (`aria-label="Dismiss"`), shown on the last page.
* - CTA-less notices show an "OK" button that pages forward and dismisses on
* the last page.
* - The release notes (ReleaseNoticeModal), shown again after every update,
* close through their X (`aria-label="Close"`).
* Multi-page notices are paged through via the pager's Next button first.
* Dismissal is persisted server-side per user, so clearing once keeps it
* cleared for every later spec in the run (shared test DB).
*/
export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000): Promise<void> {
const dialog = page.getByRole('dialog').first()
await dialog.waitFor({ state: 'visible', timeout: appearTimeoutMs }).catch(() => {})
// Clear up to a handful of queued notices.
for (let notice = 0; notice < 4 && (await dialog.isVisible().catch(() => false)); notice++) {
const next = dialog.getByRole('button', { name: /next/i })
for (let i = 0; i < 8 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click()
}
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
const close = dialog.getByRole('button', { name: 'Close', exact: true })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else if (await close.isVisible().catch(() => false)) await close.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
}
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
-95
View File
@@ -1,95 +0,0 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
//
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. What
// turned the swipe into a drag was the drag-drop-touch polyfill, confined to hybrid
// laptops since. #1616 then gave tablets their drag back on purpose, through the
// long-press bridge ([data-touch-drag]): a row may be draggable, but only a press that
// is held becomes a drag, so a swipe still scrolls. Only a real device context proves
// that; a jsdom unit test cannot express "coarse pointer at 834px".
//
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
// is WebKit underneath, which is why the reporter saw this in all three they tried.
test.use({ ...devices['iPad Pro 11'] })
test('#1432 iPad: the places list scrolls under a swipe', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
await expect(createBtn).toBeVisible()
const title = `iPad 1432 ${Date.now()}`
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
await createBtn.click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
// Seed enough places for the list to overflow and actually need scrolling.
for (let i = 1; i <= 25; i++) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
})
expect(res.ok(), `seed place ${i}`).toBeTruthy()
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// An iPad is inside the 768-1023px band, where only one side panel is open at a
// time so the map keeps a usable width (#2247). The places list is one tap away.
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
// "Plan" button of its own.
await page.locator('button[aria-label="Places"]').click()
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
// The context must really be the one from the bug report: coarse pointer, desktop
// width. If either is wrong, everything below proves nothing.
const env = await page.evaluate(() => ({
coarse: window.matchMedia('(pointer: coarse)').matches,
width: window.innerWidth,
}))
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
// 1. A swipe must not become a drag. What turned it into one in #1432 was the
// drag-drop-touch polyfill; it belongs to hybrid laptops only now
// (utils/touchDragPolyfill.ts), so a tablet must never load it. A row may be
// draggable, but only behind the long-press bridge (#1616).
const polyfillLoaded = await page.evaluate(() =>
performance.getEntriesByType('resource').some(e => /drag-?drop-?touch/i.test(e.name)))
expect(polyfillLoaded, 'the drag-drop-touch polyfill stays off a tablet').toBe(false)
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toBeVisible()
const swipeSafe = await row.evaluate(el => el.getAttribute('draggable') !== 'true' || !!el.closest('[data-touch-drag]'))
expect(swipeSafe, 'a draggable row sits behind the long-press bridge').toBe(true)
// 2. The list must scroll, and no drop-to-import overlay may appear.
// The list's scroll container carries trek-stagger among its other classes. Taken
// from the row above: the first draggable on the page can be another panel's.
const scroller = row.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " trek-stagger ")]')
const before = await scroller.evaluate(el => el.scrollTop)
const box = (await scroller.boundingBox())!
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
await scroller.evaluate(el => el.scrollBy(0, 200))
const after = await scroller.evaluate(el => el.scrollTop)
expect(after, 'places list scrolled').toBeGreaterThan(before)
await expect(page.getByText('Drop to import')).toHaveCount(0)
// 3. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
// The top tab bar has a button named Plan as well; the way back is the panel's
// own tab, which is accented while its panel is closed.
await expect(page.locator('button[aria-label="Plan"].bg-accent')).toBeVisible()
})
-98
View File
@@ -1,98 +0,0 @@
import { test, expect, devices } from '@playwright/test'
import type { Page } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone coverage for #2051: the day details area steps days under a horizontal
// swipe, so the trip is navigable one-handed instead of only from the chip rail
// pinned to the top of the screen.
//
// jsdom can drive the handlers but not the browser around them: whether the
// gesture survives real touch dispatch, whether the browser hands the vertical
// axis to its own scroller first, and whether a swipe at the first day stays put
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
// events are the ones a finger produces — not synthesised TouchEvent objects.
//
// Chromium, because CDP is where real touch injection lives. The file-level
// device sets a phone viewport with a coarse pointer, which is what puts the
// mobile shell on screen at all.
test.use({ ...devices['Pixel 7'] })
/** One finger, dispatched through the browser's own input pipeline. */
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
for (let i = 1; i <= steps; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{
x: from.x + ((to.x - from.x) * i) / steps,
y: from.y + ((to.y - from.y) * i) / steps,
}],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await cdp.detach()
// The out leg is 150ms and the in leg 220ms.
await page.waitForTimeout(500)
}
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
// and everything below would be proving nothing.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
const stamp = Date.now()
const created = await page.request.post('/api/trips', {
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
})
expect(created.ok(), 'seed trip').toBeTruthy()
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
await page.goto(`/trips/${tripId}`)
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
const chips = page.locator('button[aria-current], button[aria-current="true"]')
const rail = activeChip(page).locator('xpath=..')
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
expect(await chips.count()).toBeGreaterThan(0)
// The swipe zone: below the chip rail, above the dock, across the middle.
const mid = Math.round(env.width / 2)
const y = 460
const day1 = (await activeChip(page).textContent())!.trim()
// 1. A swipe left steps forward.
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
const day2 = (await activeChip(page).textContent())!.trim()
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
// 2. A swipe right steps back to where it started.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 3. The first day has nowhere to go back to — it must stay put, and the
// browser must not have been handed a page-back either.
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
expect(page.url()).toContain(`/trips/${tripId}`)
// 4. A vertical drag belongs to the card's own scroller, never to the day.
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
// 5. And the rail itself still works, so the gesture added a path rather than
// replacing one.
await rail.locator('button').nth(2).click()
await expect(activeChip(page)).not.toHaveText(day1)
})
-61
View File
@@ -1,61 +0,0 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
// at opacity:0 with no way to reveal them.
//
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
// note carried an invisible, fully clickable target that silently reordered the trip.
// These cases pin both halves — hidden means non-interactive, hover means visible.
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
await expect(modal).toBeVisible()
const title = `Reorder ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
const dayId = (daysRes.days ?? daysRes)[0].id
for (const name of ['Alpha', 'Beta']) {
const res = await page.request.post(`/api/trips/${tripId}/places`, {
data: { name, lat: 48.85, lng: 2.35 },
})
const body = await res.json()
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
data: { place_id: body.place?.id ?? body.id },
})
}
await page.reload()
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
await expect(row).toBeVisible({ timeout: 20_000 })
const arrows = row.locator('.reorder-buttons')
// Unhovered: invisible AND inert — a click there must not land on the button.
const idle = await arrows.evaluate(el => {
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
})
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
// Hovered: revealed and clickable.
await row.hover()
await expect(arrows).toHaveCSS('opacity', '1')
await expect(arrows).toHaveCSS('pointer-events', 'auto')
})
-26
View File
@@ -1,26 +0,0 @@
import { test, expect } from './shot'
/**
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
* this project otherwise inherits, so these render as a logged-out visitor sees
* them — which is the entire point of the login and registration pages.
*/
test.use({ storageState: undefined })
test('login page', async ({ page, shot }) => {
await page.goto('/login')
await expect(page.locator('input[type="email"]')).toBeVisible()
await shot.page_('Login')
})
test('registration page', async ({ page, shot }) => {
await page.goto('/register')
await page.waitForTimeout(500)
await shot.page_('Registration')
})
test('forgot password', async ({ page, shot }) => {
await page.goto('/forgot-password')
await page.waitForTimeout(500)
await shot.page_('PasswordReset')
})
-68
View File
@@ -1,68 +0,0 @@
import { test, clearNotices, expect, seed } from './shot'
import { PICTURE_DAY } from '../help/guide'
import type { Page, Locator } from '@playwright/test'
/**
* Collab surfaces, one capture each.
*
* Until now a single Collab.png illustrated four different wiki pages — chat,
* notes, polls and the What's Next widget — so at most one of them showed the
* feature its page described.
*
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
* So each capture targets its own card element rather than clicking a tab.
*/
/**
* The panel card containing a given piece of seeded content: see cardClass in
* CollabPanel.tsx.
*
* Matching on content rather than the panel heading is deliberate: the headings
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
* those same words also appear in the mobile tab bar, so a heading match is both
* wrong-cased and ambiguous.
*/
function card(page: Page, contains: string): Locator {
return page
.locator('div.bg-surface-secondary.rounded-2xl.min-h-0')
.filter({ hasText: contains })
.last()
}
test.beforeEach(async ({ page }) => {
// What's Next lists only what is still ahead, and shots.setup.ts timed its stop
// against the picture day's morning, the instant the help pictures are taken at.
await page.clock.setFixedTime(PICTURE_DAY)
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
await page.waitForTimeout(1200)
})
test('collab chat', async ({ page, shot }) => {
// Seeded as three different people; a single-voice log would misrepresent it.
// The chat auto-scrolls to the newest message, so assert on the last line of
// the seeded conversation rather than the first — the first is off-screen.
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
await shot.element('CollabChat', card(page, 'kaiseki'))
})
test('collab notes', async ({ page, shot }) => {
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
await shot.element('CollabNotes', card(page, 'Rail passes'))
})
test('collab polls', async ({ page, shot }) => {
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
await shot.element('CollabPolls', card(page, 'free for Nara'))
})
test("what's next widget", async ({ page, shot }) => {
await shot.element('WhatsNext', card(page, "What's Next"))
})
test('collab overview', async ({ page, shot }) => {
await shot.page_('Collab')
})
-83
View File
@@ -1,83 +0,0 @@
import { test, clearNotices, expect, seed } from './shot'
/**
* Detail pages and the surfaces that need a couple of clicks to reach.
*
* Each capture asserts something specific to the surface before shooting, so a
* navigation that quietly lands on a fallback (or an addon that is off) fails
* the run instead of producing a screenshot of the wrong screen.
*/
test('collection detail', async ({ page, shot }) => {
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
await page.goto(`/collections/${seed.collectionId}`)
await clearNotices(page)
await shot.page_('CollectionDetail')
})
test('journey detail', async ({ page, shot }) => {
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
await page.goto(`/journey/${seed.journeyId}`)
await clearNotices(page)
await shot.page_('JourneyDetail')
})
test('mcp access — admin', async ({ page, shot }) => {
await page.goto('/admin')
await clearNotices(page)
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('MCPAccess')
})
test('two-factor setup', async ({ page, shot }) => {
await page.goto('/settings')
await clearNotices(page)
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
await page.waitForTimeout(600)
// The enrolment flow is behind a button whose label varies with state; match
// loosely and fall back to capturing the tab itself.
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
if (await enable.isVisible().catch(() => false)) {
await enable.click()
await page.waitForTimeout(900)
}
await shot.page_('2FA')
})
/**
* Settle-up.
*
* WARNING for anyone extending this file: the "Settle up" button in the Costs
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
* of this test clicked it, which zeroed every balance and left the capture
* showing "Everyone's square". Because all screenshot specs share one database
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
* same run.
*
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
* instead — same surface, no side effect — and close it again.
*/
test('costs — record a settle-up payment', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
await page.waitForTimeout(800)
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
await addPayment.click()
await page.waitForTimeout(700)
const modal = page.locator('.trek-modal-backdrop > div').first()
await expect(modal).toBeVisible()
await shot.element('CostsSettleUp', modal)
})
test('trip files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
await clearNotices(page)
await expect(page).toHaveURL(/files/)
await shot.page_('Documents')
})
-37
View File
@@ -1,37 +0,0 @@
import { test, clearNotices, expect, seed } from './shot'
/**
* Modals and dialogs.
*
* Captured as element screenshots (not full page) so the wiki gets the dialog
* itself rather than a dimmed backdrop with a small box in the middle. Each one
* asserts the dialog is actually open first — a missed click would otherwise
* silently produce a screenshot of the page behind it.
*/
/**
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
* role="dialog" nor aria-modal, so there is no accessible role to query — the
* backdrop class is the only stable hook. Target its child, which is the panel
* itself, so the capture excludes the dimmed backdrop.
*/
function dialog(page: import('@playwright/test').Page) {
return page.locator('.trek-modal-backdrop > div').first()
}
test('create trip modal — with the new currency field', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await page.getByRole('button', { name: /new trip/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TripCreate', dialog(page))
})
test('share dialog', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /share/i }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('Share', dialog(page))
})
-73
View File
@@ -1,73 +0,0 @@
import { test, clearNotices, seed } from './shot'
/**
* Top-level navigable surfaces. One capture per route; anything that needs a
* dialog opened or a tab clicked lives in its own spec so a failure there
* cannot take these down with it.
*
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
* for which wiki page consumes which file.
*/
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
await clearNotices(page)
})
test('dashboard', async ({ page, shot }) => {
await page.goto('/dashboard')
await clearNotices(page)
await shot.page_('DashboardWidgets')
})
test('trip planner', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
// The seeded trip is running, so the plan opens on today, which is empty; the
// picture shows the first days, where the plan has places, a note and a flight.
// On the day's number: the middle of the header is the booked night's pill.
await page.locator('.dp-day-header').first().click({ position: { x: 22, y: 20 } })
await page.waitForTimeout(1500)
await page.locator('.dp-day-header').first().evaluate(el => el.closest('.overflow-y-auto')?.scrollTo({ top: 0 }))
await page.waitForTimeout(600)
await shot.page_('TripPlanner')
})
test('atlas', async ({ page, shot }) => {
await page.goto('/atlas')
await shot.page_('Atlas')
})
test('vacay', async ({ page, shot }) => {
await page.goto('/vacay')
await shot.page_('Vacay')
})
test('collections', async ({ page, shot }) => {
await page.goto('/collections')
await shot.page_('Collections')
})
test('journey', async ({ page, shot }) => {
await page.goto('/journey')
await shot.page_('Journey')
})
test('notifications inbox', async ({ page, shot }) => {
await page.goto('/notifications')
await shot.page_('NotificationsInbox')
})
test('in-app help', async ({ page, shot }) => {
await page.goto('/help')
await shot.page_('HelpInApp')
})
test('files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.getByRole('button', { name: /^Files/ }).first().click()
await page.waitForTimeout(1200)
await shot.page_('Files')
})
-234
View File
@@ -1,234 +0,0 @@
import type { Page } from '@playwright/test'
import { test, clearNotices, expect, seed } from './shot'
import { TOKYO_HOTEL } from '../help/fixtures'
/**
* The Plan tab and the dialogs of the trip page, as the wiki pages for the day
* plan, places, bookings, transports and costs show them. Every picture drives
* the real UI by its roles and names, so a renamed control fails here instead
* of quietly photographing the wrong thing.
*/
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
await page.waitForTimeout(800)
})
const dialog = (page: Page) => page.getByRole('dialog').last()
const firstDayCard = (page: Page) => page.locator('.dp-day-header').first().locator('xpath=..')
async function openTab(page: Page, label: string) {
// A tab can carry a longer name for screen readers than its label ("Book" for the bookings).
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
await page.waitForTimeout(800)
}
// On the day's number at the left edge: the middle of the header is the booked
// night's pill once the day has a stay, and a click there opens the hotel instead.
async function selectDay(page: Page, index: number) {
await page.locator('.dp-day-header').nth(index).click({ position: { x: 22, y: 20 } })
await page.waitForTimeout(900)
}
const selectFirstDay = (page: Page) => selectDay(page, 0)
/** Out of the way, so no tooltip of the button just clicked stays in the picture. */
const parkPointer = (page: Page) => page.mouse.move(1, 1)
/** A row of the places column, by the place's name. */
const placeOption = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
test('plan: a day card', async ({ page, shot }) => {
await shot.element('PlanDayCard', firstDayCard(page))
})
test('plan: the day "+" menu', async ({ page, shot }) => {
await selectFirstDay(page)
await firstDayCard(page).locator('button[aria-label="Add to day"]').click()
await expect(page.getByRole('button', { name: 'Add Note' })).toBeVisible()
await shot.page_('PlanDayAddMenu')
})
test('plan: the route bar', async ({ page, shot }) => {
await selectFirstDay(page)
const bar = page.locator('[data-dp="route-tools"]').first()
await expect(bar).toBeVisible()
await shot.element('PlanRouteBar', bar)
})
test('plan: the places column', async ({ page, shot }) => {
const column = page.getByPlaceholder('Search').first().locator('xpath=ancestor::div[contains(@class,"flex-col")][2]')
await shot.element('PlacesColumn', column)
})
test('plan: the place inspector', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1200)
await shot.page_('PlaceInspector')
})
test('plan: the save to list dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
await page.getByRole('button', { name: /^Save to Collection/ }).first().click()
await expect(dialog(page)).toBeVisible()
await expect(dialog(page).getByText('Kyoto shortlist')).toBeVisible()
await page.waitForTimeout(500)
await shot.element('SaveToList', dialog(page))
})
test('plan: the edit place dialog', async ({ page, shot }) => {
await placeOption(page, 'Senso-ji Temple').click()
await page.waitForTimeout(1000)
await page.getByRole('button', { name: 'Edit', exact: true }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(2500)
await shot.element('PlaceForm', dialog(page))
})
test('plan: the day details', async ({ page, shot }) => {
await selectFirstDay(page)
await shot.page_('DayDetails')
})
test('plan: the stay editor', async ({ page, shot }) => {
// Day 9 has no stay yet, so its details offer Add accommodation.
await selectDay(page, 8)
await page.getByRole('button', { name: 'Add accommodation' }).first().click()
await expect(dialog(page)).toBeVisible()
// A hotel picked, so the head band carries its name.
await dialog(page).getByText('Hotel Granvia Kyoto', { exact: true }).click()
await page.waitForTimeout(500)
await shot.element('StayEditor', dialog(page))
})
test('plan: the map hover card', async ({ page, shot }) => {
// With no day open every stop is folded into a cluster; the open day's stops are
// pins. Its details panel stays: closing it would close the day as well.
await selectFirstDay(page)
// A place pin, not a cluster bubble or an explore result (only those carry a
// name). The columns float over the map's edges and pins stack, so the first
// one in the DOM is often one the pointer cannot reach.
const pins = page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
await expect(pins.first()).toBeAttached()
const total = await pins.count()
for (let i = 0; i < total; i++) {
if (await pins.nth(i).hover({ trial: true, timeout: 1500 }).then(() => true, () => false)) {
await pins.nth(i).hover()
break
}
}
const card = page.getByTestId('tooltip')
await expect(card).toBeVisible()
await page.waitForTimeout(600)
// The card and the pin it belongs to with some map around them, not the whole planner.
const box = (await card.boundingBox())!
const width = 760
const height = 440
const x = Math.max(0, Math.min(1440 - width, box.x + box.width / 2 - width / 2))
const y = Math.max(0, Math.min(900 - height, box.y + box.height / 2 - height / 2))
await shot.region('MapHoverCard', { x, y, width, height })
})
test('plan: the export dialog', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('ExportDialog', dialog(page))
})
test('plan: the subscribe to calendar dialog', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await dialog(page).getByRole('button', { name: /^Subscribe to calendar/ }).click()
const subscribe = page.getByRole('dialog').filter({ hasText: 'Subscribe to calendar' }).last()
await subscribe.getByRole('button', { name: 'Enable calendar subscription' }).click()
// Enabled, the dialog shows the ways to subscribe and its Regenerate and Turn off.
await expect(subscribe.getByRole('button', { name: 'Turn off' })).toBeVisible()
await shot.element('IcsSubscribe', subscribe)
await subscribe.getByRole('button', { name: 'Turn off' }).click()
})
test('plan: the PDF preview', async ({ page, shot }) => {
await page.getByRole('button', { name: 'Export' }).first().click()
await dialog(page).getByRole('button', { name: /^PDF/ }).click()
const preview = page.locator('#pdf-preview-overlay > div')
await expect(preview).toBeVisible({ timeout: 30_000 })
await parkPointer(page)
// The document is a srcdoc frame; its photos and the route map load after it.
await page.waitForTimeout(4000)
await shot.element('PDFPreview', preview)
await page.locator('#pdf-close-btn').click()
})
// The Bookings tab holds a booking of every kind here (shots.setup.ts), so the
// views are shown on it; each test puts the view back to the cards.
async function bookingsView(page: Page, view: 'Cards' | 'List' | 'Timeline') {
await openTab(page, 'Book')
await page.getByRole('button', { name: view, exact: true }).first().click()
await parkPointer(page)
await page.waitForTimeout(800)
}
test('bookings: list view', async ({ page, shot }) => {
await bookingsView(page, 'List')
await shot.page_('BookingsList')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('bookings: timeline view', async ({ page, shot }) => {
await bookingsView(page, 'Timeline')
await shot.page_('BookingsTimeline')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('bookings: the detail popup and the editor', async ({ page, shot }) => {
await bookingsView(page, 'Cards')
await page.locator('article').filter({ hasText: TOKYO_HOTEL }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(600)
await shot.element('BookingDetail', dialog(page))
await dialog(page).getByRole('button', { name: 'Edit', exact: true }).click()
await page.waitForTimeout(800)
await shot.element('BookingEditor', dialog(page))
})
test('transports: timeline view', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: 'Timeline', exact: true }).first().click()
await parkPointer(page)
await page.waitForTimeout(800)
await shot.page_('TransportsTimeline')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
})
test('transports: the detail popup', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
await page.locator('article').filter({ hasText: 'LH716' }).first().click()
await expect(dialog(page)).toBeVisible()
await page.waitForTimeout(600)
await shot.element('TransportDetail', dialog(page))
})
test('transports: the add transport dialog', async ({ page, shot }) => {
await openTab(page, 'Transports')
await page.getByRole('button', { name: /^(Add transport|Transport)$/ }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('TransportEditor', dialog(page))
})
test('costs: the add expense dialog', async ({ page, shot }) => {
await openTab(page, 'Costs')
await page.getByRole('button', { name: 'Add expense' }).first().click()
await expect(dialog(page)).toBeVisible()
await shot.element('ExpenseDialog', dialog(page))
})
test('costs: the table view', async ({ page, shot }) => {
await openTab(page, 'Costs')
await page.getByRole('button', { name: 'Table', exact: true }).click()
await expect(page.getByRole('table')).toBeVisible()
await parkPointer(page)
await shot.page_('CostsTable')
// Back to the list for every other picture of the tab.
await page.getByRole('button', { name: 'List', exact: true }).click()
})
-52
View File
@@ -1,52 +0,0 @@
import { test, clearNotices, seed } from './shot'
/**
* Trip-planner tabs and dialogs.
*
* Tabs are reached by their visible label rather than a test id, deliberately:
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
* instead of silently capturing the wrong panel — which is exactly how the
* current wiki ended up with screenshots the text contradicts.
*/
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
await clearNotices(page)
})
async function openTab(page: import('@playwright/test').Page, label: string) {
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
await page.waitForTimeout(700)
}
test('costs panel', async ({ page, shot }) => {
await openTab(page, 'Costs')
await shot.page_('Costs')
})
test('lists — packing', async ({ page, shot }) => {
await openTab(page, 'Lists')
await shot.page_('PackingList')
})
test('lists: to-do with a task open', async ({ page, shot }) => {
await openTab(page, 'Lists')
await page.getByRole('button', { name: /^To-Do/ }).first().click()
await page.waitForTimeout(600)
await page.getByText('Activate JR Pass', { exact: true }).first().click()
await page.waitForTimeout(700)
await shot.page_('Todos')
await page.getByRole('button', { name: /^Packing List/ }).first().click()
})
test('transports', async ({ page, shot }) => {
await openTab(page, 'Transports')
await shot.page_('Transports')
})
test('bookings', async ({ page, shot }) => {
await openTab(page, 'Book')
await shot.page_('Bookings')
})
-59
View File
@@ -1,59 +0,0 @@
// Moves captured screenshots from the staging directory into wiki/assets/,
// downscaling and re-encoding on the way.
//
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
// of raw pixels. The wiki renders images at roughly 800–1000px, so shipping
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
// on HiDPI displays at the size it is actually shown.
//
// Usage: node e2e/screenshots/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
const MAX_WIDTH = 1600
const dry = process.argv.includes('--dry')
mkdirSync(DEST, { recursive: true })
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
if (!files.length) {
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
process.exit(1)
}
let before = 0
let after = 0
for (const file of files.sort()) {
const src = path.join(SRC, file)
const dest = path.join(DEST, file)
const srcBytes = statSync(src).size
before += srcBytes
const img = sharp(src)
const { width } = await img.metadata()
const pipeline = sharp(src)
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
.png({ compressionLevel: 9, effort: 10 })
const buf = await pipeline.toBuffer()
after += buf.length
const pct = Math.round((1 - buf.length / srcBytes) * 100)
console.log(
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)} → ${kb(buf.length).padStart(8)} (-${pct}%)`,
)
if (!dry) await sharp(buf).toFile(dest)
}
console.log(`\n${files.length} files: ${kb(before)} → ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
function kb(bytes) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
}
-37
View File
@@ -1,37 +0,0 @@
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, E2E_SEED_FILE } from '../../playwright.config'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
* runs. Its own Playwright project so it executes exactly once, after `setup`
* (which produces the authenticated storageState) and before `screenshots`.
*
* The resulting ids are written to disk because Playwright projects do not
* share memory — the capture specs read them back.
*/
setup('seed the demo trip', async ({ page, playwright }) => {
// page.request carries the storageState cookie, so this is authenticated as
// the admin. The factory hands the seeder throwaway contexts for the other
// 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: 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
// (server/src/middleware/auth.ts:9), so every "member" write would be
// recorded as the admin while still returning 200.
storageState: undefined,
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
}),
)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
mkdirSync(path.join(process.cwd(), 'e2e', '.tmp'), { recursive: true })
writeFileSync(path.join(process.cwd(), E2E_SEED_FILE), JSON.stringify(result, null, 2))
})
-354
View File
@@ -1,354 +0,0 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
import { at, day, ordinal, short } from '../dates'
/**
* Demo data for the documentation screenshots.
*
* Seeded over the REST API (not the DB) so it exercises the same paths a real
* user would and stays honest about validation. The session cookie comes from
* the storageState that auth.setup.ts writes, so `page.request` is already
* authenticated as the seeded admin.
*
* Design notes that matter for the screenshots:
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
* currency rework (per-trip currency, frozen FX rates, foreign-currency
* settle-up) — the reader would see nothing new.
* - Two extra members exist so splits, avatars and sharing tiers render with
* real names instead of a lonely single-user state.
* - Dates are relative to the run day (e2e/dates.ts): the trip ends today, so
* the running surfaces (boarding pass, What's Next, forecast, reminders) have
* something to show whenever the seed runs.
*/
const TRIP = {
title: 'Autumn in Japan',
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
start_date: day(-9),
end_date: day(0),
currency: 'JPY',
reminder_days: 3,
}
const MEMBERS = [
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
]
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
]
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: day(-6), note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: day(-9), note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: day(-4) },
]
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
]
/** Chat lines the collab guides look for by their text. */
export const SEED_CHAT = {
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
}
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
]
export interface SeedResult {
tripId: number
memberIds: number[]
dayIds: number[]
placeIds: number[]
collectionId?: number
journeyId?: number
}
/** Throws with the response body on failure — a silent 4xx here would produce
* a screenshot of an empty screen, which is worse than a loud crash. */
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
path: string, body?: unknown): Promise<T> {
const res = await api[method](path, body === undefined ? {} : { data: body })
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path} → ${res.status()}\n${await res.text()}`)
}
return (await res.json()) as T
}
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
export async function seedDemoData(
api: APIRequestContext,
newContext?: ContextFactory,
): Promise<SeedResult> {
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
// every later call to those modules 403s until these are flipped.
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
}
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
// when a default does. They match the current defaults (ba3733da made
// celsius/metric/24h consistent across the store and the settings UI) —
// stating them here keeps the captures reproducible either way.
await call(api, 'post', '/api/settings/bulk', {
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
})
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
const memberIds: number[] = []
for (const m of MEMBERS) {
const res = await api.post('/api/admin/users', { data: m })
if (res.ok()) {
const { user } = (await res.json()) as { user: { id: number } }
memberIds.push(user.id)
} else if (res.status() !== 409) {
throw new Error(`create user ${m.username} → ${res.status()}\n${await res.text()}`)
}
}
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
}
// 4. Days are auto-generated by trip creation — read them back for assignment.
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
api, 'get', `/api/trips/${tripId}/days`)
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const placeIds: number[] = []
for (const p of PLACES) {
const { day, ...payload } = p
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
}
}
// 6. A day note, so the itinerary shows more than places.
if (dayIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
time: '08:15', icon: 'train',
}).catch(() => {})
}
// 7. Costs. Split across everyone so the settle-up view has real balances.
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
for (const e of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: 1, amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
if (memberIds[0]) {
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
}).catch(() => {})
}
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => {})
}
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
// are silently dropped by the server, leaving a booking with no route.
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
title: 'LH716 FRA → HND',
type: 'flight',
reservation_time: at(-9, '13:05'),
reservation_end_time: at(-8, '08:25'),
confirmation_number: 'X7K2QP',
status: 'confirmed',
location: 'Frankfurt Airport',
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
departure_airport: 'FRA', arrival_airport: 'HND' },
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
local_date: day(-9), local_time: '13:05' },
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
local_date: day(-8), local_time: '08:25' },
],
}).catch(() => {})
// 10. A collection, populated from the trip's own places.
let collectionId: number | undefined
try {
const created = await call<{ id: number } | { collection: { id: number } }>(
api, 'post', '/api/addons/collections',
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
}
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
// 11. Journey. Entries are generated server-side from the trip, then filled in.
let journeyId: number | undefined
try {
const j = await call<{ id: number } | { journey: { id: number } }>(
api, 'post', '/api/journeys',
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
journeyId = 'id' in j ? j.id : j.journey.id
} catch { /* journey addon unavailable */ }
// 11b. Collab: chat, notes and polls.
//
// Chat is only convincing with more than one voice, and every collab
// write is attributed to the acting user — so messages and votes are
// posted as the members themselves, via their own bearer tokens, not as
// the admin. A single-speaker chat log would misrepresent the feature.
// Each member gets its OWN request context. Logging in through the shared
// one would set the trek_session cookie on it, and extractToken()
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
// Authorization header — so every later write, including the admin's,
// would silently be attributed to whoever logged in last.
const members: Record<string, APIRequestContext> = {}
for (const m of MEMBERS) {
if (!newContext) break
const anon = await newContext()
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
if (!res.ok()) { await anon.dispose(); continue }
const { token } = (await res.json()) as { token?: string }
await anon.dispose()
if (token) members[m.username] = await newContext(token)
}
/** The member's own context, or the admin's as a visible fallback. */
const as = (username: string): APIRequestContext => members[username] ?? api
const collab = `/api/trips/${tripId}/collab`
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
}
const pollRes = await api.post(`${collab}/polls`, {
data: {
question: 'Which day should we keep free for Nara?',
options: [short(-5), short(-4), short(-2)],
multiple: false,
},
})
if (pollRes.ok()) {
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
}
await api.post(`${collab}/polls`, {
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
}).catch(() => {})
const conversation: Array<[string, string]> = [
['admin', `Flights are booked, we land at Haneda 08:25 on the ${ordinal(-8)}.`],
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
['mira', SEED_CHAT.teamlab],
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
['admin', "It's included: kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
]
for (const [who, text] of conversation) {
const ctx = who === 'admin' ? api : as(who)
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
}
for (const ctx of Object.values(members)) await ctx.dispose()
// 12. Plugins, installed from the community registry.
//
// Registry install is the ONLY path that produces a representative
// screenshot. Dev-link and sideload both stamp the plugin card with a
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
// "Link a local plugin" row in the panel. Documenting either would show
// readers a UI they will never have.
//
// Needs network. If the registry is unreachable the plugin screenshots are
// skipped loudly rather than silently captured in a misleading state.
for (const id of ['koffi', 'trip-doctor']) {
const res = await api.post('/api/admin/plugins/install', { data: { id } })
if (!res.ok()) {
console.log(`PLUGIN INSTALL FAILED ${id} → ${res.status()} ${await res.text()}`)
continue
}
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
}
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
}
-105
View File
@@ -1,105 +0,0 @@
import { test, clearNotices } from './shot'
import type { Page } from '@playwright/test'
/**
* Settings and Admin tabs.
*
* Both pages use the shared PageSidebar with client-side tab state (no URL
* segment per tab), so each capture clicks its way in. Labels come from
* shared/src/i18n/en — note "General" is the tab the wiki still calls
* "Display", which is one of the corrections this screenshot run supports.
*/
async function openSidebarTab(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.waitForTimeout(600)
}
test.describe('user settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings')
await clearNotices(page)
})
// Filename kept as UsrSettings.png — the wiki already references it.
test('general tab', async ({ page, shot }) => {
await openSidebarTab(page, 'General')
await shot.page_('UsrSettings')
})
test('appearance tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Appearance')
await shot.page_('UsrSettingsAppearance')
})
test('map tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Map')
await shot.page_('UsrSettingsMap')
})
test('notifications tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Notifications')
await shot.page_('NotifSettings')
})
test('offline tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Offline')
await shot.page_('SettingsOffline')
})
test('account tab', async ({ page, shot }) => {
await openSidebarTab(page, 'Account')
await shot.page_('SettingsAccount')
})
})
test.describe('admin panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin')
await clearNotices(page)
})
test('users', async ({ page, shot }) => {
await openSidebarTab(page, 'Users')
await shot.page_('UsersAndInvites')
})
test('user defaults', async ({ page, shot }) => {
await openSidebarTab(page, 'User Defaults')
await shot.page_('AdminUserDefaults')
})
test('personalization', async ({ page, shot }) => {
await openSidebarTab(page, 'Personalization')
await shot.page_('CategoryManager')
})
test('addons', async ({ page, shot }) => {
await openSidebarTab(page, 'Addons')
await shot.page_('Addons-Overview')
})
test('plugins', async ({ page, shot }) => {
await openSidebarTab(page, 'Plugins')
await shot.page_('AdminPlugins')
})
test('github releases', async ({ page, shot }) => {
await openSidebarTab(page, 'GitHub')
await shot.page_('GithubReleases')
})
test('backup', async ({ page, shot }) => {
await openSidebarTab(page, 'Backup')
await shot.page_('Backup')
})
test('audit log', async ({ page, shot }) => {
await openSidebarTab(page, 'Audit')
await shot.page_('Audit')
})
test('admin panel overview', async ({ page, shot }) => {
await shot.page_('AdminPanel')
})
})
-147
View File
@@ -1,147 +0,0 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { E2E_SEED_FILE } from '../../playwright.config'
/**
* Shared plumbing for the documentation screenshot run (`npm run shots`).
*
* These are not assertions about behaviour — they drive the app to a known
* state and capture it for the wiki. They live behind their own Playwright
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
* never pays for them.
*
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
*/
// Playwright runs from the client workspace root, matching how
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
export const VIEWPORT = { width: 1440, height: 900 }
export const test = base.extend<{ shot: Shot }>({
// Overriding `page` (rather than doing this inside the `shot` fixture) is
// deliberate: fixtures initialise lazily, so a route registered in `shot`
// lands AFTER any beforeEach hook has already navigated — too late to
// intercept the config request.
page: async ({ page }, use) => {
await page.setViewportSize(VIEWPORT)
await hideDevOnlyUi(page)
await use(page)
},
shot: async ({ page }, use) => {
mkdirSync(OUT_DIR, { recursive: true })
await use(new Shot(page))
},
})
/**
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
* "Dev: Notifications" tab that no real deployment ever shows.
*
* Rewriting the response is the surgical fix. Flipping the server to
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
* HSTS header on localhost would upgrade the run to https and break it.
*/
async function hideDevOnlyUi(page: Page): Promise<void> {
await page.route('**/api/auth/app-config', async route => {
const res = await route.fetch()
const body = await res.json()
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
})
}
export { expect }
interface SeedIds { tripId: number; collectionId?: number | null; journeyId?: number | null }
let seedCache: SeedIds | null = null
/**
* The ids the seed project wrote for this run's database. Read on first use, not
* at import: Playwright loads every test file before the seed project has run,
* and the file is named after the API port, so a run on its own ports reads its own.
*/
export const seed: SeedIds = new Proxy({} as SeedIds, {
get: (_t, key: string) => {
seedCache ??= JSON.parse(readFileSync(path.join(process.cwd(), E2E_SEED_FILE), 'utf8')) as SeedIds
return seedCache[key as keyof SeedIds]
},
})
export class Shot {
constructor(private readonly page: Page) {}
/**
* Capture the full viewport. `name` is the target filename in wiki/assets/
* (without extension) so the mapping from screenshot to doc page is literal.
*/
async page_(name: string): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/** Capture a region of the page, for a detail with no element of its own (a card over the map). */
async region(name: string, clip: { x: number; y: number; width: number; height: number }): Promise<void> {
await this.settle()
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`), clip })
}
/** Capture one element — preferred for dialogs, panels and cards. */
async element(name: string, target: Locator): Promise<void> {
await this.settle()
await expect(target).toBeVisible()
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
}
/**
* Quiet the page before capturing: fonts loaded, images decoded, animations
* finished, no pending network. Without this, screenshots catch skeleton
* loaders and half-faded modals, which is exactly how the current wiki
* assets ended up inconsistent.
*/
private async settle(): Promise<void> {
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
// fully idle and an unbounded wait would burn the whole test timeout.
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
await this.page.evaluate(async () => { await document.fonts.ready })
await this.page.evaluate(async () => {
await Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(res => { img.onload = img.onerror = res })),
)
})
// Let CSS transitions land (modal fade-in, sidebar slide).
await this.page.waitForTimeout(400)
}
}
/**
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
* but tolerant: on a seeded DB the notice may already be cleared.
*/
export async function clearNotices(page: Page): Promise<void> {
// The "what's new" card of a release lays an overlay over the whole page that
// takes every click; it may land a moment after the page, so give it that moment.
const releaseClose = page.locator('.rn-close')
if (await releaseClose.waitFor({ state: 'visible', timeout: 2_500 }).then(() => true, () => false)) {
await releaseClose.click().catch(() => {})
await page.locator('.rn-overlay').waitFor({ state: 'detached', timeout: 3_000 }).catch(() => {})
}
const next = page.getByRole('button', { name: /next/i })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
-22
View File
@@ -1,22 +0,0 @@
import { test as setup } from '@playwright/test'
import {
ensureBookingsFixtures, ensureCollabFixtures, ensureDayDetailFixtures, ensureFilesFixtures,
ensureListsFixtures, ensureMapFixtures, ensureTransportFixtures,
} from '../help/fixtures'
/**
* The demo trip as the wiki pictures show it: the seed plus the fixtures the help
* guides add, so the Bookings tab holds a booking of every kind, Files has
* documents, Collab has links and What's Next something ahead, and the days carry
* their stays and transports. Its own project between the seed and the capture:
* the help-media run adds these per guide and has to start from the bare seed.
*/
setup('fill the demo trip for the wiki pictures', async ({ page }) => {
await ensureDayDetailFixtures(page.request)
await ensureBookingsFixtures(page.request)
await ensureTransportFixtures(page.request)
await ensureMapFixtures(page.request)
await ensureFilesFixtures(page.request)
await ensureListsFixtures(page.request)
await ensureCollabFixtures(page.request)
})
+3 -10
View File
@@ -12,10 +12,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
// The port is in the name, so two runs on two port pairs keep two databases
// and can render different guide files at the same time.
const apiPort = process.env.E2E_API_PORT || '3001'
const dbFile = path.join(here, '.tmp', `e2e-${apiPort}.db`)
const dbFile = path.join(here, '.tmp', 'e2e.db')
const serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
@@ -23,18 +20,14 @@ for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
}
// Build once (no watcher) — the resulting process is a single killable node.
// Several runs on their own port pairs share one dist, so they build it once
// beforehand and set E2E_SERVER_PREBUILT: a rebuild under a running server
// swaps its files mid-flight.
if (!process.env.E2E_SERVER_PREBUILT) execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
// playwright.config.ts picks the port; 3001 is the historical default.
PORT: process.env.E2E_API_PORT || '3001',
PORT: '3001',
NODE_ENV: 'development',
}
+2 -8
View File
@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
@@ -7,17 +6,12 @@ import { dismissSystemNotices } from './helpers'
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// The release notice greets a freshly seeded user and its backdrop eats the click below.
await dismissSystemNotices(page)
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.trek-modal-backdrop')
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
// Target Title by placeholder: the cover-image search inputs sit above it, so
// input[type=text].first() is the photo search box, not the field we want.
const title = `E2E Planner ${Date.now()}`
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
-116
View File
@@ -1,116 +0,0 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT.
//
// iOS Safari minimises its address bar only in reaction to the root scroller
// moving. V4 blocked that twice over: html was globally overflow:hidden and the
// mobile shell added a scroll container of its own, so the gesture never
// reached the viewport and the bar kept ~1/6th of the screen forever. The bar
// itself is not observable from page context, but the invariant it hangs off is:
// document.scrollingElement has to move.
//
// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no
// webkit project, the engine comes from this file-level device (defaultBrowserType
// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not
// a nicety: viewport propagation and the toolbar behaviour are Safari's.
//
// The file sorts last in the e2e run, so the trips it seeds cannot show up in
// another spec's dashboard assertions.
test.use({ ...devices['iPhone 14'] })
function readScroll() {
const el = document.scrollingElement as HTMLElement
return {
top: el.scrollTop,
scrollHeight: el.scrollHeight,
viewport: window.innerHeight,
htmlOverflow: getComputedStyle(document.documentElement).overflowY,
}
}
test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// The context has to be the one from the report: phone width, coarse pointer.
// If either is wrong, nothing below proves anything.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true)
// Enough trips for the dashboard to overflow a phone screen.
const stamp = Date.now()
for (let i = 1; i <= 20; i++) {
const res = await page.request.post('/api/trips', {
data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' },
})
expect(res.ok(), `seed trip ${i}`).toBeTruthy()
}
await page.reload()
await dismissSystemNotices(page)
await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 })
// 1. The root scroller is unlocked and the page is longer than the viewport.
const before = await page.evaluate(readScroll)
expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden')
expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport)
expect(before.top).toBe(0)
// 2. Scrolling moves the document, the signal Safari retracts its bar for.
await page.evaluate(() => window.scrollBy(0, 400))
const scrolled = await page.evaluate(readScroll)
expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0)
// 3. The brand tile scrolls the page back up (it used to walk up to the shell
// container, which no longer exists).
await page.getByRole('button', { name: 'TREK' }).click()
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
// 4. The shared body scroll lock: with the document as the scroller, an open
// sheet must freeze the page behind it and give the position back. The
// lock is body overflow: hidden, which reaches the viewport only while
// html stays overflow: visible (index.css), and there it stops the swipe.
// It does not stop a script: window.scrollBy moves an overflow: hidden
// viewport in every engine, so the lock is read where it takes effect.
await page.evaluate(() => window.scrollBy(0, 300))
const locked = await page.evaluate(readScroll)
expect(locked.top).toBeGreaterThan(0)
await page.getByRole('button', { name: 'New Trip' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
const lock = await page.evaluate(() => ({
body: getComputedStyle(document.body).overflowY,
html: getComputedStyle(document.documentElement).overflowY,
top: document.scrollingElement!.scrollTop,
}))
expect(lock.body, 'the open sheet locks body').toBe('hidden')
expect(lock.html, 'html stays visible, so the lock reaches the viewport').toBe('visible')
expect(lock.top, 'opening the sheet does not move the page').toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(await page.evaluate(() => getComputedStyle(document.body).overflowY), 'the lock is released').not.toBe('hidden')
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.evaluate(() => window.scrollBy(0, 200))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
// 5. Counter-probe: the map screens keep their own full-viewport layout. They
// deliberately do NOT scroll the document (and so keep the address bar), and
// the map must not collapse to zero height now that the shell has none.
await page.goto('/atlas')
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const atlas = await page.evaluate(() => {
const map = document.querySelector('.leaflet-container') as HTMLElement
window.scrollBy(0, 400)
return {
top: document.scrollingElement!.scrollTop,
mapHeight: map.getBoundingClientRect().height,
}
})
expect(atlas.top, 'the atlas does not move the document').toBe(0)
expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200)
})
+2 -72
View File
@@ -5,7 +5,6 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import trek from './scripts/lib/eslint-rules.mjs';
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
@@ -47,11 +46,6 @@ export default tseslint.config(
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: reactStub,
trek,
},
languageOptions: {
// Typed linting, for the rules that need to know a value is a promise.
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
'react/no-danger': 'off',
@@ -60,12 +54,8 @@ export default tseslint.config(
// --- Severities tuned to keep CI green on a codebase that was never linted ---
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
// A hook called conditionally is a crash waiting for the render that skips it.
// The rest of react-hooks v7's recommended set (the React Compiler's checks:
// state set in effects or render, refs read in render, impure renders,
// mutated props) as warnings, each count held by scripts/eslint-baseline.json.
...Object.fromEntries(Object.keys(reactHooks.configs['recommended-latest'].rules).map((rule) => [rule, 'warn'])),
'react-hooks/rules-of-hooks': 'error',
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
'react-hooks/rules-of-hooks': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
@@ -83,66 +73,6 @@ export default tseslint.config(
'no-useless-escape': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
// --- Added later as warnings; lint:warnings freezes what exists and fails on each new one ---
// A promise nobody awaits or catches loses its failure, and an async
// handler where a void one is expected (onClick={async () => ...})
// rejects into nowhere.
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': ['warn', { checksVoidReturn: { attributes: true } }],
'trek/no-swallowed-catch': 'warn',
// A `!` silences the null the type admits instead of handling it.
'@typescript-eslint/no-non-null-assertion': 'warn',
// Whole-store subscriptions; the message points to useShallow.
'trek/store-without-selector': 'warn',
// window.__dragData, window.__addToast and window.dispatchEvent buses.
'trek/no-window-globals': 'warn',
'no-console': 'warn',
},
},
{
// Size and branching, in app code only. ESLint's defaults for complexity
// (20) and max-depth (4). Five parameters: a hook with more takes an
// options object. A function body may hold 120 lines in a .ts file, which
// is logic only; a .tsx component also holds its markup, which is long by
// nature, so it gets 300, still well under the god components this is
// meant to stop (LoginPage was one function of 1300 lines).
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
complexity: ['warn', 20],
'max-depth': ['warn', 4],
'max-params': ['warn', 5],
'max-lines-per-function': ['warn', { max: 120, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
files: ['src/**/*.tsx'],
ignores: ['src/**/*.test.tsx'],
rules: {
'max-lines-per-function': ['warn', { max: 300, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
// react-dom/server was worth ~190 KB raw / 57 KB gzip in a chunk three lazy
// routes share — including the Leaflet renderer, which is the default — for
// output that is always a single <svg>. utils/iconMarkup.ts does that job
// without Fizz, and this keeps the import from creeping back: nothing else
// would notice, the build stays green and the app keeps working.
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'react-dom/server',
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
}],
'no-restricted-syntax': ['error', {
selector: "ImportExpression > Literal[value=/^react-dom\\u002F(server|static)/]",
message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one <svg>.',
}],
},
},
);
+9 -4
View File
@@ -8,9 +8,6 @@
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
same reasons, and ahead of the entry, which it has to watch. -->
<script src="/shell-guard.js"></script>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
@@ -22,7 +19,15 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Fonts are bundled from @fontsource in src/main.tsx, see the note there. -->
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
+48 -76
View File
@@ -1,16 +1,12 @@
{
"name": "@trek/client",
"version": "4.3.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"version": "3.1.3",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -20,103 +16,79 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:check": "eslint .",
"lint:warnings": "node scripts/eslint-ratchet.mjs",
"lint:pages": "node scripts/check-page-pattern.mjs",
"lint:rtl": "node scripts/rtl-lint.mjs",
"lint:size": "node scripts/size-lint.mjs",
"lint:format": "node scripts/format-lint.mjs",
"lint:layers": "node scripts/layer-lint.mjs",
"lint:offline": "node scripts/offline-lint.mjs",
"lint:dup": "node scripts/dup-lint.mjs",
"lint:pairs": "node scripts/pairs-lint.mjs",
"lint:skips": "node scripts/skip-lint.mjs",
"lint:strict": "node scripts/strict-lint.mjs",
"lint:i18n-keys": "node scripts/i18n-keys.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"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\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"axios": "^1.20.0",
"dexie": "^4.4.6",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.5.2",
"iso-3166-2": "^1.0.0",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router": "^7.18.4",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.6.5",
"zustand": "^5.0.15"
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@jscpd/core": "^4.2.5",
"@jscpd/tokenizer": "^4.2.6",
"@playwright/test": "^1.63.0",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.9.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
"@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.1.1",
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prettier": "^3.9.8",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
"vitest": "^4.1.9"
}
}
+4 -91
View File
@@ -9,34 +9,6 @@ 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}`
/**
* The session and the seed's ids belong to the database the run boots, which is
* named after its API port (e2e/server-launch.mjs). Two runs on two port pairs
* therefore share nothing, and the default port keeps the old file names.
*/
export const E2E_STATE_FILE = `e2e/.tmp/state-${API_PORT}.json`
export const E2E_SEED_FILE = `e2e/.tmp/seed-${API_PORT}.json`
// The day the seed and the pictures are relative to (e2e/dates.ts). Fixed here
// for every project and worker that loads this file, so a run that crosses
// midnight still agrees with itself; `help:media` sets it before this runs.
if (!process.env.E2E_PICTURE_DAY) {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
process.env.E2E_PICTURE_DAY = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
@@ -47,7 +19,7 @@ export default defineConfig({
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: E2E_BASE_URL,
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -60,74 +32,16 @@ export default defineConfig({
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
// run by its own testMatch — these capture artwork for wiki/assets/, they
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
// Populates the demo trip the screenshots are taken of. Separate project so
// it runs exactly once, between auth and capture.
{
name: 'seed',
testMatch: /seed\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['setup'],
},
// The wiki pictures show the demo trip filled in with the fixtures the help
// guides use (a booking of every kind, documents, links); help-media adds
// them guide by guide and so starts from the bare seed.
{
name: 'shot-fixtures',
testMatch: /shots\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['seed'],
},
{
name: 'screenshots',
testMatch: /\.shot\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: E2E_STATE_FILE,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
dependencies: ['shot-fixtures'],
},
// 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/,
// Four minutes. A guide is a whole task, and a few of them wait on the
// network more than once: the nearby-places one runs two POI searches
// against a public index and opens the place form on top of them, which
// does not fit in the two and a half minutes this used to allow.
timeout: 240_000,
use: {
...devices['Desktop Chrome'],
// The full Chrome for Testing build rather than the headless shell the
// other projects run on. Only the full build carries the PDF viewer,
// and the Files tab's preview embeds a PDF as <object>, which the
// shell leaves as a "Download PDF" link. Still headless.
channel: 'chromium',
storageState: E2E_STATE_FILE,
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: API_PORT,
env: { E2E_API_PORT: String(API_PORT) },
port: 3001,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
@@ -135,8 +49,7 @@ export default defineConfig({
},
{
command: 'npm run dev',
port: WEB_PORT,
env: { TREK_DEV_PORT: String(WEB_PORT), TREK_DEV_API: `http://localhost:${API_PORT}` },
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-tower-control-icon lucide-tower-control">
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
<path d="M8 13v9" />
<path d="M16 22v-9" />
<path d="m9 6 1 7" />
<path d="m15 6-1 7" />
<path d="M12 6V2" />
<path d="M13 2h-2" />
</svg>

Before

Width:  |  Height:  |  Size: 495 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

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