Compare commits

..

328 Commits

Author SHA1 Message Date
Maurice 1bfe57d75c chore(db): regenerate entities after the dev merge 2026-10-07 15:44:54 +02:00
Maurice 7ca7dfd555 Merge dev into fix/prettier
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
2026-10-07 15:43:13 +02:00
Maurice c956fe92ba Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:

- one MikroORM migration for tour_types, tours and tour_waypoints,
  idempotent so instances that ran the old positional steps keep
  their data; the hike tour type is seeded inside it, the addon row
  in the AddonSeeder
- entities and repositories for the three tables, tours domain on
  repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
  repository code

Fixes found while porting:

- GPX import checks addon, trip access and permission in the handler
  instead of guards, so a refused upload gets a 403/404 instead of a
  connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction

Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
2026-10-06 22:43:09 +02:00
jubnl c708b93824 align eslint 2026-10-06 22:06:14 +02:00
jubnl b50812d51d regenerate entitied 2026-10-06 21:59:39 +02:00
jubnl 267e0e16a2 lint + prettier 2026-10-06 21:50:59 +02:00
jubnl cb47947bb7 fix production deps 2026-10-06 19:43:58 +02:00
jubnl de8caa81a2 Feat/orm : mikroorm (#2416)
* base entities

* update deps

* fix date as string

* rename entities

* migrations + seeders

* feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text

* feat(db): discover entities from an explicit list instead of a glob

* test(db): createTestOrm binds an ORM to a suite's own SQLite handle

* feat(db): dialect helper is the one place SQL functions are spelled

* test(db): dialect helper refuses malformed column references and day counts

* feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)

* fix(db): repositories mirror the statement's column set and guard their row types

* feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests

* test(server): integration suites boot on the migrated schema snapshot

* test(server): snapshot harness keeps the legacy id semantics

* fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config

* test(db): coverage ratchet entries for the ORM foundation

* test(server): websocket suites boot on the migrated schema snapshot

* test(server): coverage's uncovered-file pass goes through the SWC transform

* fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer

* test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema

* chore(server): promise safety net and sweep tooling for the async migration

* refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork

* fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test)

* test(doc-sync): unit suites await the methods the async sweep made async

* refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule refusing a Promise used as a plain value, and the misses it found

* test(server): suites await the methods Task 1's forced edits made async

* refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork

* test(oauth): controller suite awaits the async service

* chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await

* refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork

* refactor(plugins): async DB-touching methods; transactions through UnitOfWork

* chore(server): db-call-graph --sync stops its transitive walk at an async callee

* chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub

* refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork

* refactor(auth, files): async DB-touching methods; transactions through UnitOfWork

* test(server): suites await the methods earlier sweep groups made async

* refactor(tokens): async DB-touching methods; the auth token handlers await them

* test(files): upload filters fail closed on an excluded or unresolvable type

* refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork

* refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects

* refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork

* fix(vacay): type the loop accumulators the async sweep introduced

* refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork

* fix(server): the journey hooks are awaited by their callers, not detached

* refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork

* refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias

Recipe R4 trap named in the Task 12 brief: a .bind alias of an
AssignmentsService/AccommodationsService method is typed any and hides a
missing await from tsc and the promise lint rules. createAccommodation
belongs to this task's group, so rewritten as an arrow forwarding to the
method instead of a bound reference.

* refactor(assignments): drop the unused Trip type alias

* refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork

* refactor(roadtrip, reservations): return await on transactional; document the single-flight promises

* refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork

* refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork

* refactor(trips, feeds): drop added return annotations; no double await on the feed guard

* fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports

* fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test

* test(db): entity metadata must match the migrated schema; fix the four drifts it found

* feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)

* test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways

* test(db): numeric ids for the primary-relation spike

* feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules

* fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures

* fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives

* refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity

* fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case

* chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked

* feat(db): AppSettings and Users repositories; forFeature wiring pattern

* refactor(permissions): permission flags through AppSettingsRepository

* fix(server): boot-time settings read runs inside an explicit ORM request context

* refactor(audit): audit log writes through AuditLogRepository

* fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config

* docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes

* fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs

* fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors

* refactor(categories, tags): repositories replace raw SQL

* refactor(addons): addon catalog and photo providers through repositories

* fix(db): generator captures inline UNIQUE constraints

SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_
entry before entity-generator ever sees it, so a table-level
UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX
(settings' UNIQUE(user_id, key) among ~40 others) never reached the
generated entity's uniques: metadata. Adds a general
RULE9_addImplicitUniqueConstraints (skips a column set identical to
the table's own primary key — AppSettings.key/Addons.id noise) fed by
a PRAGMA index_list/index_info scan, and regenerates every entity.

* chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories

* fix(platform): well-known gate tolerates a synchronous addon read from the context helper

* fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes

* refactor(settings): user and instance settings through repositories

SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.

An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).

* test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host

* fix(db): generator names relation properties in captured UNIQUE constraints

* fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions

* fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed

* fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned

* feat(db): generated entities declare their repository type; test casts dropped

* fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed

* feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository

* refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed

* refactor(tokens): MCP and API tokens through McpTokensRepository

* refactor(auth): passkeys and registration invites through repositories

* fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned

* fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned

* refactor(oauth): OAuth server tables through repositories

OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.

* fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned

* fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped

* refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default

* refactor(auth): AuthService and account cleanup through repositories

* refactor(oidc): OIDC identities and settings through repositories

* fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned

* fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing

* refactor(server): trip-access primitives async at every caller

* refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster

* refactor(days): days and day notes through repositories

* refactor(assignments): day assignments through repositories

* refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories

* refactor(trip-members): membership, guests and the trip read model through repositories

* refactor(places): places, tags, ratings and place images through repositories

* fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned

* refactor(places): import paths through repositories

* refactor(trips): trips through repositories

* fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed

* refactor(trips): trip copy through repositories for the owned tables

* fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate

Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.

PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.

DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.

Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.

Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.

* test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB

* fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets

* feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets

* refactor(roadtrip): roadtrip tables through repositories

* refactor(reservations): reservations, endpoints and travelers through repositories

* refactor(feeds): ICS feed tokens and anonymous feeds through repositories

* refactor(accommodations): stays through repositories inside the existing transactions

* test(server): plan 3d — MCP test helper constructors follow the accommodations and days services

* refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables

* refactor(calendar): calendar reads and reservation visibility through one predicate

* feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit

* fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories

* refactor(photos): trek photos, memories access and cache sweeps through repositories

* refactor(files): trip files and file links through repositories, with transactions added

* test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors

* refactor(memories): unified, immich and synology photo sync through repositories

* refactor(collab): notes, polls, messages and links through repositories

* refactor(budget): budget items, members, payers and settlements through repositories

* refactor(todo): todo items through repositories

* refactor(packing): packing items, bags and templates through repositories

* feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins

* refactor(school-holidays): country and region catalog through repositories

* refactor(notifications): in-app notifications and channel preferences through repositories

* refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories

* fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories

* refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories

* refactor(system-notices): notice dismissals through a Nest provider and repository

* refactor(atlas): visited countries, regions and bucket list through repositories

* feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations

* refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories

* feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep

* fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories

* refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories

* refactor(journey): public share links, TREK Studio books and the journal facades through repositories

* refactor(collections): access model, hydration, export/import and list CRUD through repositories

* refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories

* refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories

* refactor(collections): saved places, copy-to-trip, labels and invitations through repositories

* refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories

* refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories

* test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum)

* fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories

* feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations

* feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness

* refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap

* refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories

* feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision

* refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories

* test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4)

* refactor(plugins): the trip-write role gate through a repository, in isolation

* refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService

* fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations

* refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories

* fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository

* refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories

* refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories

* refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories

* test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot

* test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot

* fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories

* refactor(server): plan 4 — convert the program's orphaned raw-SQL callers

* fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)

* fix(server): plan 4 — RULE12 paren-count scanner ignores string literals

The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression
paren-balance scanner counted every `(`/`)` character regardless of
whether it fell inside a single-quoted SQL string literal. A CHECK
expression like `status IN ('confirmed)', 'tentative')` has a literal `)`
inside its first string value; the scanner would hit depth 0 at that
in-string character and truncate the expression, losing its own real
closing paren. The scanner now tracks single-quote string state (handling
the doubled `''` SQLite escape) and skips parens while inside one.

Latent-bug fix — no shipped entity's CHECK expression has hit this shape
yet (3d's ledger: "nothing affected today").

* refactor(server): plan 4 — unify the trip-access predicate's Kysely copies

TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the
identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL)
through the Kysely expression builder. Task 7's security review (M1)
evaluated a shared helper and set it aside for time, falling back to the
TRIPREPO-041 cross-method parity test as a regression guard instead.

Extracted the boolean into one generic helper, tripAccessExpr
(_shared/trip-access.ts), following the reservation-visibility.ts
publicReservationExpr precedent: fully generic over <DB, TB> since the
predicate has no correlated subquery, so it composes across
TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower
fixed-alias shape. The LEFT JOIN itself stays hand-written at each call
site — tripSelectQuery's own docstring already explains why a shared join
helper can't be typed across these differently-joined outer queries.

feeds.service.ts and memories-access.service.ts no longer duplicate this
predicate at all (Task 2's facade-inline sweep already converted both onto
TripsRepository.findAccessible/getFeedTokenIfReachable/
listReachableActiveTrips, which read through the canonical MikroORM
QueryBuilder form, accessibleTripsQuery) — the remaining duplication this
task closes is the three Kysely copies inside this file.

TRIPREPO-041 (64/64 in the file) stays green as the unification's own
regression guard.

* fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL

The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly
make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer
(TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path)
has always bound a real string — 3e's R5 confirmed this. Appends a new
migration rebuilding the table with cache_key NOT NULL (SQLite has no
ALTER COLUMN, so this follows the same rebuild recipe
Migration20200101020000 uses in the opposite direction) and updates the
entity/generated metadata to match. Schema tightening, not a behaviour
change.

* refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23)

Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/
listByTripAndIds and Trips.repository.ts's findPublicForShare all took a
raw `.where('col = ?', [v])` string condition even though their id/trip_id
parameters were already plain `number` (or, for findPublicForShare,
narrowed to `number` here after confirming its one caller,
share.service.ts#getSharedTripData, only ever passes a number sourced
from a Kysely-typed share_tokens row, never an unconverted URL param) —
converted all six to MikroORM's typed `.where({...})` filter object.

The remaining raw string-form conditions in src/db/repositories/** are
NOT converted here: they are the `number | string` raw-bind seam
findAccessible/isOwner's own docstrings describe (trip_id/id threaded
through unconverted from req.params), which structurally requires the
deferred guard-level single toRowId parse to land first — narrowing any
of those methods' signatures now would either break the '0x10'/'007' id
parity those docstrings warn about, or duplicate the deferred item's own
work ahead of it landing.

* refactor(server): plan 4 — trek-photos.repository.ts finishes its rename

3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService,
since the name collided with the generated ORM TrekPhotos.repository.ts) but
left the FILE and its test file's names unchanged. Renames both to
trek-photo-registration.service.ts / .test.ts.

The old path is kept as a thin re-export stub (the src/websocket.ts shape)
rather than repointed at every one of its ~20 importers in this pass: several
of those files sit in two sibling tasks' exclusively-owned windows on this
shared branch (Task 3's budget/packing/todo/accommodations/places ripple,
reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts;
Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts)
and are off limits here. Flagged for a follow-up once those tasks land: delete
the stub and repoint every remaining importer at ./trek-photo-registration.service.

* fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic

install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.

Deliberate behaviour change, named for the user per the task brief.

DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.

* refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags)

Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds
directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and
DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings
in accommodations.service.ts (placesRepo was already injected there).

BudgetService/BudgetMcp/TodoService collapse their constructors, dropping
the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts
register TripMembers on forFeature). PackingService keeps DatabaseService as
a vestigial param behind an optional TripMembersRepository with a fallback
in tripRosterIds: an in-flight Task 5c file
(tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService
positionally without the new repository, so its arity cannot change this
task; the fallback keeps that file's DatabaseService.rosterUserIds spy
working until Task 4 drops the param with the class itself.

* test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline

Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).

* fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too

Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow):
PluginRuntimeService's own discoveryRepos getter still built the bundle
without a uow field. Threads its already-injected, @Optional() uow through
so its own onApplicationBootstrap/reload/dev-link discovery calls also get
the transactional DI5-DI8 refresh, not just PluginRegistryService's.

No throwing guard here, unlike PluginRegistryService's: several hand-built
partial-DI-graph test instances of this class (boot-registry-order.test.ts,
in a sibling task's held file window) construct it with uow omitted and
exercise onApplicationBootstrap's discovery call directly. Discovery must
never refuse outright just because a test built a partial graph, so it
degrades to the pre-existing untransacted sequence when uow is absent
(DiscoveryRepos.uow's own docstring covers this), the same "boot must
never block app init" defensiveness this file already applies elsewhere.

* fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2)

TripAccessGuard/TripOwnerGuard already parse :tripId exactly once
(Number(), guarded by Number.isFinite) and hand the resolved TripAccess
row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing
TripAccessGuard's own lookup when both are on the same route rather than
re-querying — verified by reading both guards directly, no change needed
there.

PlacesService.verifyTripAccess's own docstring separately claimed its
returned `tid` gets "returned... for every downstream call to reuse
(never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading
every call site (not assumed) shows this is false: `.tid` has exactly one
reader (PlacesController.requireTrip itself), which discards it — none of
requireTrip's 13 callers read `.tid` off the trip it returns; each passes
the original raw tripId string on to the write/read method it calls next,
which toRowId-parses it again itself. That second parse cannot be
eliminated by this gate alone: the same service methods are called
directly by places.mcp.ts too, an entry point with no requireTrip gate at
all (its own numeric tripId is re-stringified, String(tripId), to call
them), so each method owns its own id validation regardless of entry
point. Threading `tid` all the way through would mean giving every one of
~15 methods a second, number-only call shape for the REST path alone to
use — a real refactor, out of this docstring fix's own scope. Corrected
the docstring to say so plainly instead of repeating the false claim.

* refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub

JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).

* refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates

3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after
unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as
"same class as OauthClients:147" but left them unexamined per-site. Decided
per site: document, don't delete — TypeScript still types
executeTakeFirst()'s result as `| undefined` even though SQL guarantees
exactly one row for these unqualified aggregates, so the optional chain
can't be removed without a non-null assertion (worse, not safer). Six
sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42,
countStatsPlaces JG67), JourneyEntryPhotos.repository.ts
(maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder
JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction
VC2). No behavior change — comments only.

* refactor(server): plan 4 — unify the two user-data-erasure enqueue paths

UserCleanupService.erasePluginUserData (the account-deletion transaction's
own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the
emitUserDeleted sink's post-commit enqueue) independently walked the
identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data
filter + INSERT OR IGNORE — and both genuinely run for every account
deletion, since every emitUserDeleted call site (auth.service.ts,
admin.service.ts, trip-members.service.ts's guest removal) fires
immediately after deleteUserCompletely.

Extracted the shared filter into enqueueHookUserDataErasures
(nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it.
UC2/UC3 also come off DatabaseService onto PluginsRepository/
PluginUserErasureQueueRepository in the same change (they were the last
raw statements standing in the way of sharing one implementation) — this
narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1
only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which
still stays raw, unaffected. UserCleanupService's orphan-data-directory
scan (a plugin uninstalled with data retained) has no equivalent on the
enqueueUserErasure side and stays there, now via the same repository.

Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this
idempotent) — this unifies the IMPLEMENTATION, not the call count; not
touching the emitUserDeleted event-sink wiring itself was a deliberate,
smaller-blast-radius choice for this task.

* refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number

Both accepted trip_id: number | string per 3c's carry note (their "stays
loose for other callers" reasoning), pending the guard-level single parse
landing first. Re-checked every caller directly: setOwner's one production
caller (trip-members.service.ts#transferOwnership) already passes trip.id,
a real number resolved earlier in the same transaction; remove's callers
are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod-
typed tool input, already a number) and TripMembersController.removeMember
(the one genuine raw-string caller, :id off the route with no class-level
TripAccessGuard on this controller by design).

TripMembersController.removeMember now parses :id once (toRowId, not
Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to
both its own access check and removeMember, instead of passing the raw
string through twice. TripMembersService.removeMember and both repository
methods narrow to number accordingly; setOwner's WHERE also moves off its
raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and
remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter
(user_id keeps its own raw bind — a different, still-live NaN-safety seam,
unrelated to this narrowing).

TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam
on setOwner/remove, no longer typecheck against the narrowed signatures
and are removed with an explanatory comment in their place.

* refactor(server): plan 4 — U6 gate-level id parsing for todo item ids

Rule 21's carry from 3e Task 8's review: TodoController.update/.remove
passed the raw :id route string all the way to TodoItemsRepository,
relying on SQLite's column-affinity CAST to match it instead of a typed
filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's
TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread
the number down through TodoService.updateItem/deleteItem into
TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all
narrowed from number | string to number. todo.rpc.ts's num()-derived
todoId no longer needs its String() wrapper. tripId stays string | number
— a separate, already-accepted carry (23c292f2f). Two new e2e tests pin
the non-numeric-id 404 at the gate.

* test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4)

3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests.
17 cases pinning every read against the legacy statement run raw on the
same connection — findRawByTrip/findTokenByTrip with every nullable
column both null and set, findValidByToken/findTripAndShareMapByToken/
findTripIdByToken's shared token+expiry predicate (valid, future-expiry,
past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/
deleteByCreator's write correctness (deleteByCreator's cross-trip erasure
kept distinct from deleteByTrip's single-trip scope, per the repository's
own docstring). The listPublicForShare family (the other half of 3h L4)
is a separate, larger carry — not in this file.

* test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry)

Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts
reads on ReservationsRepository flagged by the 3d Task 7 review as having
no repository-level toEqual(<legacy raw>) coverage (listEndpointsForShare,
listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare,
listScheduledForPublicApi, listUnscheduledForPublicApi,
listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new
cases against the legacy statement run raw on the same connection.

* test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)

Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.

* test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)

Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.

* test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks

235f03665 landed two versions of the same DaysRepository.listPlanDays and
PlacesRepository.isTrackInTrip parity coverage side by side (a merge
artifact from concurrent work on the same carry item). Keeps one block per
method, drops the duplicate describe/it blocks and their colliding test ids.

* test(server): plan 4 — budget calc suite onto real repositories (3e carry)

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)

Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry)

Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key
has no at-rest pin", "document_connections.secrets ... has no enc:v1: check",
"trip_document_links.webhook_secret - no at-rest pin"). Three targets, each
proven by reading the raw column and asserting the stored value is
enc:v1:-prefixed ciphertext that round-trips, never the plaintext:
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the
repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the
service layer, and DocSyncConfigService's connection secrets map plus
createLink's webhook secret.

* test(server): plan 4 — drop duplicate AirTrail encryption repository test

Task 8b-2 item 4 follow-up: 6965869dd already covers
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest
ciphertext at the service layer (airtrail.service.test.ts's
AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins
(DOCSYNC-ENC-001/002); this repository-level file duplicated the same
AirTrail assertion a second time.

* test(server): plan 4 — atlas read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (atlas half): full-key toEqual(<legacy raw>) coverage for
the atlas read models the 3f Task 7 review's L6 finding listed as missing
(minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places.
listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/
listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/
listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/
listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser),
AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/
countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34
(BucketList.listForUser/findById/findForUser), AT41 (Places.
listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone
files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which
had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids
only by the existing consumer test — full-row parity for those three is
NOT done here (carried forward, same as the review's own open item).

* test(server): plan 4 — vacay read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (vacay half): full-key toEqual(<legacy raw>) coverage for
the vacay read models the 3f Task 7 review's L6 finding listed as missing
(minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the
getPlanData/getStats composite's own plan read), VacayYears.listForPlan
(listYears), VacayCompanyHolidays.listForPlan (the company-holidays list),
VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads),
VacayUserSettings.findForUser (user year settings), VacayUserYears.
findForYear (getStats rows, including the NULL vacation_days case the
review named explicitly), and VacayPlanMembers.listAvailableForFusion/
listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven
new standalone files — only VacayShares had a repository-level test before
this. getSharedCalendars itself is a service-level composite over these
same reads plus VacayCompanyHolidays.listDatesForRange (already exercised
elsewhere); its own dedicated test is NOT added here.

* test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH13 read. Seeds Common items with every nullable
column both null and set, plus Personal and Shared-with-people items on
both sides of the is_private/owner/recipient predicate, and proves only
the Common tier surfaces.

* test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH14 read. Seeds two items — every nullable column
set and every nullable column null — plus a foreign-trip item, and proves
the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has
none).

* test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH16 read. Seeds a root message, a reply (reply_to
set), a deleted message (must be excluded) and a foreign-trip message, and
proves the narrower username/avatar-only join shape (no reply_text/
reply_username, unlike joinedQuery).

* test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH12 read. Seeds a categorised place with every
nullable column set and an uncategorised place with every nullable column
null, plus a foreign-trip place, and proves the named 20-column allow-list
never leaks an owner-only column (reservation_status, google_place_id,
…).

* test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the SH9 read. Seeds assignments across multiple days (one excluded day_id
never leaks in), every nullable place/assignment column both null and
set, and proves the six owner-only columns SharePublicAssignmentRow omits
(google_place_id, google_ftid, osm_id, amap_poi_id, stop_type,
fill_percent) never appear on the row at all.

* test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests

* test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up)

* fix(server): add the missing numbered migration for the booked-night reseat step (#242)

reseatBookedNights (legacy step 242) was never invoked from any shipped
MikroORM migration -- only from the retired db/migrations.ts array --
so an install upgrading through the numbered chain never got the
one-time reorder. Adds Migration20200101040300, which calls the frozen
function through the native better-sqlite3 handle so it runs inside
the migration's own transaction.

orm-driver.ts's BoundSqliteConnection now also captures that handle
onto the field SqliteConnection.getNativeClient() reads, which its
createKyselyDialect() override previously left unset -- needed for
this migration (and any future one) to reach it in production, not
just under the bare test driver.

RESEAT-009 moves off the legacy createTables+runMigrations builder
onto the real Migrator via tests/helpers/migration-step.ts, plus a
new fresh-install guard (RESEAT-011).

* test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder

day-notes.service.test.ts, public-api-scopes.test.ts,
registration-invites.service.test.ts, trip-invite.service.test.ts onto
createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent),
dropping their own hand-rolled createTables()+runMigrations() harness.
trip-invite.service.test.ts and registration-invites.service.test.ts
also drop their now-redundant DatabaseService wrapper (its .connection
was always just the same test db handle). expect( assertions unchanged.

user-cleanup.service.test.ts held back: its constructor call was
updated to match UserCleanupService's new EntityManager-based shape,
but deleteUserCompletely() now deadlocks against a real UnitOfWork
(reproduced in isolation, independent of this test file -- see the
report), which looks like a genuine bug in erasePluginUserData's new
MaintenanceRepository(em) call versus UnitOfWork.transactional(), not
a test-harness problem.

* test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb()

Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in
auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for
their own archiver/filesystem assertions, which broke
createSnapshotTestDb()'s read of the schema snapshot through
schema-snapshot.ts's own fs import.

auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now
built via a small helper that points fsMock's three calls at the real
filesystem (fetched through vi.importActual, bypassing the mock) just
long enough to build the db, before the test installs its own
existsSync stub -- never touching schema-snapshot.ts itself.

demo-reset-path.test.ts: fs.existsSync is selectively spied to answer
only for the baseline path, so the db is now built before that spy is
installed instead of after.

* test(server): plan 4 — retire the legacy-runner-only migration hygiene checks

migration-hygiene.test.ts deleted outright: every block in it (the
destructive-op allowlist, the empty-catch guard, the full-chain smoke)
scans or runs the legacy db/migrations.ts array being deleted this
plan, never the numbered src/db/migrations/*.ts chain, so there is
nothing in it to carry forward.

leg-mode-incoming.test.ts: deleted its first describe block (the
legacy-runner column-add + replay-safety smoke, also against
db/migrations.ts); the read-path parity block, already on the modern
createSnapshotTestDb() harness, is untouched.

* chore(server): delete the legacy schema/migration/seed builders

MikroORM's migrations are the only schema source now, including for
tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are
gone, along with tests/unit/db/schema-parity.test.ts (the parity gate
that guarded them, already red on 8a's cache_key NOT NULL) and
createTestDb() + its now-dead imports out of tests/helpers/test-db.ts
(its last two callers moved onto createSnapshotTestDb() this plan).
Their three entries drop from eslint.config.mjs's better-sqlite3
allow-list -- the files they exempted no longer exist.

Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts,
document-provider-seed.ts) and every numbered migration in
src/db/migrations/ are untouched.

Precondition grep (server/tests + server/src, from the task brief) is
now empty:

  grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts"

* docs: plan 4 — the repository layer is the only data access path

DatabaseService/DatabaseModule are gone; queries go through repositories
(server/src/db/repositories/) and UnitOfWork (server/src/nest/database/
unit-of-work.ts) for transactions. MikroORM's migrations are the only
schema source, including for tests — the hand-kept schema.ts/migrations.ts/
seeds.ts test builders and their parity test are deleted. Documents the
db/database.ts (connection only) / db/orm-driver.ts (bound driver) /
UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints,
the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/
check:entities) as the schema-parity guard. Corrects two stale nest/README.md
mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1
trip-access bullet) and marks the legacy src/services/ migration recipe as a
historical changelog rather than current guidance.

* refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters

Removes the now-dead DatabaseService injection (or private get db() getter)
from addons.service.ts, trips.service.ts, trip-invite.mcp.ts,
place-photo-cache.service.ts, trek-photo-registration.service.ts,
categories.mcp.ts, journey-domain.service.ts, plugins.service.ts,
plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts
and packing.service.ts — every one of them was fully repository-backed
already (Task 2/3's facade inline sweep), just still carrying the param.

PackingService's TripMembersRepository is required now (the
this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's
uow is no longer @Optional() — both moved ahead of the optional
registry?/hostFactory? params, since TypeScript refuses a required
parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow
is required to match, and its upsert() always runs the descriptor
refresh inside one transaction instead of falling back to an
un-transacted sequence.

user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's
"stays raw" plugin-table trio) moves off DatabaseService onto a new
MaintenanceRepository.deletePluginUserData method — the rule-4-permitted
connection.execute() home, built from a directly-injected EntityManager.
Threads the active UnitOfWork.transactional's transaction context through
execute()'s ctx param: without it, a raw connection.execute() call issued
from inside deleteUserCompletely's open transaction contends for the same
single-connection checkout the transaction already holds and deadlocks
(never released, since the transaction is itself awaiting the call).
tests/unit/db/transaction-yield.test.ts's probe B already pinned this
exact hazard class for a raw better-sqlite3 statement; this is the same
mechanism reached through the ORM's connection pool instead.

nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim)
is deleted, with every importer repointed to trek-photo-registration.service
directly. The ~7 files that only imported the TripAccess/PlaceWithTags
type re-export from database.service.ts now import them from
Trips.repository.ts/Places.repository.ts directly, the actual single source.

* refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts

Grep gate confirmed zero src/ importers of DatabaseService left outside
these three files (Task 1/2/3's earlier conversions + this task's own
vestigial-param sweep). Deletes database.service.ts, database.module.ts
and database.tokens.ts, drops DatabaseModule from app.module.ts's imports
and the five other modules that still listed it
(accommodations/calendar/doc-sync/feeds/trip-members) — none of them had
a live DatabaseService injection left, so the import was already dead.
OrmModule is the module that survives DatabaseModule's deletion, per the
Phase 0 ledger's own ruling; untouched.

db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test
files' vi.mock('…/db/database', …) factories return a { db: <mock
handle>, … } shape, and their own `import { db as testDb } from
'…/db/database'` is typed against this file's real exports regardless of
the runtime mock — vi.mock swaps the module at runtime, but tsc resolves
the import statically. Deleting the export would fail typecheck:tests
across the whole suite for a test-harness idiom this task's file set
doesn't own (Track B's territory). src/ has zero remaining importers, so
the ESLint no-restricted-imports gate this task also shrinks still holds
this file to its permanent allow-list entry either way.

* test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule

Mechanical follow-through of the previous two commits across ~110 test
files:

- The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their
  partial Test.createTestingModule graphs; the import and the array entry
  are dropped (nothing in any composed domain module injected
  DatabaseService any more, so the entry was already dead weight).
- The shared test helpers (mcp-test-controllers.ts, plugin-host.ts,
  accommodations-service.ts, notifications.ts, test-addons.ts,
  test-uow.ts) each held a DatabaseService/DatabaseService-shaped param
  used only for its raw better-sqlite3 handle; every one now takes that
  handle directly, and every constructor call site the helpers make
  (PackingService, PlacesService, JourneyDomainService,
  PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService,
  UserCleanupService, AddonsService, PluginsService, PluginRuntimeService)
  drops the removed param and, where PluginRuntimeService's uow is
  involved, reorders it ahead of the optional registry?/hostFactory?
  params to match the real constructor.
- ~60 further unit/integration test files hand-constructed one or more of
  those same services directly; same fix, file by file. Four of them
  (memories-access.service.test.ts, places.directions.test.ts,
  places.service.test.ts, unified-memories.service.test.ts) also drop a
  vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner'
  | 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those
  methods delegated through DatabaseService — dead now that the services
  read the owning repository directly.
- database-service.test.ts and database-service.delegation.test.ts tested
  DatabaseService directly and are deleted with it.
- tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw
  DatabaseService statement's dirty-read behaviour inside an open
  transaction (rule 24), now issues the same raw better-sqlite3
  statement directly against the shared test handle — the exact shape
  DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(),
  bypassing MikroORM/Kysely entirely) — so the hazard it documents is
  unchanged. Verified still green.

No expect(...) assertion changed in any file; only construction/import
plumbing. typecheck, typecheck:tests and the full touched-file run
(101 files / 2668 tests) are green, plus the compiled boot
(bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real
e2e boot file (trips.e2e.test.ts).

tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this
commit — see the next one.

* test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix

This file arrived already modified and unstaged in the shared tree —
Task 6 (the parallel legacy-schema-deletion task) had swapped its DB
harness onto createSnapshotTestDb() and updated the UserCleanupService
construction to this task's new EntityManager-first shape while landing
its own commits. Finished here: USER-CLEANUP-005 ("survives a slim
schema without the plugin tables") passed a fake `{} as unknown as
EntityManager`, which doesn't reach the real UC1 path any more (the
previous raw DatabaseService.run swallowed everything in a try/catch
regardless); it now builds a real EntityManager bound to the slim
handle via sharedTestOrm(slim), so the per-statement query against the
missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state
tables is what's actually being proven caught, not a construction-time
throw.

USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them
exercising UC1 inside the real uow.transactional wrap) are the
regression test for the previous commit's deadlock fix in
user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every
one of these hung and timed out. Green now, verified directly.

* chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list

Per ruling 6: database.service.ts's entry comes off (the file is
deleted). The five plugin entries (plugin-route-normalize.ts,
plugin-host-state.ts, install/discovery.ts, settings-defaults.ts,
signature-status.ts) come off too — re-verified none of the five import
better-sqlite3 or DatabaseService any more (3j's own conversion). The
src/demo/** blanket entry was already gone (3i's own task landed it).

reseat-booked-nights.ts and document-provider-seed.ts stay, with the
`// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7),
invoked from inside numbered, already-shipped MikroORM migrations, never
Plan 4's or any future plan's to touch.

Permanent allow-list after this commit: src/db/database.ts,
src/db/orm-driver.ts, src/db/durability.ts (the driver homes),
src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a
plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts
(3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts
and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts
entries were already removed by Task 6 when it deleted those files.

0 new lint errors; warning count unchanged at 2123 (well under the 2247
lean-gate ceiling) — every file the shrink touches was already fully
converted.

* refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:settlementId/:userId route param in BudgetController is parsed ONCE at
the gate with toRowId, matching the legacy affinity-seam "not found" outcome
for a malformed id on each route (404 'Budget item not found'/'Settlement
not found', except toggleMemberPaid — the one legacy handler that never
404'd an unknown item/member either, so a malformed id there skips the write
and keeps the same 200 { member } + Number(id)/Number(userId) broadcast
shape it always had).

The parsed numbers thread down through BudgetService's update/updateMembers/
setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and
their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/
applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's
getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById
and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById,
all narrowed from number | string to number; trip_id stays loose, a
separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no
longer needs its String() wrapper on the update/delete calls; budget.mcp.ts
was already Zod-typed to number throughout. Two new e2e tests pin the
non-numeric-id 404 at the gate; controller/service unit tests cover the
narrowed call args and the toggleMemberPaid malformed-id parity case.

* refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:bagId/:templateId/:userId route param in PackingController is parsed
ONCE at the gate with toRowId, matching the legacy affinity-seam "not found"
outcome for a malformed id on the routes with a pre-ORM Express precedent
(update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers ->
'Bag not found', applyTemplate -> 'Template not found or empty').

#858's sharing/contributors routes (setSharing/clone/addContributor/
removeContributor) are native Nest code with no pre-ORM precedent (landed in
7eabf6066, after the migration) — a malformed id there just 404s the same
way an unknown one already does. removeContributor's :userId used to reach
contributorsRepo.deleteOne through a bare Number.parseInt(), the exact
NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too.

The parsed numbers thread down through PackingService's updateItem/
setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/
getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed
from number | string to number; tripId stays loose, a separate,
already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/
update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/
update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply
narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer
need their String() wrappers on the update/delete/updateBag/deleteBag/
setBagMembers calls; packing.mcp.ts was already Zod-typed to number
throughout. Two new e2e tests pin the non-numeric-id 404 at the gate;
controller/rpc unit tests cover the narrowed call args and the
removeContributor malformed-userId parity case.

AdminPackingTemplatesController (/api/admin/packing-templates) is out of
scope — a separate admin-gated surface with its own string-typed template
CRUD, not part of this trip-scoped controller's :id chain.

* test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3)

* fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1)

An install the retired positional runner migrated has a schema_version row
and no mikro_orm_migrations rows, so its first boot on this branch replayed
all 244 migrations over an already-migrated schema: eight refuse the boot,
and the replay-safe ones overwrite user data (step 26 rewrites every
day_assignments.assignment_time).

runSchemaBootstrap now records the baseline plus every migration whose
"Legacy migration step N" docstring is <= schema_version as executed, in one
transaction through the migrator's own storage, before the ordinary run
applies the rest. The step map is derived from the discovered migrations'
source, never hand-kept, and anything unmappable (a newer or malformed
schema_version, a duplicated, missing or out-of-order step) refuses the boot.

* test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline

SQL dumps of databases the retired runner itself built: schema_version 205
(main at fa406b708), 242 (417d0445b, the runner's last commit) and 82
(v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead
and run for the first time). Each gets canary rows: user-set assignment
times and a booked night.

buildApp() on each asserts the baseline and the applied rest in order, the
assignment times untouched, the booked-night reseat run exactly once on 205
and 82 and not at all on 242, schema_version kept, no half-built *_new
table, a schema identical to a fresh install's, and an idempotent re-run.

* refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1)

demo-reset.ts was the one src/ consumer of the db Proxy left, reached
through a runtime require that a grep on `from` misses. It now imports
closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts
does, so the Proxy genuinely has no src/ importer (the claim in database.ts
and server/CLAUDE.md now holds). The path test mocks the module with vi.mock
instead of teaching Node's require loader to answer for a .ts file.

* test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3)

ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and
the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so
each id names one test again. No test body changes.

* refactor(server): derive the two restated public row types instead of copying their columns (m4)

JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen
columns with only the parent key changed; it is now that row with
journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely
table shape restated PlaceRow's columns; both now Pick them off PlaceRow.
Types only. The review's CPD method over Plan 4's new src lines drops from
about 3.4% to 0.64%.

* test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5)

Neither read had any test reference. Each is now pinned with
toEqual(<legacy statement run raw on the same connection>), the
listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus
a missing id (TRIPREPO-067/068), and VC90 over one world exercising every
exclusion (caller, guest, existing share, plan owner, accepted member) and
a missing plan.

* fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1)

The legacy runner ran the running release's createTables() on every boot
before its pending steps, and about 30 tables exist only there. Marking the
baseline migration as executed left installs older than those tables (below
legacy step 68) failing with "no such table: photo_provider_fields" and, since
the rows were committed before the run, stuck half-baselined for every later
boot.

Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs
first, as the legacy boot did. The rows are logged inside the same
transaction as migrator.up({ transaction }), so a failed first boot records
nothing and the next boot baselines again.

A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied,
canaries kept. Its two schema differences from a fresh install
(users.immich_access_token, reservations.accommodation_id INTEGER) come
from the legacy chain itself and are pinned as carried.

* fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames

feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.

* ci: give the server lint step an 8 GB heap for the type-aware rules

* test(server): keep the no-promise-as-value rule tester out of single-run mode on CI

With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.

* refactor(server): name the new migrations after the first seven words of their purpose, like the rest

* fix(server): keep tsx a production dependency so image key rotation can load it

* test(server): re-pin the repositories branch floor at the measured 89.83%

* test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom

* test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL

* test(client): keep console output for failing tests only and answer the health probe

* test(client): drop the health handler, it races the admin page's first load

* chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-10-06 19:27:30 +02:00
Maurice 46fcd45eb6 fix(tours): space the tour row actions apart 2026-10-06 17:25:32 +02:00
Maurice 5a249e0ca9 fix(tours): match the delete button to the add-to-day button and use the TREK tooltip 2026-10-06 15:48:00 +02:00
elmocito 9b73ede60c fix(tours): preserve viewport when starting a new tour 2026-10-06 15:22:07 +02:00
elmocito b1289c67b8 test(tours): fix assignment test validation 2026-10-06 15:16:23 +02:00
elmocito a05855ee2b test(tours): align assignment coverage with row action 2026-10-06 15:09:50 +02:00
elmocito 99f3bbfb09 fix(tours): simplify post-save confirmation 2026-10-06 13:44:08 +02:00
elmocito 411a6052fe test(planner): stabilize day auto-scroll tests 2026-10-06 13:41:07 +02:00
elmocito 8739b4c74d fix(tours): restrict permanent deletion to tours workspace 2026-10-06 12:52:25 +02:00
elmocito 04c7491e2a fix(tours): preserve map viewport during route updates 2026-10-06 00:44:43 +02:00
elmocito af7dbce105 fix(tours): prevent duplicate day assignments 2026-10-05 23:53:11 +02:00
elmocito a9f9a23908 test(tours): align locale count after copy cleanup 2026-10-05 20:47:04 +02:00
Maurice 040e6f2405 fix(tours): center the empty tour list in the Tours tab like the planner rail 2026-10-05 12:12:54 +02:00
Maurice c92478c5ad feat(tours): align the Tours UI with the planner redesign
The Tours surfaces now use the same building blocks as the rest of the
planner: tinted head bands, eyebrow labels, white field boxes and pills,
place-style list rows and the inspector frame for the map detail.

- Tour detail on the map uses the place inspector's frame, head band,
  labelled metric fields, collapsible track colour and footer buttons
- Tour rows in the Plan sidebar and the Tours tab look like place rows,
  with the facts as pills and a "+" that opens the trip's days as a menu
- Tours tab rails get the planner head bands, editor fields and
  waypoint rows in the day card style; notices share one look
- Map hint shortened to one line and the empty state reads as a single
  sentence in all locales
- New "tours" mascot scene with a pack, a trekking pole, hills and a
  trail, used by the Tours empty states
2026-10-05 12:04:49 +02:00
elmocito ccaed9f9ba test(tours): align sidebar fixture with planner-owned filter reset 2026-10-05 07:54:44 +02:00
elmocito 2ec5ae7e4d merge: update Tours branch with current Places filters 2026-10-05 01:50:47 +02:00
elmocito 0c2dbdf03b test(tours): update planner wiring and stabilize date fixture 2026-10-05 00:55:58 +02:00
EvKoh b6115d48f3 refactor(places): one name for the uncategorized bucket and the category matcher
PlacesSidebarHeader uses the UNCATEGORIZED constant, placesBrowserModel stops
re-exporting matchesCategoryFilter (its test imports it from utils/placesFilter),
and the orphan comment in the en places strings joins the places.filters key.
2026-10-04 16:20:17 +02:00
EvKoh 4b263c8e4b fix(mobile): align the places browser's filter panel with the map control
The panel button is named "Filters" like the map control, its badge counts
kinds of filter through countActivePlacesFilters (the pool, shown as chips
above it, stays out), and picking a rating floor clears the selection as
picking a pool does.
2026-10-04 16:20:17 +02:00
EvKoh 3d99e569ea test(map): keep the places filter on a road trip's plan map without Dawarich 2026-10-04 16:20:17 +02:00
EvKoh eee361ef28 docs(places): say where the map's places filter departs from the lists
The header claimed the lists and the map always agree. They apply the same
predicate to the same values, but the map narrows "planned" to the open day
(#2024) and leaves collapsed days' stops off.
2026-10-04 16:20:17 +02:00
EvKoh db3c9d660e fix(mobile): announce which Show and rating chip is chosen
MChip only showed its state through colour. A pressable option renders
aria-pressed from active; the places filter's Show and rating floor chips use
it, and the tests now check the chosen chip by its pressed state.
2026-10-04 16:20:17 +02:00
EvKoh 3951e8cc9a fix(map): announce the places filter control as opening a dialog
MapTogglePill always set aria-pressed, so the phone map's Filters control read
as a toggle although it opens a sheet. An opensDialog option drops aria-pressed
for aria-haspopup="dialog"; the other map toggles are unchanged.
2026-10-04 16:20:17 +02:00
EvKoh a9f7adf99b fix(map): move the tracks filter fallback to the always-mounted planner hook
The fallback from "Tracks" to "All" once the last track disappears lived in
the desktop sidebar and the phone places browser, neither of which is mounted
while the phone map is in front: the map could sit empty under a filter no
control offers any more. useTripPlanner now owns it, once, for every screen.
2026-10-04 16:20:16 +02:00
EvKoh b5514024fa docs(wiki): describe the map's places filters
Map-Features gets a "Filtering the places on the map" section: the markers
follow the places list's Show, category and rating filters, and on a phone
the Filters button of the plan map opens the same choices in a sheet.
Places-and-Search says the rating floor narrows the map markers too.
2026-10-04 16:20:16 +02:00
EvKoh fe5a8a8854 fix(map): make the places filter sheet opaque
The sheet opens over the phone map, and with the default bar glass the map's
place names showed through the pool chips, the rating floors and the category
rows, which made them hard to read at 390px wide. It now uses the opaque sheet
material, like the other menus that open over busy content.
2026-10-04 16:20:16 +02:00
EvKoh 66f11602b2 feat(map): filter trip places from the mobile map
On a phone the only way to narrow the trip's pins was to leave the map for
the places list, and the rating floor could not be set at all. The phone map
now has a round "Filters" control at the foot of its right-hand stack, badged
with how many filters are on, opening a sheet with the pool (all / unplanned /
planned / tracks), the minimum rating (the desktop list's floors), the trip's
categories including "no category", and a reset. It is hidden on the road trip
stage, which draws its own pins.

The places browser's filter panel gains the same rating floor; its category
rows move into MPlacesFilterControls so the panel and the sheet share one
copy. Everything writes the trip store, so list and map stay in step.
MapTogglePill takes an optional count badge.
2026-10-04 16:20:16 +02:00
EvKoh a44c966f4d feat(places): share the rating floor between the lists and the map
The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
2026-10-04 16:20:16 +02:00
Maurice b6c3b899d5 test(planner): pin a weekday in the opening hours inspector test so it passes on Sundays 2026-10-04 15:52:50 +02:00
Maurice 5bcb377b9c fix(mobile): trim the note wrap classes to one rule and cover the kept line breaks
overflow-wrap:anywhere alone lets a long URL wrap inside a flex row; break-words
is overridden by it and word-break:break-word is deprecated. MarkdownText already
gets the rule from .collab-note-md, so its call sites drop the duplicate.
2026-10-04 15:46:10 +02:00
Diogo Mendes a9c78f680e fix(mobile): prevent layout breaking from long unbroken text in notes 2026-10-04 15:46:09 +02:00
elmocito 562d8c41d2 merge: update Tours branch to current dev 2026-10-04 13:39:04 +02:00
Maurice a856c68f87 docs(helm): list PLACES_API_KEY and VAPID_PRIVATE_KEY among existingSecret keys 2026-10-04 13:36:34 +02:00
Oscar Carlsson 4f2f937e17 Clarify how the existingSecret really works 2026-10-04 13:36:17 +02:00
Luffy c7793a6017 fix(email): link notification preferences to settings tab
- Make the “Manage preferences” text in notification emails clickable.
- Link directly to `/settings?tab=notifications`.
- Preserve the localized link text and existing email styling.
- Add a regression test for the generated preferences URL.
2026-10-04 13:35:58 +02:00
elmocito 9e475f447a feat(tours): add non-motorized day tour planning 2026-10-04 12:59:55 +02:00
Maurice c93cf2bce9 docs(help): new pictures for every screen that changed, guides for the release's new tasks, texts and translations to match 2026-10-01 22:09:58 +02:00
Maurice 05b53d024e docs(wiki): bring every page up to the next release; admin settings, user defaults and notifications get pages of their own 2026-10-01 19:40:22 +02:00
Maurice cf3818cb66 feat(settings): confirmations in the shared dialog, token colours on About, scope picker in the new look 2026-10-01 16:59:09 +02:00
Maurice 56dfd56bab fix(settings): stacked rows take no height from their basis; grouped navigation without a second heading; white admin page 2026-10-01 16:59:09 +02:00
Maurice 788a58679e feat(settings,admin): every tab and popup in the planner's design 2026-10-01 16:59:09 +02:00
Maurice 4bc7c5cf68 feat(settings): a settings kit in the planner's language; cards, navigation and header 2026-10-01 16:59:09 +02:00
Maurice d7bf7f2f16 fix(background-tasks): no state update after unmount when the features answer arrives late 2026-10-01 16:54:28 +02:00
Maurice 5669f8b860 fix(places): e-mail check without a backtracking regex; explicit grouping in the no-proxy match 2026-10-01 16:10:33 +02:00
Maurice 8f4accc8a5 test(calendar): a test id of its own 2026-10-01 15:33:51 +02:00
Maurice 1e4cc85750 test(calendar): per-leg zones from one endpoint per airport (#2389) 2026-10-01 15:33:38 +02:00
Maurice 62950aed24 test(e2e): hand-made schemas follow the new columns; placing entries never blocks the refresh 2026-10-01 15:18:30 +02:00
Maurice 39074f6d46 fix(places): hours can be typed, and an edited place takes over the looked-up hours; quota as a badge 2026-10-01 15:18:30 +02:00
Maurice 647bb33565 fix(costs): centre the amount/percent switch on its hint 2026-10-01 14:55:42 +02:00
Maurice d694fc68f3 chore: journey type fields for the test typecheck, prettier on the new shared schemas 2026-10-01 14:37:06 +02:00
Maurice b6b4904664 feat(bookings): cruises list their ports of call with arrival and departure (#1807) 2026-10-01 14:31:12 +02:00
Maurice 8d684b48cc fix(journey): the photo-location switch marks its save as unawaited 2026-10-01 14:20:27 +02:00
Maurice db4337287b fix: mark deliberately unawaited promises with void (Sonar S9383) 2026-10-01 14:19:52 +02:00
Maurice f9baf3395e feat(studio): add photos from Immich and Synology like the journey gallery (#2271) 2026-10-01 14:19:51 +02:00
Maurice 81d7022b96 feat(journey): optionally place an entry where its first photo was taken (#1003) 2026-10-01 14:05:47 +02:00
Maurice f875c85474 feat(journey): geotagged photos on the journey's own map, both renderers (#2453) 2026-10-01 13:59:10 +02:00
Maurice f4d81ea42a feat(places): an empty search offers adding the place by hand (#2472) 2026-10-01 13:47:04 +02:00
Maurice d61d8ca9d7 feat(places): phone, e-mail and opening hours by hand when the search has none (#2472, #2356) 2026-10-01 13:42:44 +02:00
Maurice 8c37597bd7 feat(dashboard): search every trip by title, date or a place on it (#2190) 2026-10-01 13:30:44 +02:00
Maurice 99c62878f3 feat(pwa): a relaunch also returns to the plan day it was on (#666) 2026-10-01 13:22:37 +02:00
Maurice d1f4deaa31 feat(pwa): reopen the trip and tab the installed app was in (#1024) 2026-10-01 13:18:47 +02:00
Maurice 1f464ce805 feat(places): sort the places list by name, rating, age or last change (#2093) 2026-10-01 13:15:02 +02:00
Maurice a98449d573 feat(planner): ask before a stray click throws away an edited place or booking (#2253) 2026-10-01 13:11:09 +02:00
Maurice 477f41693d feat(journey): set a journey's status by hand (#762) 2026-10-01 13:03:46 +02:00
Maurice 3063606f56 feat(costs): enter a custom split in percent (#1709) 2026-10-01 12:58:31 +02:00
Maurice 855c0a4b24 feat(settings): optional preferred map app for the navigate button (#2423) 2026-10-01 12:52:58 +02:00
Maurice a416d41cda feat(packing): templates keep weight, quantity and bag; packed and per-person weights (#1131) 2026-10-01 12:47:58 +02:00
Maurice 88afdb986c feat(mcp): upload_trip_file tool for adding documents to a trip (#1566) 2026-10-01 12:39:58 +02:00
Maurice 1c34a74498 feat(calendar): one feed event per leg of a connecting flight or train
A flight or train with several legs comes out of the calendar feed as one event per leg, titled with its route and carrying the leg's own departure, arrival and segment reference, so the layover shows as the gap between them. A booking with a leg missing its departure time stays one event, as before. (#2389)
2026-10-01 12:32:42 +02:00
Maurice f4626c9f3e feat(notifications): admin defaults for users, and channels blocked for everyone
Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
2026-10-01 12:28:36 +02:00
Maurice 7acf7f5655 feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582)
2026-10-01 12:15:38 +02:00
Maurice 5138358d7b feat(net): the SSRF-guarded requests go through HTTP_PROXY / HTTPS_PROXY too
Place photos, link previews, webhooks and the integrations fetch through the guard's own dispatcher and so ignored the outbound proxy, which left a server that can only reach the internet through one without them. They now read HTTP_PROXY, HTTPS_PROXY and NO_PROXY themselves and connect through the proxy, after the guard has vetted the target. The Helm chart passes the three variables through. (#1754)
2026-10-01 12:00:01 +02:00
Maurice 885896aba2 feat(files): FILE_UPLOAD_LIMIT_MB sets the upload limit for files and notes
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
2026-10-01 11:56:14 +02:00
Maurice 750b178d5b test(client): follow the password checklist and the labelled modal close in three suites 2026-10-01 10:43:53 +02:00
Maurice 52934a8326 fix(mobile): the map compass stands above the base-layer switcher
Beside it the two round controls read as one crowded row over the dock; on the switcher's own left edge, one gap above it, they form a column.
2026-10-01 10:38:29 +02:00
Maurice 94447fa011 test(public-api): the other stats suites stub next_trip too 2026-10-01 10:38:29 +02:00
Maurice ba0cc331c1 feat(mobile): today marked on the day rail, and a button back to it
While a trip is running, today's chip on the phone carries a ring and a dot, and a button beside the rail jumps back to today from wherever it was scrolled, in the plan and on the map. It stays in place, dimmed, while today is open, so the rail never shifts under the thumb. (#2392)
2026-10-01 10:17:29 +02:00
Maurice 48f28b40c4 feat(collections): navigate to a saved place from its sheet on the phone
The phone's collection place sheet gets the Navigation button the desktop detail and the trip's place sheet already have, first among its actions: one offered app opens straight away, more open the same picker. (#2091)
2026-10-01 10:17:29 +02:00
Maurice 9f0fe456a4 feat(planner): the panel widths can be dragged with a finger, also on a tablet
The handles on the day plan and the places panel only listened to the mouse, so on an iPad they did nothing, and in the portrait band they were left out. They now follow a finger as well, show a grip on touch screens, keep the page and the map from taking the gesture, and stay in the narrow layout within the room the map needs. As separators they also move with the arrow keys. (#1012)
2026-10-01 10:17:29 +02:00
Maurice 542a57b74e feat(auth): password rules checked while typing, with the server's own policy
The password policy moves to @trek/shared, where the server keeps enforcing it unchanged and every form that sets a password now runs it before sending: the forced change after the first login, registration, the reset link, the account settings on desktop and phone, and creating or editing a user in admin. Under each new password a checklist ticks off length, upper and lower case, a number and a special character, and a refusal names the actual reason, a too common or too repetitive password included. (#1200)
2026-10-01 10:17:28 +02:00
Maurice 44cc596c39 feat(dialogs): the focus starts inside a dialog and Tab stays there
Every dialog, the shared frame and the older modal alike, moves the focus in when it opens: on a desktop into the first text field, on a phone onto the panel so no keyboard pops up. Tab wraps around inside instead of running through the page behind, and closing hands the focus back to what opened it. Fields that open a list on focus are skipped, and the older modal's close button got a label and a tooltip. (#1302)
2026-10-01 10:17:28 +02:00
Maurice c80ee6b12a feat(planner): reach a hotel's booking from the plan, not only its place
A booked stay's pill in the day head gets a second target that opens the booking; the name still opens the place. The place panel of a hotel, on the desktop and on the phone, lists the stay booked for it next to the bookings pinned to the stop. (#2363)
2026-10-01 10:17:28 +02:00
Maurice 104f0eef38 feat(costs): each expense row shows who it is split between, with their part
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
2026-10-01 10:17:28 +02:00
Maurice b4f0a6dea3 feat(pdf): transport notes in the trip PDF, with a switch in the preview
The notes of a flight, train or rental print under its booking code, like the notes of a place. A Transport notes switch next to Page break per day leaves them out and is remembered; it only shows when a transport has a note. (#1571)
2026-10-01 09:30:43 +02:00
Maurice f7782b9ef7 feat(planner): the places search also finds the description and the notes
The search box of the places list matched only name and address. It now also reads what was written about a place, through one helper the desktop list, its filter counts and the phone's browser share. (#2252)
2026-10-01 09:30:42 +02:00
Maurice 81ebc31698 build(docker): OCI labels on the images, so Renovate links an update to its release notes
The image carries org.opencontainers.image.source, url, documentation, licenses, title, description and version; the release and pre-release workflows add the commit and the build time, and the multi-arch index gets source, version and licenses as annotations. (#1498)
2026-10-01 09:26:27 +02:00
Maurice 1fee38e8f7 feat(oidc): OIDC_USERNAME_CLAIM picks the claim a new account's username comes from
Providers like Pocket ID send the full name in 'name', which became 'JaneDoe'. OIDC_USERNAME_CLAIM=preferred_username builds the username from that claim instead. Unset, or with the claim empty, the old order applies (name, preferred_username, the email's local part), and it is only read when an account is created. (#1677)
2026-10-01 09:25:23 +02:00
Maurice ba3dc65926 feat(public-api): next_trip in /api/v1/stats counts down to the next trip
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542)
2026-10-01 09:22:37 +02:00
Maurice e24da67218 test: finish the qol sweep follow-up, one footer on the public journey, patched image deps
The public journey page drew the old "Powered by" block above the shared
frame's footer, so the credit showed twice; only the shared footer is left.
Its tests read the new frame: the title in the hero, the counts in the hero
pills, the loading skeleton and the cover as an image.

The shared trip tests look for markers inside the map, since the unplanned
card names the same places. The place form clicks the search button by name,
now that a picked place adds the nearby button to that row. The journey
editors send the photo order as one PUT, so their tests follow that request,
and a refused order rolls back as a whole. Payments carry their note, and a
saved mobile entry carries its draft flag.

nodemailer goes to 10.0.13 and brace-expansion to 2.1.7 for the advisories
Docker Scout reports (CVE-2026-102276, CVE-2026-102278, GHSA-v53p-9fqp-m79j).
2026-10-01 09:17:18 +02:00
Maurice d6435572b7 fix(journey): two quick swipes in the photo viewer turn pages instead of zooming
A double tap counted every lift of a finger, the end of a swipe included, so
two quick swipes zoomed in. Only a touch that stays in place is a tap now.
The client tests follow the fields and arguments the qol sweep added.
2026-09-30 23:30:51 +02:00
Maurice f5103d04e0 test: follow the qol sweep in the e2e schemas and the assertions it changed
The hand-written e2e schemas gain the new columns and place_regions, and the
unit assertions take the new arguments and fields. The Public API page names
GET /api/auth/app-config for the running version.
2026-09-30 23:28:14 +02:00
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00
Maurice 91dfab3f4b fix(planner): hand the day plan's unawaited calls to void
Sonar counts a promise that is neither awaited nor caught as a reliability bug, and it reads every such call in a touched file as new. Each of these already reports its own failure (toast, rollback or the sheet's catch), so they are marked as fire and forget rather than changed.
2026-09-29 21:55:03 +02:00
Maurice fd5afb0fad test(map): opening another trip with the overview on frames that trip
The overview holds its camera frame across content edits and only publishes a
fresh one on activation or on a trip switch. Activation is covered; the trip
switch branch was not, so a regression there would leave the map framed on
the previous trip. The new case keeps days and stops identical and changes
only the trip id, so the switch alone has to produce the new frame.
2026-09-29 21:48:27 +02:00
pipipiper 71a8b24f8d fix(map): keep the overview frame when the trip is edited 2026-09-29 21:48:26 +02:00
Luffy 7d22857de8 fix(mobile): restore day selection when reopening a trip 2026-09-29 21:48:26 +02:00
dependabot[bot] 22d5d67023 chore(deps): bump undici from 7.29.0 to 7.30.0
Bumps [undici](https://github.com/nodejs/undici) from 7.29.0 to 7.30.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.29.0...v7.30.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.30.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-29 21:48:22 +02:00
dependabot[bot] b8f2a86987 chore(deps): bump ip-address from 10.5.0 to 10.7.2
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.5.0 to 10.7.2.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.7.2)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-29 21:48:22 +02:00
Maurice 91be785fbc fix(planner): a leg's time zone sits behind a globe instead of beside the route
A departure or arrival row wrote its zone out ("Europe/Berlin") next to the
phase label, and on the narrow day plan that ate the width the route needed,
so "BER → HAM" ended in an ellipsis. The zone is now a small globe after the
time that names it on hover, the time pill carries only the time, and the
route gets its room back.
2026-09-29 21:16:26 +02:00
Maurice 5f700615b9 fix(mobile): files attached while editing a booking are uploaded on save
The phone booking and transport sheets collect picked files and upload them
after the save, but only when the record was new. Editing an existing booking
showed the picked files in the sheet, then dropped them on Update without a
word. The upload now runs after every save that comes back with a record,
through one helper both sheets share. A save that fails uploads nothing.

Closes #2534
2026-09-29 21:16:26 +02:00
Maurice 86243d42af fix(transit): keep the day's hotel in the from/to picks of the transit search
The quick picks of the public transit search listed the day's places first
and the trip's stays after them, then cut the list at eight. On a day with
eight or more located stops the hotel was always cut, so it could not be
picked as the start or the end of a connection.

The stay the day wakes up in and the one it ends in now come first and are
never cut. The day's places and the trip's other stays follow, capped at
eight as before.

Closes #2538
2026-09-29 21:16:26 +02:00
Maurice bd5a6cc748 fix(cors): the instance's own address no longer trips ALLOWED_ORIGINS
With ALLOWED_ORIGINS set, every request carrying an Origin that was not on
the list was failed with an unhandled error, which the exception filter
answered with a 500. A browser sends Origin on every POST and WebSocket
upgrade, including same-origin ones, so opening TREK under any address the
list did not name (the LAN IP, a second hostname, http instead of https)
broke the login with "Internal Server Error" while another browser that
used the listed address worked.

A request whose Origin is the host it was sent to is now treated as
same-origin and always passes, for REST and the /ws upgrade alike. A
foreign origin is still refused, now with a 403 "Not allowed by CORS" and a
warning in the log naming the origin to add.

Closes #2543
2026-09-29 21:16:26 +02:00
Maurice 7b0333a0c1 fix(roadtrip): a booked night no longer shows up mid-day when the day already ends at the stay
Booking a night writes the hotel onto its check-in day as a stop, seated first
or at its check-in, so the drive reaches it even without hotel bookends. With
"Start and end each day at your stay" on, the evening row already ends that day
at the same hotel, so the road trip drove check-out, tonight's hotel, the day's
places and back to the hotel again. Days hides that stop and draws the stay at
the day's edges only.

seatNightBookends now passes over the stop tonight's booking wrote whenever it
seats that stay as the day's evening, in the browser and in calculate_roadtrip
alike. The stops keep their stored index, a hotel the traveller placed stays,
and the booking's stop stays while it is the day's last one or no evening is
seated. With the switch off nothing changes.

Closes #2546
2026-09-29 21:16:26 +02:00
Maurice 5c7aaab3c6 fix(share): the shared map opens framed on the whole trip, not on a corner of it
The opening camera of the public share map was computed for the browser
window, while the map lives in a card a fraction of that size. Leaflet then
rounded the fractional zoom up, and the first fit skipped its correction
because it believed the map had already opened framed. Only picking a day and
going back to the whole trip ran a real fit.

The card is now measured before the map mounts and the opening frame is
computed for it, on a whole zoom level, with the same padding and zoom cap the
later fits use. A card without layout falls back to the first fit.

Closes #2549
2026-09-29 21:16:26 +02:00
Maurice ec06035855 fix(collab): a long link title wraps instead of being cut off
The link chip held its title on one line with an ellipsis, and nothing in
the UI showed the rest: the tooltip names the action, not the link, and a
phone or the PWA has no hover at all. The title now wraps and the chip grows
in height, the host drops to its own line under a wrapped title, and the
chip keeps rounded corners instead of pill ends once it is taller.

Closes #2550
2026-09-29 21:16:26 +02:00
Maurice 64087512df fix(planner): the plan's costs follow the expenses in Costs
The day pills, Total Cost at the foot of the plan and the same figures in
the PDF added up the price column of every planned place. No form sets
that price since expenses replaced it, and nothing in Costs changes it,
so deleting an expense left the plan's total where it was, and a place
planned on two days counted twice.

With the Costs addon on, the plan now adds up the expenses: each one once,
on the day its booking starts, else the first day its place is planned,
else its date, and in the total either way. A foreign expense counts at
the rate frozen when it was entered and a refund nets against its day,
so Total Cost matches Total trip spend in Costs. With Costs off, a
place's own price still counts, once per place.

Closes #2551
2026-09-29 21:16:25 +02:00
Demisu f83201f976 fix(helm): quote Secret stringData values 2026-09-29 21:16:25 +02:00
Maurice 26910ac6fb fix(packing): hand the list's unawaited calls to void
Sonar counts a promise that is neither awaited nor caught as a reliability bug. Every one of these already reports its own failure (toast or optimistic rollback), so they are marked as fire and forget rather than changed.
2026-09-29 21:04:00 +02:00
Maurice 272df55cd4 feat(packing): sort the packing list A to Z
A switch at the end of the list's tab row shows every list's items in name
order, so checking whether something is already on the list no longer means
reading all of it. The order follows the reader's language (accents beside
their letter, "Shirt 2" before "Shirt 10") and the empty list stand-in stays
at the bottom.

Sorting only changes what is shown: the manual order stays stored and comes
back with a second click. While sorted the drag grips step aside, the box
keeps its column, and the choice is remembered per browser.
2026-09-29 20:25:25 +02:00
Maurice 6f61b66c5c fix(share): the day line on the shared map asks for a picked day as a boolean
`selectedDay && ...` would render a 0 if a day id were ever 0; the
check is explicit now.
2026-09-28 21:55:38 +02:00
Maurice fe93df7711 docs(help): new pictures for Collections, Vacay, Atlas and Journey
Every guide of the four screens is recorded again with the new dialogs.
The guide scripts follow the new markup, and a review of the pictures
fixed what they taught wrong: the Kyoto list holds Kyoto places with
their photos, the invite and share steps show the picked user, the shared
calendar step shows one shared with you, the Dawarich and wishlist steps
frame the whole dialog, and the create journey guide links a trip with
places.
2026-09-28 21:22:47 +02:00
Maurice 2c486e6832 fix(journey): the Studio export dialog in the new look, and the Atlas country card draws its flag
The export dialog was the last popup on the old card: it is now the shared
dialog frame with its options as rows, and it takes Escape for itself so
the key no longer reaches Studio behind it. Its stylesheet goes.

The Atlas country card printed the flag emoji, which Windows draws as the
two letters of the country code; it uses the shared CountryFlag now.
2026-09-28 21:22:46 +02:00
Maurice acd003ea67 feat(dialogs): Vacay, Collections, Atlas and Journey in the new dialog look
Every popup of the four pages moves to the shared dialog frame the trip
uses: head band with its icon, the scrolling body, the footer with its
actions, and ConfirmDialog for the questions.

- Vacay: settings, removing a year (now says so when it fails), the
  fusion request, inviting a person, a person's colour and sharing the
  calendar.
- Collections: new and edited lists, adding a place, importing a file and
  a trip's places, sharing, labels and assigning them, copying to a trip,
  moving or duplicating, saving a trip's places and deleting a list. The
  stylesheet of the old import view goes.
- Atlas: the country popup with the flag in its head, its choices as rows
  and the wishlist step; Dawarich's Atlas dialog and its accept dialog on
  the desktop.
- Journey: creating a journey, its settings, linking a trip, inviting a
  contributor, the entry editor (discarding asks in a dialog) and the
  photo provider picker; the entry card's menu is the shared one.

The phone keeps its own sheets: where a dialog is shared with it
(Dawarich, the contributor invite, the provider picker) only the desktop
frame changes.
2026-09-28 16:52:32 +02:00
Maurice 9064ee57b7 test(share): the map test's page hook knows the folded days and the map's day pick
The redesigned share page reads collapsedDays, toggleDay and pickDayOnMap
from its hook; the map test's stand-in left them out and every case there
failed on the missing set.
2026-09-28 16:52:32 +02:00
Maurice bbf8d94f9f feat(share): the public trip page in the planner's design
The page a share link opens is rebuilt from top bar to footer: the
planner's light top bar with the language picker, the trip as a hero
made of its cover, a tab bar that stays in reach, the days as the
planner's cards beside a map that stays in view, bookings as the
booking cards, packing and costs as cards with their progress and
shares, the chat as bubbles, and empty states for sections the owner
shared with nothing in them yet.

The map picks its day in a select with a step back and forth instead
of a row of chips a three-week trip did not fit into.
2026-09-28 14:40:34 +02:00
Maurice 21c068eb43 fix(trips): the Share dialog sits in the middle of the window like the other dialogs 2026-09-28 13:39:58 +02:00
Maurice 3901aecbc0 feat(trips): Share and Save to list in the new dialog look
Share opens on the shared dialog frame: the trip's name and the number of
people in the head band, members and guests on the left, the public link
and the invite link as two cards on the right. Rows carry their actions as
icon buttons named by their tooltips, and removing a member, leaving,
handing over the trip or removing a guest asks in the app's own dialog
instead of the browser's. The invite link can no longer be created twice by
a double click. The phone, which opens the same dialog from its More sheet,
keeps the sheet it had; both run on one set of hooks.

Save to list has the place's name in the head band, Mark visited as a pill
beside it, the lists as rows with a tick, and View at its foot.

The public link's hint loses its dash in every language, and the help
guides and wiki pages of the Share dialog show the new look.
2026-09-28 13:19:04 +02:00
Maurice af9710c618 docs(wiki): the pictures that were missing, and the follow-ups of the rewrite
The wiki pictures of the trip are taken again with the fixtures the help
guides use, so the Bookings tab shows a booking of every kind, Files has
documents and What's Next has something ahead. New pictures: the map's
hover card, the Subscribe to calendar dialog, the PDF preview, the To-Do
view, and the Transports tab's own timeline and booking detail. Day
selection in the screenshot run clicks the day's number, now that the
middle of a day header is the booked night.

Home, Real-Time Collaboration, Road Trip, Map Features and the bookings
page are brought in line with the rewritten pages, and sixteen assets no
page uses any more (mostly old GIFs, 22 MB) are removed.
2026-09-28 12:19:49 +02:00
Maurice 146fe4a063 docs(help): the trip screens' guides for the new layout, with new pictures
The guides of the map, bookings, costs, files, collab, lists, road trip and
transports now describe the desktop as it is after the redesign: the bars
with their icon buttons and filter popovers, the dialogs with the title in
the head band and status and type as pills, the three booking views and
the booking detail, the compact note cards, the Costs table. Two guides
are new: the booking views (cards, list, timeline, the Trip and Day zoom
and the detail) and the Costs table view. Every changed text is translated
into every locale, and the Plan tab texts call the bookings tab by the
label it shows, Book.

Every picture of the thirteen trip screens is taken again against the new
layout, on one picture day. Scripts follow the new markup; the Dawarich
fixture uploads its recording again when the stored one belongs to another
run, and the spare poll gets its vote from a member so its winner shows
green.

server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so
several recordings on their own port pairs can share one dist.
2026-09-28 12:19:30 +02:00
Maurice c88f9a4a23 fix(i18n): labels the translators flagged, and two dashes in the UI
vi called the Tracks filter "songs", sv the pending status "commuting", nl
the open-now pill "opening hours", and tr showed "its plan" on the Plan tab
and "being suspended" for pending. The road trip strings added in August
had lost their accents in eight languages (Arrive and Leave, the stay
buttons, the whole day and halfway filters, the avoidance options), uk had
a Latin i in "halfway", hu and tr called No tolls "free of charge", and az
put an en dash between the two ends. sv and vi said "I owe" for I'm owed and
You're owed, sv had "Namnn" as the table's Name, and th said "split
equally" for Split matches total.

The climate hint of the day details and the Unfinished tooltip in Costs
lose their dash in every language.
2026-09-28 12:18:47 +02:00
Maurice 350a144457 fix(ui): full-height document previews, and tooltips only on keyboard focus
The PDF preview on the Files tab and the document preview of a Collab note
were about 150 px tall: the object carried flex-1, and in a column without a
fixed height that falls back to the object's own height instead of 74vh.

A tooltip no longer opens when a closing dialog hands the focus back to the
button that opened it. It floated over whatever came next, at the place the
button had before the page moved. Keyboard focus still opens it.

The table view sorts its dates with localeCompare, and two NaN fallbacks and
an unused import go.
2026-09-28 12:17:15 +02:00
Maurice 33b440706e fix(weather): spell the German descriptions with their umlauts
Bewölkt, Teilweise bewölkt, Überwiegend klar and Schneekörner were written without their umlauts and reached the day details and the planner that way.
2026-09-27 23:26:42 +02:00
Maurice 950029ccc4 fix(weather): read a past day of the day details from the archive
The detailed weather asked the forecast API for every day up to 16 days out,
past days included. Once a day is a couple of months back that API answers
nulls instead of an error, and the day details showed 0° while the planner,
which already reads the archive, showed the real figure.

A past day now comes from the archive, with the same daily figures, sunrise,
sunset and hourly series as the climate path, which shares the new helper. A
recent day the archive has no figures for yet goes on to the forecast API as
before, and nulls from either source now answer no_forecast instead of 0°.
2026-09-27 23:21:28 +02:00
Maurice cb7014b378 feat(costs): table view, and the wiki and help center for the new trip page
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.

On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.

The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
2026-09-27 23:15:19 +02:00
Maurice 9f5e332293 test(reservations): pick the confirm button without Array.at, which the ES2020 lib lacks 2026-09-27 21:06:02 +02:00
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00
Maurice acd0d6511e feat(reservations): desktop bookings and transports in the phone's look
The Bookings and Transports tabs on the desktop take the phone's card
language: a tinted status band, the type chip and framed fields. One bar
holds the search, a filter popover for status, types and travelers, the
view switch and the view options.

Three views over the same filtered entries: cards (four per row at most),
a dense list grouped by day, and a timeline with a trip scale and a day
scale by the hour. A booking opens as a dialog with everything it holds,
the booking code blurred by the existing setting, and "On map".

Work in progress: only en and de are translated, and tests, help guides
and the wiki still follow.
2026-09-27 00:34:33 +02:00
Maurice 237f42a493 fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.

The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
2026-09-26 15:59:05 +02:00
RemEx b1c05bca83 feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
2026-09-26 14:38:01 +02:00
Maurice 706c6c3959 Merge pull request #2531 from liketrek/fix/v4.3.3-dev
fix: bring the v4.3.3 fixes to dev
2026-09-26 14:29:11 +02:00
Maurice 6c0d08a415 Merge branch 'main' into fix/v4.3.3-dev
Brings the v4.3.3 release back into the dev line, so the 4.4.0 release
merges into main without conflicts. All twelve conflicts resolve to the
dev side, which carries the same fixes adapted to dev (migration order
of #2527, dev only code around the costs and PWA fixes). Against the dev
fixes alone, the only change is the version bump to 4.3.3.
2026-09-26 14:23:45 +02:00
github-actions[bot] 1c05ee4b3a chore: bump version to 4.3.3 [skip ci] 2026-09-26 12:23:05 +00:00
Maurice 1ff22490f2 Merge pull request #2529 from liketrek/fix/v4.3.3
v4.3.3 bug fixes
2026-09-26 14:21:56 +02:00
Maurice d1dc8542ac fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:27 +02:00
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00
Maurice 99d7fb7f01 chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice c6a089107f chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice 626adead3f fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:20 +02:00
Maurice cd06f0f7f4 fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:19 +02:00
Maurice 44a12e21fc fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked costs are labelled in the trip currency (every linked
row, the link picker and the phone's linked costs), a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 10:36:03 +02:00
Maurice 6ed2561c63 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 10:36:03 +02:00
Maurice ae73527362 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.

On dev the trigger takes schema version 244, the slot it has on main, so a
4.3.3 install upgrading to 4.4.0 does not skip it. Web Push moves up to
245 and is idempotent, so a 4.3.3 install gets its table there. An
instance that already ran Web Push at 244 never runs 244 again, so 246
creates the trigger once more; everywhere else that is a no-op.
2026-09-26 10:36:03 +02:00
Maurice 8b6a8d3098 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 10:36:03 +02:00
Maurice 62f8b0deee fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 10:36:03 +02:00
Maurice 5d4178d067 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 10:36:02 +02:00
Maurice c5e405ca79 fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 27 locales.
2026-09-26 10:36:02 +02:00
Maurice c9dfe9c688 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.

Dropping the worker ends a Web Push subscription with it, so on such a device
the settings card shows push as off until it is switched on again. A missing
sw-push.js now fails the worker install with a 404 rather than index.html; the
comments that described the old answer say so.
2026-09-26 10:36:02 +02:00
Maurice 83d591dc6c fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 10:36:02 +02:00
Maurice a5745fd856 fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 10:36:02 +02:00
gso a0d89abde5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:02 +02:00
gso e6f66014a5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:01 +02:00
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00
Maurice 5f77c2cee9 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 04:22:50 +00:00
Maurice cf5920ebe0 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
2026-09-26 04:22:50 +00:00
Maurice a0952bd871 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 04:22:50 +00:00
Maurice 3c7f6e69dd fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 04:22:50 +00:00
Maurice 30a3f664b1 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 04:22:50 +00:00
Maurice 0a3a993dfc fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
2026-09-26 04:22:50 +00:00
Maurice bea3af4b45 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
2026-09-26 04:22:50 +00:00
Maurice 26ab4a58b1 fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 02:26:39 +00:00
Maurice 0887f251ce fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 02:26:39 +00:00
Maurice 287b84875b fix(budget): show the link controls on an explicit null check
targetId is a number or null; a bare && would render a 0 if it were ever 0 (Sonar S6439).
2026-09-25 16:25:30 +02:00
Maurice 8864edda18 feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could
not be tied to the booking it belongs to, and a booking held one expense.

- The Costs block of the booking, transport and place forms lists every
  linked expense with edit, unlink (the expense stays in Costs) and delete,
  and offers the expenses that belong nowhere yet under Link existing
  expense. The phone sheets get the same block in their own design, and
  their Files row lists the attached files and links an existing one.
- PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC)
  take reservation_id / place_id; create and update refuse an id from
  another trip on all three paths.
- The booking's mirrored price is the sum of its expenses when they share
  one currency (none counts as the trip's), else the first one, with that
  currency, and follows every change to a linked expense. The booking
  form's price field leaves a booking with several expenses alone.
- Deleting a booking deletes all of its linked expenses and broadcasts each
  one, also from the MCP delete tools.
- Cards show the price with its currency.
- Booking and transport dialogs: Booking Type and Travelers as dropdowns at
  the top, the URL beside Files (transports gain the URL field). The row is
  one shared component; linking and unlinking files go through the store.

Help center pictures and texts for the booking, transport and expense
guides are redone in all 27 languages, five Costs labels that were still
English in 16 languages are translated, and the wiki follows.
2026-09-25 15:34:32 +02:00
Maurice 1f79776070 feat(todo): To-Do in the look of the packing list, detail pane always in reach
Rows get the packing list's filled checkbox, priority, due date and list
as quiet badges, and the assignee as an avatar with the name as its
tooltip. The tasks sit in a card whose head names the filter and holds
the sort, Priority or Due date, which moved out of the sidebar; a second
click on the active one goes back to the manual order. The progress card
matches the packing list's.

The detail pane is a card with a box in its head that ticks the task
off, and Delete and Save changes stay in sight: the To-Do view now fills
the space under the Lists bar instead of a fixed height that pushed the
pane's foot off screen. The detail pane and the new-task form share one
set of fields, and a new list is named in a dialog, from the sidebar
and from the form; the dialog moved to shared/NameDialog for both lists.

The To-Do help pictures are re-rendered, the texts that described the
old sidebar and form follow in all 27 languages, and the wiki page is
updated. The phone screens are unchanged.
2026-09-25 13:29:06 +02:00
Maurice dc60f39554 feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
2026-09-25 12:57:15 +02:00
Maurice 5c312a8497 feat(packing): print, PDF and Markdown/CSV export of packing lists
The packing list bar gets an export menu next to the import button, both
now icon only. The menu prints the open view as a TREK styled page (trip,
dates, packed count, one card per category, bags), which the browser can
also save as a PDF, and downloads it as a Markdown checklist or as a CSV
in the exact format the import reads, bags included, so a list works as a
personal template without admin rights.

The import now also reads Markdown checklists: headings become
categories, "- [x]" marks an item packed, a "3x" prefix sets the
quantity and a "(200 g)" suffix the weight. The phone action sheet
offers the same exports through the shared usePackingExport hook.

The help center gets an export guide with new pictures, the import guide
and the wiki page describe the Markdown format, in all 27 languages.

Addresses discussions #1420 and #875.
2026-09-25 11:31:23 +02:00
Maurice 2a8071f642 feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.

The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.

Addresses discussion #2221.
2026-09-25 09:19:11 +02:00
Maurice 1727732841 feat(plugins): plugins can add POI categories to the map and answer them
A new manifest capability poiCategories (up to 4, icon from an allow-list,
strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the
hook:poi-category-provider permission. GET /api/plugin-pois in the plugin
contributions module answers them in the /api/maps/pois shape, with
search_plugin_pois as the MCP twin. plugin: ids no longer count as Google
place ids. SDK 1.8.0 carries the types, validation and docs.

On the map, one hook merges the built-in categories with the ones active
plugins declare, and the pill shows the plugin chips after a divider on
desktop and phone, scrolling sideways instead of shrinking a chip below
its touch target. A plugin chip searches through a repo that refuses
offline and parses the answer strictly. Its pins, on Leaflet and GL, use
the category's own icon and color, both checked again before they reach
any markup, and the hover card lists the plugin's details as escaped
text. A plugin POI can be added as a place like any other. The admin
panels on desktop and phone show a chip for the new permission and the
categories a plugin would add, from one shared module.

Addresses discussion #1781.
2026-09-25 01:24:55 +00:00
Maurice c7c9ba24a3 fix(journey): select all takes every photo in the range and imports keep their order
The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".

A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.

After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.

Addresses discussion #1587.
2026-09-24 23:48:49 +00:00
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00
Maurice 7ce31756a3 feat(settings): every date picker starts its week on the day you pick
Settings > General > Language & region gets "Week starts on" (Monday,
Sunday or Saturday), and Admin > User Defaults a default for everyone who
has not picked one. Monday stays the built-in value, so nothing changes
for anyone who leaves it alone.

Both calendar implementations follow it: CustomDatePicker, used by trip
dates, bookings, costs, to-dos and the admin school holidays on desktop
and phone, and the journey date picker, whose weekday headers were
hardcoded English and now speak the app language. The grid math and the
header row live in one helper (utils/calendarWeek.ts). The day names on
the options come from Intl, so only the label and a hint needed
translating. Vacay keeps its own per-plan week start.

The value is validated like the other regional settings: VALID_VALUES for
admin defaults, the display-settings MCP tool, and the managed key list.

Addresses discussion #2029.
2026-09-24 21:56:12 +02:00
pipipiper 196dc12762 feat(mobile): place details block in the place editor 2026-09-24 21:40:13 +02:00
Maurice bb5c5dcfc1 docs: TREK ships 27 languages now
Azerbaijani, Estonian, Slovak and Thai joined; the README and the wiki
still counted 23.
2026-09-24 17:35:25 +02:00
Maurice b7f777024a feat(i18n): bring the Thai translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers th
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:29 +02:00
Dan ba9936f90a feat(i18n): add Thai (th) translation 2026-09-24 17:34:29 +02:00
Maurice 3c036ea25d feat(i18n): bring the Slovak translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers sk
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:09 +02:00
Peter Gonda 80502dcc9d feat(i18n): add Slovak (sk) translation 2026-09-24 17:34:09 +02:00
Maurice ffa085aef7 feat(i18n): bring the Estonian translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers et
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:33:51 +02:00
Jaano Rosin 9263421c5e feat(i18n): add Estonian (et) translation 2026-09-24 17:33:51 +02:00
Maurice 33aa1cd52e feat(i18n): bring the Azerbaijani translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers az
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 16:57:11 +02:00
shakirgv 9b843bee80 feat(i18n): add Azerbaijani (az) translation 2026-09-24 16:57:00 +02:00
Maurice 8906b66bc8 feat(settings): set the Google Maps key through PLACES_API_KEY
The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
2026-09-24 16:50:02 +02:00
Maurice 79cd3095e0 feat(login): an OIDC-only instance shows the way to the IdP, not a form
On a slow line an OIDC-only login page drew the email and password form while it
asked the server which sign-in it offers, then the SSO button once it knew, and only
then left for the identity provider. Both looked like something to fill in or click.

Until the config answers, the page now shows a short wait instead of the form, and
on an OIDC-only instance it becomes a screen that only says where it is going,
"Taking you to Keycloak…", with nothing to fill in. A repeat visit announces that
redirect at once from the cached config. Should the redirect not have left the
page after eight seconds, a quiet link continues by hand.

The redirect still never runs on a cached config, right after a sign-out or after a
failed sign-in at the provider. That last case now loads the config as well, so an
OIDC-only instance shows its own screen with the error rather than a password form
nobody can use.
2026-09-24 16:34:51 +02:00
Maurice 20e912d6c4 fix(help): the document sync guide speaks for every store, not only Nextcloud
"Keep the documents in step with a Nextcloud folder" walked through Nextcloud
alone: its app password, its base folder, the question where the trip should live
in Nextcloud. The dialog offers five stores. The guide now reads for all of them:
how each files things (by tag, in a folder, in a space), what each signs in with
(an API token for Paperless-ngx, an API key and the organisation for Papra, username
and app password for Nextcloud, username and app token for OpenCloud, username,
password and a two-factor code for Synology Drive), that the base folder belongs to
Nextcloud and Synology Drive, and that the trip lives in a tag, folder or space. The
pictures keep Nextcloud as the example, and the text says so. All 23 languages use
the labels of their own interface.
2026-09-24 15:23:21 +02:00
Maurice 3b05a59b19 fix(help): the days guide shows deleting a day and adding one with a date
"Add, reorder and rename days" still described the reorder popup as it was before
#2485: Move up, Move down and one Add day at the end, and its picture showed that
popup. The guide has five steps now. The reorder step names the bin on each day and
the two ways of adding one, the next date or Without date, and a new step shows the
question before a day goes, with what goes with it, answered with Cancel. The first
step says that shortening the dates lists the days that go, and the tip says moving
days can be undone and deleting one cannot. All 23 languages use the labels their
own interface shows.

The picture script looked for the Add day button that #2485 removed, so it could not
run any more. It frames the popup itself now, asks about the second day so the
question has places and a booking to list, and clicks the first day a second time
when the steps before left it selected and the first click folded its panel away.
2026-09-24 15:00:30 +02:00
Maurice 055c3ae5c7 feat(help): readable rings, real documents and imagery in the pictures
The pictures picked up five faults that the run could not see, because a
passing step only proves the click landed.

The ring vanished on the lightbox: the accent is nearly black and so is
the overlay. It now measures the surface behind it and puts a white
hairline outside itself and the number only where the contrast is too
low, so every other picture keeps the single line it had.

The number fell outside the picture whenever the target was wider than
the frame, which is every full-width row: the frame is known before the
ring is drawn, so the number moves inside far enough to clear the edge.

The satellite imagery was a grey rectangle although every tile came back
200. Leaflet fades a tile in over 200 ms and takes the progress from
`+new Date()`, and the browser's clock is pinned to the picture day, so
the opacity it writes on every frame is always 0. A stylesheet on
Leaflet's own `leaflet-tile-loaded` outranks that inline style. The
satellite guide also waits for decoded tiles now instead of for a tile
element to exist.

The uploaded files looked like nothing anyone would upload. A PDF is
drawn with an issuer band, a reference, a table of fields and a footer,
and the two photographs are gone: a drawn photograph reads as clip art
at any size, so the trip carries a map screenshot with a pin and a
booking confirmation in a browser window, which are graphics in reality
too.

In the panel, a step picture was covered into a 16:10 box and lost its
sides; contained everywhere now, the hero and the result in 16:9.

Beside that: the place guide gained the drag-and-drop route to a day as
its fourth step, in all 23 languages, and `e2e/help/README.md` now holds
the whole visual contract, the traps that cost this round (the pinned
clock, prettier against the client sources, a killed run holding its
ports) and how to find out which pictures a change forces to be retaken.
2026-09-24 14:06:38 +02:00
Maurice 8aa9e308bb feat(help): every picture from one run
The whole set, 914 files, made in one pass on one machine against one
calendar: 41 screens, 225 guides, 764 steps. What changed beyond the new
guides: the pictures carry the run day's dates now, the Files tab's
preview shows a real PDF page, the Place details card has its hours, its
ring and its phone number, the trip map and the Atlas carry the Dawarich
control the new bullets describe, and the Files toolbar has Document
sync beside Trash.
2026-09-24 14:06:38 +02:00
Maurice c4d436bfc1 feat(help): translate the new guides into all locales
Ninety-eight keys in the other 22 languages: the nine guides of this
round, the reworked read-place and import-booking-file, and the two
screen bullets. Every UI label in them is the app's own string in that
language, and where a label is still English in a locale, the text keeps
it English so the reader finds it on screen.
2026-09-24 14:06:37 +02:00
Maurice 41851429c9 feat(help): guides for what TREK talks to, and a seed that follows the day
The first round could not photograph a few screens because the service
behind them was not there: Dawarich, AirTrail, a document store, the
booking extractor. They are there now, so the guides are too.

- map-dawarich-trail, dawarich-countries and dawarich-wishes: the run
  uploads a recording of the seeded trip, and of the year before it, into
  a Dawarich of its own (dawarich-track.ts) and reads it back.
- airtrail-import: four flights are put into an AirTrail account, two of
  them connecting in Tokyo, so the picker offers the layover.
- import-booking-file and import-transport-file: the confirmations carry
  their facts as schema.org data the way portals send them, the extractor
  reads them, and the guides go on to the filled-in form and the card.
- files-sync and document-providers: a Nextcloud folder with two
  documents in it, bound to the trip, and the switches in the admin panel.
- read-place: three seeded places carry their OpenStreetMap id, so the
  card shows the week's hours, the phone number and the Open or Closed
  ring. The rating stays Google's, and the texts say so now.
- map-compass on the MapLibre renderer, which the guide switches to and
  back; files-preview opens a PDF, which needs the full browser rather
  than the headless shell (channel: chromium on the help-media project).

Each of those takes an address and a key from e2e/help/media.env, which
is not committed; media.env.example and the README say what each one
needs and why the address has to be the machine's own on the network.

The seed's dates are offsets from the day the pictures are taken
(e2e/dates.ts): the trip ends today, so the boarding pass, What's Next
and the forecast have something to show whenever the run happens, and
nobody bumps a date in three files every few weeks. run.mjs fixes the day
once per run so the projects and the workers agree across midnight.
2026-09-24 14:06:37 +02:00
Maurice b1528b63c4 docs(wiki): a link can be edited now
The Links section still described the panel as it was before #2414: pin
or delete, and add again to correct a typo. It names Edit link now, says
the form takes only http and https addresses and what the chip shows, and
it is a panel beside Notes rather than a tab, except on the phone.
2026-09-24 14:06:36 +02:00
Maurice 33b65ab8fa fix(i18n): two strings the locales had wrong
shared.other labels the same fallback group on the public share page that
the app itself calls Sonstiges and Otros, so de and es said one thing in
the trip and another beside it. admin.tabs.addons was Russian in uk.
2026-09-24 14:06:10 +02:00
Maurice b7dcb58a6b chore(i18n): drop the catalogue entries nothing renders
reservations.fromPlan, packing.suggestions, packing.suggestionsTitle,
packing.allSuggested, packing.emptyHint and the packing.suggestions.items
array are referenced by no component; the suggestions block left the
packing list in #14 and the keys stayed behind in all 23 languages. The
VORSCHLAEGE constant beside them was exported and imported nowhere.

With the one structured entry gone, a catalogue value is a string and
nothing else, so TranslationValue says so and the placeholder spec no
longer needs to skip what is not one.
2026-09-24 14:05:46 +02:00
Maurice 0f28c929be fix(files): the image lightbox renders through a portal
Rendered in place, its fixed overlay sat inside the trip page's stacking
context, below the navbar's z-[200]: the bar painted over the lightbox's
header, so Open in new tab, Download and the x were unreachable. The two
document previews beside it already portal into the body for this reason.

The tests read the lightbox through baseElement now, since a portal puts
it outside the container Testing Library returns.
2026-09-24 14:05:46 +02:00
Maurice e8637f7208 fix(help): the leg mode lands when the menu closes, not when one icon changes 2026-09-24 14:05:46 +02:00
Maurice dbc369beee fix(help): the kind disc answers to its own label, and so does the row around it 2026-09-24 14:05:46 +02:00
Maurice 168cd742a4 fix(help): three the third full run showed up
- The lightbox header's button group is the right thing to ring, but its centre
  falls between the buttons where the picture underneath takes the pointer, so
  the hover never landed. The pointer goes on a button, the ring stays on the
  group.
- The collection the place is saved into was matched by a name another guide's
  list starts with.
- help-media gets four minutes a guide instead of two and a half. 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 and opens the place form on top.
2026-09-24 14:05:46 +02:00
Maurice 05b453b493 fix(help): the collection helper belongs beside the scripts, not inside them 2026-09-24 14:05:46 +02:00
Maurice 13017b505a fix(help): the undo guide needs the day it puts a place on to stay open
The + at the end of a place row belongs to the open day, and closing the day's
details panel closes the day with it, so there was no + to click and the guide
waited for one until the test gave up.
2026-09-24 14:05:45 +02:00
Maurice f6b2880abb fix(help): three more the whole suite showed up
- place-to-collection found the place already in a list, because the Collections
  screen's guides run earlier and leave it there. It clears the place first now,
  with the same helper its cleanup uses.
- The road trip rail leaves out a day with nothing to drive, which its own
  helper says, and the fold step was the last place still pointing at day 1.
- The transport form's location search took whatever the geocoder ranked first,
  and for a landmark that is as often a shop down the road. It picks the
  suggestion that answers the query.
2026-09-24 14:05:45 +02:00
Maurice 64b190862e fix(help): the locators the whole suite in one database showed up
Every one of these passed when its own file was run alone and failed when all
of them ran against one seeded trip, because another screen's fixture had put
something in the way.

- The Bookings fixture names a booking after a place, so the Assign File dialog
  offered two entries opening with the same words.
- The Lists tab has a Shared button, which the loose name of the navbar's Share
  also matches, so opening a trip on that tab never settled.
- A traveller's avatar is named by the initial it draws, not by the username;
  the title carries the name.
- Departure and Arrival are what a booking gets when it crosses days, so the
  guide about them points at the flight, not at the train that starts and ends
  on one day.
- The What's Next stop was timed from the wall clock of whoever runs this,
  while the browser's clock is pinned to the day the pictures are taken on.
  Both now come from the same constant.
- The map was panned with the arrow keys, which Leaflet only reads while its
  container has focus, and focusing it from here did not take. It is dragged
  now, from a point checked to be free of markers, because a press on a marker
  drags the place onto a day instead.
2026-09-24 14:05:45 +02:00
Maurice e9393e9e6c fix(help): a duplicated trip outlived its guide and broke another screen
The duplicate-trip guide dropped its copy by position, keeping whatever the
trips endpoint happened to answer first. The copy survived, and three files
later the journey dialog offered two entries starting "Weekend in Lisbon" and
the create-journey guide could not say which one it meant.

The copy is now dropped by name and the delete is checked, and the journey
dialog picks its trip by accessible name rather than by a substring another
trip shares. Each file passed on its own before this; only the whole suite in
one run showed it.
2026-09-24 14:05:45 +02:00
Maurice d2e352085d fix(help): the corrected sentences in the other languages too
Forty-five values were rewritten after a second pass over the running app found
the old text naming a control that is not there or describing one in the wrong
order. Each locale's existing translation was edited rather than replaced, so
the register and the vocabulary of the block it sits in are kept.
2026-09-24 14:05:45 +02:00
Maurice 821a103146 feat(help): translate the Road Trip block, and parity is green again
All ten screens of a trip are now in every language TREK speaks: 2134 help keys
in each of the 23 locales. i18n:parity:strict passes for the first time since
the help center landed, which also un-skips every CI job that was waiting
behind it.
2026-09-24 14:05:45 +02:00
Maurice 3c805e2c52 feat(help): translate the Costs and Transports blocks into all locales 2026-09-24 14:05:44 +02:00
Maurice e7db35b0a8 feat(help): translate the Lists and Bookings blocks into all locales 2026-09-24 14:05:44 +02:00
Maurice 1a5a8a9372 feat(help): translate the Collab block into all locales 2026-09-24 14:05:44 +02:00
Maurice 0ed0d98c32 feat(help): ten more screens of a trip, registered with their guide scripts
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides over 360 steps, each one a task a reader can
follow, with the Playwright script that will photograph it. The fixtures they
need go in beside ensureDaysFixtures, and every screen is a child of the trip,
so the panel folds them under it.

The capture now pins the browser's clock to the day the seeded trip ends. A
picture has to look the same whoever takes it and whenever: a day later the
trip is over, My Trips moves it out of the boarding pass, What's Next has
nothing left to list, and a forecast for a day in the past is not a forecast.
Only `Date` is pinned, so timers keep running.

trip-days is green end to end after its locators were checked against the
running app; its route guides read day 6, whose two stops sit next to each
other, because day 1's are separated by the flight and have no leg of their own.
2026-09-24 14:05:44 +02:00
Maurice 30a7cfd483 feat(help): the English texts for the ten screens of a trip, and four of them translated
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides and 360 steps, written against the running app
rather than against the components, so every label a guide names is a string the
reader will actually see. Places, Day details, Map and Files are translated into
the other 22 languages; the rest follow.

Also reworded the Settings overview: it called TREK self-hosted, which the
repository's own wording rule forbids and which nothing was running to catch,
because the test that does needs i18n parity and parity has been red.
2026-09-24 14:05:43 +02:00
Maurice cb6aeb7bfd test(members): stop the location stub from swallowing the request it waits for
Two cases replaced window.location wholesale to spy on reload. The copy is flat,
the real object's fields are getters on its prototype, so the replacement has no
href for a relative request URL to resolve against and the DELETE the leave-trip
case waits for never reached the mock server. It also stayed replaced for the
rest of the file, which took six later cases down with it.

The leave-trip case asserts the request, so it keeps the real location: jsdom's
reload only logs. The transfer case asserts the reload, so it keeps the stub,
and the original is put back after every test.

Nobody saw this: the client coverage job needs i18n parity, which has been red.
2026-09-24 14:05:43 +02:00
Maurice d3a9e00752 test(help): find the fold toggle inside its own row in the switcher
'2 sub-screens' stopped identifying one button the moment a second screen had
two of them, and the trip is about to have a dozen. The toggle is looked up
within the row it belongs to instead, which says what the assertion means.
2026-09-24 14:05:43 +02:00
Maurice 1f01447093 fix(collab): the expand button on a note showed its own translation key
`collab.notes.expand` was never added to the catalogue, and `t()` answers with
the key itself, so the `|| 'Expand'` fallback never ran and the tooltip read
collab.notes.expand. `common.expand` says the same thing and is already
translated everywhere, so the button uses that, and it now carries the label
for assistive tech as well.
2026-09-24 14:05:43 +02:00
Maurice 1e9b23c5dd fix(help): the corrected places texts in the other languages too 2026-09-24 14:05:43 +02:00
Maurice 72b32c6a12 docs(wiki): a page for the help center
What the ? in the navbar opens, what an overview and a guide contain, how the
search and the screen switcher work, where the pictures come from and what
happens on a screen that has no help yet. Linked from the sidebar, from the
features list and from In-App-Help, which documents the other thing and was
being confused with it.
2026-09-24 14:05:42 +02:00
Maurice 431eb883c9 feat(help): pictures and translations for the trip's places column
All eight guides of Trip > Places now have their pictures, taken from the real
app, plus the hero of the screen, and the block is translated into the other
22 languages.

Making the pictures showed three texts describing a UI that is not there, and
they are corrected: the file import dialog has no Enrich places via Google
switch (only the list import has one, and only with a Google key), a GPX import
closes with a message while only KML and KMZ leave a summary behind, and both
list providers are always offered. The search and the details column need no
Google key either: they fill themselves from the TREK index, OpenStreetMap,
Wikipedia, Wikivoyage and Wikimedia, and only ratings are Google's.

The script needed the same honesty. ConfirmDialog and the two import dialogs
are portals of their own with none of Modal's classes, so they get their own
locators, and create-place now looks up a place whose entry actually carries a
picture, opening hours and a description, instead of photographing an empty
details column.
2026-09-24 14:05:42 +02:00
Maurice 5584d287a9 refactor(help): register a screen from one description, and fix the doc links
Every context file carried its own copy of the same little guide factory and
the same context literal. Thirteen screens of a trip would have made thirteen
copies of it, which is both the duplication a quality gate counts and the
hand-mirrored state the repository asks us not to keep, so the shape moves into
defineScreen and a context file becomes data: ids, counts, docs, cross-links.

Four doc links never resolved. Trip-Planner and Sharing-and-Collaboration are
not wiki pages (Trip-Planner-Overview and Trip-Members-and-Sharing are), the
places import anchor is importing-multiple-places, and the offline anchor was
written in GitHub's slug, which is not the one the in-app reader produces.
Nothing caught them, so the registry test now checks every slug and every
anchor against wiki/, with the heading slug itself lifted out of HelpPage so
the reader and the test cannot drift apart.
2026-09-24 14:05:42 +02:00
Maurice cd0a80e70d feat(help): translate the trip block into all locales 2026-09-24 14:05:42 +02:00
Maurice 4346d7f2c1 feat(help): in-app help center with per-screen guides and pictures
A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
2026-09-24 14:05:42 +02:00
github-actions[bot] 151e32107f chore: bump version to 4.3.2 [skip ci] 2026-09-24 11:14:02 +00:00
Maurice 8779529efb feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.

Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
2026-09-24 11:32:52 +02:00
Maurice e1315806c2 feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.

It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.

The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.

The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
2026-09-24 11:32:52 +02:00
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00
Maurice 3062df153f chore(notices): the release notice points at 4.3.2 2026-09-24 11:27:12 +02:00
Maurice 233076030b fix(docsync): a store picked before the panel settles stays picked
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
2026-09-24 08:14:29 +02:00
Maurice c5ac479915 fix(dayplan): sort the trip's dates with an explicit compare
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
2026-09-23 23:11:10 +02:00
Maurice 954758082b fix(dayplan): calmer day dialogs, and the shrink question side by side
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
2026-09-23 22:40:47 +02:00
Maurice 83be759c49 fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
2026-09-23 22:40:47 +02:00
Maurice f3ca0b1a4c test(mobile): the delete-day confirm test takes a free id
FE-MOB-SHOST-030 is taken by the invite field test that landed on dev in
the meantime, so the delete-day confirm test moves to 032.
2026-09-23 22:40:47 +02:00
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00
Maurice 1639fc6a3e docs(wiki): deleting and adding days, and what shortening a trip removes
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
2026-09-23 22:40:46 +02:00
Maurice 1b63285ad0 feat(trips): shortening a trip says first which days go and what is on them
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
2026-09-23 22:40:46 +02:00
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00
Maurice 436f0740fb fix(planner): the delete question says plainly what goes with a booked place
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
2026-09-23 22:39:54 +02:00
Maurice 1423ba8933 fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
2026-09-23 22:35:01 +02:00
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00
Maurice 362b1ee8d1 fix(map): a selected stay is flown to even when no pin stands for it
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
2026-09-23 22:11:51 +02:00
Maurice b5457d9e6a fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
2026-09-23 21:28:34 +02:00
Maurice a1d6348c5f revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
2026-09-23 20:32:41 +02:00
Maurice f16698bdb1 fix: an Immich error leaves out Node's advice, a locked save looks locked
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
2026-09-23 18:03:58 +02:00
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00
Maurice 0df82185bd feat(collections): a saved place keeps its price, website and phone, editable like in a trip
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
2026-09-23 17:41:26 +02:00
Maurice e8ec3427e5 fix(share): the user picked to invite shows in the field before it is sent
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
2026-09-23 17:41:26 +02:00
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00
Maurice 41b14ebc2a fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
2026-09-23 17:41:25 +02:00
Maurice 05f533dd0d fix(roadtrip): a late check-out morning says so, a road driven twice takes its via
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
2026-09-23 16:01:40 +02:00
Maurice e6e520b78f fix(roadtrip): the hotel at the edge of a day stands, drives and reads right
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
2026-09-23 15:31:09 +02:00
Maurice 05651eed1a docs(wiki): a road trip day can start and end at the stay
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
2026-09-23 15:31:09 +02:00
Maurice 7e7d13c021 feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
2026-09-23 15:31:09 +02:00
Maurice 514e2e62ee feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
2026-09-23 15:31:09 +02:00
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00
Maurice 3a67a8e279 fix(planner): trip places in every manual location field, never at 0,0
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
2026-09-23 13:41:31 +02:00
hiro-nikaitou cc5957dc6a fix(planner): offer trip places in manual transport
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
2026-09-23 13:34:02 +02:00
Maurice 83d088e842 fix(roadtrip): a merged drive keeps its first piece ends when a leg comes without them
A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
2026-09-23 12:53:24 +02:00
Maurice e921799580 test(roadtrip): mount the phone case of a refused way the way the phone suites do
The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
2026-09-23 12:30:10 +02:00
Maurice 34c1dc79b5 fix(roadtrip): a drive bent through a via prints its whole length, and a refusal is said once
A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.

On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
2026-09-23 12:03:00 +02:00
Maurice 451e6d240b fix(roadtrip): seat rides and the drive into tomorrow by one rule, and say why a way was not taken
The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
2026-09-23 11:28:24 +02:00
Maurice ab3a81a9eb fix(roadtrip): a booked ferry is driven to, and one without a date says so
A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
2026-09-23 11:28:24 +02:00
Maurice e6daed9403 fix(roadtrip): the drive between two days can take another way too
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
2026-09-23 11:28:24 +02:00
Maurice 53a422d22d fix(roadtrip): other ways come from the router that draws the leg, and a choice is saved once it holds
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.

The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
2026-09-23 11:28:24 +02:00
Maurice 6124e0b056 fix(maps): amap hours are read whole or not at all, and the tip stash lives on the service
The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.

amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.

The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
2026-09-23 09:36:04 +02:00
pipipiper d52154c765 fix(maps): amap answers for tip-only places, region-bearing fallback queries, and Chinese hours 2026-09-23 09:36:04 +02:00
Maurice aceb75949b fix(planner): closing the place dialog resets the whole search block
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.

Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
2026-09-23 09:11:48 +02:00
Luffy 4f5a6bb7d8 fix(planner): clear place search input when modal closes 2026-09-23 09:07:48 +02:00
Maurice d609e1e202 Merge pull request #2466 from liketrek/docs/wiki-mcp-openwebui-callback
docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
2026-09-23 08:49:56 +02:00
Maurice 6f3977e4c1 docs(wiki): the Open WebUI callback carries mcp: in front of the connection ID
The MCP setup page gave Open WebUI's redirect URI as
{your Open WebUI URL}/oauth/clients/{connection id}/callback. Open WebUI puts
mcp: in front of the ID it builds that path from, so a client registered by
hand with the bare ID never matched and the consent step answered
"Unregistered redirect_uri". The page now names the real path and adds how
Open WebUI connects: it registers and trades the code from its own server, so
its container has to reach the public TREK address, and its WebUI URL has to
be the public https address.
2026-09-23 08:28:29 +02:00
Maurice 615e74aff6 fix(pwa): an upgrade counts as applied once the new build has taken over
After a server upgrade the app wrote the new version into its marker
before the service worker had fetched the new build. When that fetch
failed, when the new precache broke off halfway, or when the browser had
already swapped workers before the version check answered, the page kept
the old bundle, and every later launch took the marker at its word and
never tried again. An installed PWA went on showing the previous UI.

The marker now moves only once the handover is done or turned out to be
unnecessary, so a failed attempt is simply retried on the next launch. A
failure no longer reloads into the shell the old worker serves again, a
worker that took over before the check answered still gets its reload,
and no path reloads more than once per version and session. Caches and
workers are still never deleted.
2026-09-22 21:48:23 +02:00
Maurice bb515ac3cc fix(planner): booking codes stay blurred in the edit forms too
With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.

One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
2026-09-22 21:48:22 +02:00
Maurice 93dddaa4af fix(mobile): booking routes on the phone map follow the day they run on
A booking switched on with "On map" stayed drawn on every day of the
phone's plan map, so a flight from the first day crossed the map of the
third, although a day chip frames one day and drops the other days' pins.
The plan map now draws such a booking only on the days it runs on, the
arrival day of an overnight one included, and the all-days view still
shows every booking that is switched on. The switch itself stays
trip-wide, and the desktop, collections and the road trip stage draw
exactly as before.

"On map" in a booking's sheet now reads as on only where the plan map
draws the route. From any other day a tap takes the map to the booking's
day, switching the route on when it is off, instead of hiding a route
that is not drawn there anyway.

The phone settings also gain the "Always show booking routes" switch the
desktop already had. As on the desktop it sets the default for trips not
yet touched on this device, so a trip where a route was switched on or
off by hand keeps that choice.
2026-09-22 21:48:22 +02:00
Maurice 1a8d5a3dd9 fix(roadtrip): a stop with a start is driven where the day list shows it
The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.

Behind a later start the road trip reached such a stop late, and the
night spill took its earlier clock for a midnight: the stop and every
stop after it opened the next day's card, and a day left with one stop
fell to the placeholders at the bottom. A stop the schedule marks late
now stays on the day the schedule files it on, and the stops behind it
are read from its hour, so a clock that goes back past that hour still
starts the next day. The desktop rail, the phone's road trip tab and
the MCP calculate_roadtrip tool share that spill.
2026-09-22 21:48:22 +02:00
github-actions[bot] ed8acf4c4b chore: bump version to 4.3.1 [skip ci] 2026-09-22 12:17:29 +00:00
Maurice a280517ff3 Merge pull request #2450 from liketrek/audit/431-fixes
fix(release): the audit round over v4.3.1
2026-09-22 13:30:38 +02:00
Maurice 2dd77aaac1 docs(wiki): the pages the 4.3.1 changes left behind
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
2026-09-22 13:30:12 +02:00
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00
Maurice cb85ba915c fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
2026-09-22 13:30:12 +02:00
Maurice 81292d4d2b fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
2026-09-22 13:30:12 +02:00
Maurice 757967bf18 fix(map): the location button follows the viewport instead of the mount
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
2026-09-22 12:57:04 +02:00
Maurice b0c19bfac1 fix(settings): the client registration form says what the server refused
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
2026-09-22 12:12:41 +02:00
Maurice da32eb8a3c fix(mcp): answer the discovery documents under the server address too
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
2026-09-22 12:12:41 +02:00
Maurice a44df6cf11 fix(planner): place edit and delete controls follow place_edit, not day_edit
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.

Closes #2446
2026-09-22 11:29:43 +02:00
Maurice e510ff25ab fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false. The nested objects a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
2026-09-22 11:06:13 +02:00
Maurice 34f3aeee5f fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 11:06:13 +02:00
Maurice bdd326163f fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 10:32:08 +02:00
Maurice b1baf3baa9 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 10:32:08 +02:00
Maurice fdab3bb347 Merge pull request #2437 from liketrek/fix/mcp-search-provider-test
test(mcp): search_place hands the provider option on
2026-09-21 23:05:25 +02:00
Maurice d288459449 test(mcp): search_place hands the provider option on, and refuses any but Google
The tool passes `{ googleOnly }` to the search since #2436; four assertions
still expected the four-argument call. Pinned the new argument, and the one
case the contract added: `provider: 'google'` reaches the search as
`googleOnly: true`, any other value is refused by the schema before the
search is asked.
2026-09-21 23:04:26 +02:00
Maurice e35d95da71 Merge pull request #2436 from liketrek/feat/places-google-only
feat(places): send a search to Google, once or always
2026-09-21 22:23:59 +02:00
Maurice 6b1d65b06c fix(i18n): the Google-only strings carried a doubled escape in English 2026-09-21 22:21:02 +02:00
Maurice b6fcaced2e feat(planner): the places filter is one select, not four pills
With the tracks tab the pill row had grown past what the rail can hold: four
pills sharing one line clipped a word as soon as the panel narrowed. The
filter is the app's own select now, the count riding on the trigger the way
it rode on the active pill, and every option carrying its own in the menu.
2026-09-21 22:18:51 +02:00
Maurice e12ae950df feat(places): send a search to Google, once or always
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.

Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.

Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
2026-09-21 22:14:44 +02:00
Maurice 11b1399b5c fix(pdf): list a day the way the plan lists it, without the booked night's stop
Booking a night writes the hotel onto its check-in day as a stop, and since
4.3.1 that stop heads its day. The day plan hides it and shows the booking as
the day's accommodation block, but the desktop toolbar hands the export the
store's own assignments, filter and all, so the PDF printed the hotel place
above a flight that left in the morning.

The export now drops that stop before it merges the day, as the list does. The
mobile export already received the filtered set and was right all along.

Closes #2434
2026-09-21 20:54:23 +02:00
Maurice 0644106f7a fix(collections): a marker click shows the place, every time
A marker click toggled the selection, so the second click on the marker of the
place that was open closed it again, which read as the details refusing to
open: every other click on the same marker showed nothing. A marker click now
selects and never clears; the map background and the close control do that,
and a list row still toggles as before.

Closes #2431
2026-09-21 19:35:41 +02:00
Maurice 903378f8ad fix(maps): build the day's road from the stops the day plan shows
Booking a night writes the hotel onto its check-in day as a stop, for the road
trip's driving chain. The day plan hides that stop and draws the hotel as the
day's bookends instead, but the route builder behind the map line and the
phone's travel connectors still read it from the store. It carries no time of
its own, and a timed booking is seated behind the last timed stop, so on a day
that opened with a flight the hidden stop led the day: the map drove from the
hotel to the departure airport and back to the hotel after landing.

The builder now leaves the booked night's stop out, as the list does, and the
bookend rules put the hotel where it belongs: behind the flight, on the road
from the arrival airport. A hotel the traveller placed as a stop of their own
is untouched.

Closes #2430
2026-09-21 18:09:44 +02:00
Maurice 84d3dd0c4a fix(roadtrip): a hop between two desks keeps its line and drops the pill
The hire desk beside the terminal is a 200 m leg, and the pill under it read
"0 km in 0 min" where the eye wants the next stop. Under 150 m and two
minutes a leg is a hop: the rail keeps the dashed line, the phone chain skips
the row, and neither offers other ways of driving it. A note a plugin
attached keeps the row.

Refs #2428
2026-09-21 17:16:19 +02:00
Maurice bdb031a1e9 feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
2026-09-21 17:05:42 +02:00
Maurice 0b4230ff4c fix(roadtrip): the booking a terminal opens has somewhere to open
The day panel owns the transport detail dialog, and in road trip mode the
day panel is not mounted: a terminal row, a ride pill and a map endpoint all
set the booking to show and nothing showed it. The rail brings the dialog
along now. Also drops a translation key nothing read.
2026-09-21 16:17:49 +02:00
Maurice fa35966ab8 feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals
The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.

A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.

The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.

Closes #2428
2026-09-21 16:03:21 +02:00
Maurice 4be2314856 docs(wiki): name the toolchain npm asks for on install since better-sqlite3 13 2026-09-21 11:09:38 +02:00
Maurice f43b89be13 test(journey): the editor tile for a clip with and without its poster 2026-09-21 11:08:38 +02:00
Maurice ac23773352 fix(journey): the map waits for its first view before it pans to the active entry
The journey map sets its first view on a rAF after the build, and the active
entry asks for its pan on a 50 ms timer. In a background tab, or on a slow
frame, the timer wins. Leaflet does not refuse a pan on a viewless map, it
takes it as the first view with the zoom still undefined: the tile layer aborts
its own add on that, the load event stops halfway, and every entry marker
queued behind it never reaches the map. The catch that was meant for this
never fired, because nothing threw. The next rebuild, after any save on the
page, then tore down markers that were never added and died in Leaflet's icon
removal, and the error boundary took the whole journey page with it.

Both pans go through whenReady now: at once on a map with a view, otherwise
right after the fit lands.
2026-09-21 11:02:01 +02:00
Maurice 8138c83558 fix(journey): the video player brings its own icons instead of asking cdn.plyr.io
Plyr's defaults point at its CDN twice: the icon sprite for the controls is
fetched over XHR, and on teardown the element is pointed at a blank clip there
to abort the stream. connect-src and media-src both refuse that host, so the
clip played but every button in the player was blank, and each close of the
lightbox logged a blocked request and an uncaught XHR error.

The sprite ships with the bundle now, served from this origin, which Plyr then
references directly instead of loading it. plyr's exports field hides the file,
so it goes through the same resolver alias the rtl-text plugin already needed.
The teardown source is empty: that aborts the stream just the same, and the
element is unmounted in the same tick.

Refs #2341
2026-09-21 11:02:01 +02:00
Maurice 141afe708d fix(journey): let the browser read a picked clip, so it previews and gets a poster
A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.

media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.

Closes #2341
2026-09-21 10:35:37 +02:00
Maurice 43a3a5217e fix(server): better-sqlite3 13, so the image never compiles it against Node headers
The runtime stage installed python3 and build-essential for one reason: when
better-sqlite3 12.x could not download its prebuilt binary, node-gyp built the
addon against the headers of whatever Node the base image carried that day.
Since Node 24.19 those headers give node::ObjectWrap a cleanup hook that the
runtime cannot unregister, and the first garbage-collected statement takes the
process down with "Assertion failed: (env) != nullptr". The published image
was spared only because its prebuild download had succeeded (issue #2421).

13.x is built on N-API, so it never touches that path, and it ships the binaries
for amd64 and arm64 inside the npm tarball. Nothing is compiled any more, and
the toolchain leaves the Dockerfile. The install runs with --ignore-scripts:
a lockfile-driven npm ci still synthesises `node-gyp rebuild` for the
binding.gyp it finds and ignores the package's gypfile:false (npm/cli#9837),
which without the toolchain would fail the build.

Closes #2421
2026-09-21 10:35:27 +02:00
Maurice d14d83ebaa fix(dashboard): a long trip title cannot widen the hero card
The hero's content grid had an auto column, which takes the title's
min-content width. A title that is one long word made the column, the
tools row and the boarding pass as wide as that word, with edit, clone,
archive and delete pushed off the card. The column is capped at the card
now, and the title breaks inside it and stops at two lines.
2026-09-20 23:08:41 +02:00
Maurice cce64c4030 fix(oidc): a successful SSO login writes the user.login audit row
Every other login method writes one; the SSO callback touched last_login and
login_count and wrote nothing, so an instance moving its users to SSO watched
its audit log go quiet while looking complete. The row is written in the
callback, where the provider has vouched for the user and the client IP is,
next to the role-change row, with method: oidc in the details the way the
passkey login names its method.

Closes #2417
2026-09-20 22:28:35 +02:00
Maurice bf0507ddb5 fix(planner): Google hours that share a half of the day convert whole to 24h
Google writes a range that stays in one half of the day with the meridiem on
the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon.
The 24-hour conversion only saw times carrying their own AM/PM, so the
opener stayed "1:00" beside a converted "15:00". The closing meridiem is
handed back to an opener that has none before converting; a range spelled
on both sides, the 12h display and lines without a meridiem are untouched.

Closes #2412
2026-09-20 20:28:23 +02:00
Maurice 9bd9ff4a80 style(collab): a link chip's title takes its width first, the host gets the rest 2026-09-20 20:11:10 +02:00
Maurice 8845f73eb9 style(collab): the host gives way before the title does, all of it 2026-09-20 20:09:22 +02:00
Maurice 6b09a8bc11 style(collab): the title of a link chip gives way last
Two to a row, the title and the host were shrinking together into two
ellipses. The host shrinks first now, the open glyph takes the favicon's
place on hover instead of room of its own, and the actions are a touch
smaller.
2026-09-20 20:07:39 +02:00
Maurice 67dff455da style(collab): link chips two to a row across the panel
A chip takes half the panel now, one to a row where the panel is narrower
than a container query's 420px; the title and the host give way inside it
instead of capping their width.
2026-09-20 20:04:39 +02:00
Maurice 97a2ea1748 style(collab): the chip actions keep their width
Unfolding them reflowed the row under the pointer, and a chip that grows as
you reach for it is a chip you cannot catch. They sit faint and come forward
on hover instead.
2026-09-20 19:52:40 +02:00
Maurice 8d70d875b3 style(collab): shared links as chips
One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
2026-09-20 19:50:06 +02:00
Maurice 3dfdc92b2a fix(collab): a shared link can be edited, and its glyph opens it
A link in the Collab tab could only be deleted and added again once its
title or address needed a correction; the update route has taken both all
along. The add dialog edits a link handed to it now, behind a pencil on
each row. The external-link glyph next to a row that was already a link
read as a button doing nothing; it is a link to the address itself now.

Closes #2414
2026-09-20 19:43:52 +02:00
Maurice 84b73d1a83 fix(planner): the last day scrolls into view beside the road trip switch
With the road trip addon on, the desktop panel puts the Days / Road trip
switch above the day list. The list was sized by height alone, so it ran the
switch's height past the panel's clipped edge and the last day could never
be scrolled into view. It is a flex child of the panel now and yields that
height; where nothing sits above it, the height still fills the panel.
2026-09-20 19:34:47 +02:00
Maurice eeda3188c2 Merge pull request #2411 from liketrek/fix/roadtrip-night-reseat-migration
fix(roadtrip): seat the nights booked before this release where their check-in says
2026-09-20 19:28:33 +02:00
Maurice be9a38e3b0 fix(accommodations): a second night on the day counts by its check-in
Two bookings on one day used to settle by whichever was seated last, because
the other night's stop carries no hour of its own. Its check-in counts now,
in the service and in the migration alike.
2026-09-20 18:48:26 +02:00
Maurice 417d0445b9 fix(roadtrip): seat the nights booked before this release where their check-in says
A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.

A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
2026-09-20 18:45:37 +02:00
Maurice a86e4b560a test(accommodations): a quiet edit still corrects a night the clocks contradict
Covers seatedByCheckIn with a check-in set, which the re-seat on a new
check-in no longer passes through, so the accommodations function ratchet
held.
2026-09-20 17:49:54 +02:00
Maurice cd769d9993 fix(roadtrip): a booked night holds its check-in and heads its day
A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.

The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.

A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.

The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
2026-09-20 17:26:50 +02:00
Maurice 979a9283cd Merge pull request #2408 from liketrek/fix/431-collection
fix(notices): keep the promise box whole when the release notice has to scroll
2026-09-20 16:56:10 +02:00
Maurice 32f2488774 fix(notices): keep the promise box whole when the note has to scroll
On a 1080p screen at 100% the release notice's right column runs out of
height. The column scrolls, but the promise box is a flex item with
overflow hidden, so it may shrink to nothing and was pressed down to its
label while the paragraphs around it kept their height. Everything inside
the two scrolling columns now keeps its size.

Below 1080px the halves stack and the panel scrolls; the single minmax row
that keeps the two-column layout inside the panel starved the release half
to zero height there. The rows go back to auto, and the ask sticks to the
bottom edge once the note is in view.
2026-09-20 16:48:31 +02:00
5818 changed files with 670890 additions and 188327 deletions
+14
View File
@@ -92,6 +92,12 @@ jobs:
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@v6
@@ -102,6 +108,9 @@ 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: |
@@ -163,7 +172,12 @@ jobs:
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" \
+14
View File
@@ -138,6 +138,12 @@ jobs:
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@v6
@@ -148,6 +154,9 @@ 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: |
@@ -207,7 +216,12 @@ jobs:
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:$VERSION" \
+17
View File
@@ -180,9 +180,26 @@ jobs:
# catches it.
run: cd server && npm run typecheck:tests
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
# gen-plugin-facts.ts uses import.meta and does not compile under this
# config (needs a newer --module target), so the gate is named for what
# it actually covers rather than the whole directory.
run: cd server && npm run typecheck:scripts
- name: Entity files match the generator
run: cd server && npm run check:entities
- name: Lint
# Type-aware rules build the src + tests program, which outgrows
# Node's default ~4 GB heap on the runner.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd server && npm run lint:check
- name: DB call-graph gate (no sync DB-touching methods, no raw transactions)
run: cd server && npm run db:call-graph -- --sync --tx
client-quality:
name: Client Types & Lint
runs-on: ubuntu-latest
+2 -2
View File
@@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
- **`/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. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **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 `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`; `server/src/websocket.ts` is a legacy re-export stub.
- **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`.
@@ -90,7 +90,7 @@ 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.
- **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.
- **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.
+26 -5
View File
@@ -27,7 +27,8 @@ COPY client/ ./client/
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
# --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.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
@@ -52,13 +53,22 @@ COPY server/package.json ./server/
# 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 python3 build-essential \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev && \
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; \
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 && \
chown -R node:node /app
@@ -113,6 +123,17 @@ ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# 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}"
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
+2 -2
View File
@@ -168,9 +168,9 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
- **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 and ntfy, plus any channel a plugin registers
- **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
- **23 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
- **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
</td>
+1
View File
@@ -53,4 +53,5 @@ See `values.yaml` for more options.
- `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.0
version: 4.3.3
description: Minimal Helm chart for TREK app
appVersion: "4.3.0"
appVersion: "4.3.3"
+5 -2
View File
@@ -2,8 +2,11 @@
- 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 secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- 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` and
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
- 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.
+18
View File
@@ -64,9 +64,21 @@ 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 }}
@@ -97,3 +109,9 @@ data:
{{- 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 }}
+12
View File
@@ -75,6 +75,12 @@ spec:
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:
@@ -87,6 +93,12 @@ spec:
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
volumeMounts:
- name: data
mountPath: /app/data
+20 -8
View File
@@ -20,12 +20,18 @@ data:
{{- 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 }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -40,26 +46,32 @@ metadata:
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec | quote }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | quote }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .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 }}
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 }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .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 }}
{{- end }}
+31 -1
View File
@@ -71,10 +71,16 @@ 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"
@@ -108,6 +114,15 @@ env:
# 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.
# Secret environment variables stored in a Kubernetes Secret.
@@ -133,6 +148,11 @@ secretEnv:
# 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
@@ -142,11 +162,21 @@ secretEnv:
# 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
generateEncryptionKey: false
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
# 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. Missing keys are simply left unset (they are optional), so only
# provide the ones your setup needs.
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
+1
View File
@@ -6,3 +6,4 @@ playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
e2e/help/media.env
+2 -1
View File
@@ -1,9 +1,10 @@
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/.tmp/state.json'
const stateFile = E2E_STATE_FILE
// 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
+114
View File
@@ -0,0 +1,114 @@
/**
* 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
@@ -0,0 +1,180 @@
# 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
@@ -0,0 +1,601 @@
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
@@ -0,0 +1,642 @@
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
@@ -0,0 +1,790 @@
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
@@ -0,0 +1,421 @@
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
@@ -0,0 +1,357 @@
/**
* 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
@@ -0,0 +1,364 @@
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
@@ -0,0 +1,464 @@
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
@@ -0,0 +1,30 @@
# 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
@@ -0,0 +1,61 @@
// Moves the help-center media from the staging directory into public/help-media/,
// re-encoding the PNG captures to WebP on the way.
//
// Captures are 2x (3840px wide for a full frame). The panel shows a step
// picture at ~600px and a full frame at ~1000px, on HiDPI displays, so 1440px
// and 1920px keep them sharp at a fraction of the bytes. WebP rather than PNG
// on purpose: the service worker precaches `png`, and help pictures must not
// ride along in every visitor's first download.
//
// Usage: node e2e/help/promote.mjs [--dry]
import sharp from 'sharp'
import { readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
import path from 'node:path'
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
const DEST = path.join(process.cwd(), 'public', 'help-media')
const STEP_WIDTH = 1440
const FRAME_WIDTH = 1920
const dry = process.argv.includes('--dry')
if (!existsSync(SRC)) {
console.error(`No media in ${SRC} — run \`npm run help:media\` first.`)
process.exit(1)
}
let before = 0
let after = 0
let files = 0
async function picture(src, dest, width) {
const bytes = statSync(src).size
before += bytes
if (dry) {
console.log(` ${path.relative(SRC, src)} → ${path.relative(DEST, dest)} (${(bytes / 1024).toFixed(0)} KB)`)
return
}
const img = sharp(src)
const meta = await img.metadata()
const pipeline = (meta.width ?? 0) > width ? img.resize({ width }) : img
await pipeline.webp({ quality: 84, effort: 5 }).toFile(dest)
after += statSync(dest).size
files++
}
mkdirSync(DEST, { recursive: true })
for (const dir of readdirSync(SRC)) {
const from = path.join(SRC, dir)
if (!statSync(from).isDirectory() || dir.startsWith('.')) continue
const to = path.join(DEST, dir)
mkdirSync(to, { recursive: true })
for (const file of readdirSync(from)) {
if (path.extname(file) !== '.png') continue
const width = file.startsWith('step-') ? STEP_WIDTH : FRAME_WIDTH
await picture(path.join(from, file), path.join(to, file.replace(/\.png$/, '.webp')), width)
}
}
const mb = (n) => (n / 1024 / 1024).toFixed(1)
console.log(dry ? `dry run: ${mb(before)} MB in staging` : `promoted ${files} files: ${mb(before)} MB → ${mb(after)} MB in ${path.relative(process.cwd(), DEST)}`)
+58
View File
@@ -0,0 +1,58 @@
// `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
@@ -0,0 +1,344 @@
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
@@ -0,0 +1,770 @@
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
@@ -0,0 +1,496 @@
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
@@ -0,0 +1,736 @@
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
@@ -0,0 +1,363 @@
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
@@ -0,0 +1,550 @@
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
@@ -0,0 +1,616 @@
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
@@ -0,0 +1,758 @@
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
@@ -0,0 +1,809 @@
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
@@ -0,0 +1,610 @@
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 files' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The list opens itself once the upload lands.
only(p => card(p).getByRole('button', { name: '1 files' })),
// 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
@@ -0,0 +1,519 @@
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
@@ -0,0 +1,760 @@
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
@@ -0,0 +1,200 @@
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
@@ -0,0 +1,701 @@
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 flight(s) 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
@@ -0,0 +1,252 @@
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
@@ -0,0 +1,587 @@
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])
})
}
+8 -9
View File
@@ -1,7 +1,6 @@
import { test, clearNotices, expect } from './shot'
import { test, clearNotices, expect, seed } from './shot'
import { PICTURE_DAY } from '../help/guide'
import type { Page, Locator } from '@playwright/test'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* Collab surfaces, one capture each.
@@ -15,13 +14,10 @@ import path from 'node:path'
* So each capture targets its own card element rather than clicking a tab.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The panel card containing a given piece of seeded content — see cardClass in
* CollabPanel.tsx:20.
* 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
@@ -30,12 +26,15 @@ const seed = JSON.parse(
*/
function card(page: Page, contains: string): Locator {
return page
.locator('div.bg-surface-card.rounded-2xl')
.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()
+1 -6
View File
@@ -1,6 +1,4 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { test, clearNotices, expect, seed } from './shot'
/**
* Detail pages and the surfaces that need a couple of clicks to reach.
@@ -10,9 +8,6 @@ import path from 'node:path'
* the run instead of producing a screenshot of the wrong screen.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test('collection detail', async ({ page, shot }) => {
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
+1 -6
View File
@@ -1,6 +1,4 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { test, clearNotices, expect, seed } from './shot'
/**
* Modals and dialogs.
@@ -11,9 +9,6 @@ import path from 'node:path'
* silently produce a screenshot of the page behind it.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
+13 -7
View File
@@ -1,6 +1,4 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { test, clearNotices, seed } from './shot'
/**
* Top-level navigable surfaces. One capture per route; anything that needs a
@@ -11,9 +9,6 @@ import path from 'node:path'
* for which wiki page consumes which file.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
@@ -28,6 +23,14 @@ test('dashboard', async ({ page, shot }) => {
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')
})
@@ -62,6 +65,9 @@ test('in-app help', async ({ page, shot }) => {
})
test('files', async ({ page, shot }) => {
await page.goto(`/trips/${seed.tripId}/files`)
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
@@ -0,0 +1,234 @@
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()
})
+12 -7
View File
@@ -1,6 +1,4 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { test, clearNotices, seed } from './shot'
/**
* Trip-planner tabs and dialogs.
@@ -11,9 +9,6 @@ import path from 'node:path'
* current wiki ended up with screenshots the text contradicts.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
test.beforeEach(async ({ page }) => {
await page.goto(`/trips/${seed.tripId}`)
@@ -21,7 +16,7 @@ test.beforeEach(async ({ page }) => {
})
async function openTab(page: import('@playwright/test').Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
await page.waitForTimeout(700)
}
@@ -35,6 +30,16 @@ test('lists — packing', async ({ page, shot }) => {
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')
+4 -4
View File
@@ -2,6 +2,7 @@ import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
/**
* Populates the throwaway E2E database with the demo trip before any screenshot
@@ -17,7 +18,7 @@ setup('seed the demo trip', async ({ page, playwright }) => {
// members — see the comment in seed.ts on why they must not share one.
const result = await seedDemoData(page.request, token =>
playwright.request.newContext({
baseURL: 'http://localhost:5173',
baseURL: E2E_BASE_URL,
// MUST be explicit: newContext otherwise picks up the project's
// storageState, i.e. the admin's trek_session cookie — and the server
// reads the cookie BEFORE the Authorization header
@@ -31,7 +32,6 @@ setup('seed the demo trip', async ({ page, playwright }) => {
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
const dir = path.join(process.cwd(), 'e2e', '.tmp')
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
mkdirSync(path.join(process.cwd(), 'e2e', '.tmp'), { recursive: true })
writeFileSync(path.join(process.cwd(), E2E_SEED_FILE), JSON.stringify(result, null, 2))
})
+27 -20
View File
@@ -1,5 +1,6 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
import { at, day, ordinal, short } from '../dates'
/**
* Demo data for the documentation screenshots.
@@ -15,15 +16,16 @@ import type { APIRequestContext } from '@playwright/test'
* 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 sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
* have something to show.
* - 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: '2026-09-12',
end_date: '2026-09-21',
start_date: day(-9),
end_date: day(0),
currency: 'JPY',
reminder_days: 3,
}
@@ -64,15 +66,15 @@ const PLACES = [
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
expense_date: day(-6), note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
expense_date: day(-9), note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: '2026-09-13' },
expense_date: day(-8) },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: '2026-09-17' },
expense_date: day(-4) },
]
const PACKING = [
@@ -81,10 +83,15 @@ const PACKING = [
{ 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: '2026-08-15', priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
{ 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 {
@@ -212,8 +219,8 @@ export async function seedDemoData(
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
title: 'LH716 FRA → HND',
type: 'flight',
reservation_time: '2026-09-12T13:05:00',
reservation_end_time: '2026-09-13T08:25:00',
reservation_time: at(-9, '13:05'),
reservation_end_time: at(-8, '08:25'),
confirmation_number: 'X7K2QP',
status: 'confirmed',
location: 'Frankfurt Airport',
@@ -222,10 +229,10 @@ export async function seedDemoData(
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
local_date: '2026-09-12', local_time: '13:05' },
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: '2026-09-13', local_time: '08:25' },
local_date: day(-8), local_time: '08:25' },
],
}).catch(() => {})
@@ -293,7 +300,7 @@ export async function seedDemoData(
const pollRes = await api.post(`${collab}/polls`, {
data: {
question: 'Which day should we keep free for Nara?',
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
options: [short(-5), short(-4), short(-2)],
multiple: false,
},
})
@@ -308,13 +315,13 @@ export async function seedDemoData(
}).catch(() => {})
const conversation: Array<[string, string]> = [
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
['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', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
['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."],
['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)
+29 -1
View File
@@ -1,6 +1,7 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync } from 'node:fs'
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`).
@@ -56,6 +57,20 @@ async function hideDevOnlyUi(page: Page): Promise<void> {
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) {}
@@ -68,6 +83,12 @@ export class Shot {
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()
@@ -104,6 +125,13 @@ export class Shot {
* 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
+22
View File
@@ -0,0 +1,22 @@
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)
})
+10 -3
View File
@@ -12,7 +12,10 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const dbFile = path.join(here, '.tmp', 'e2e.db')
// 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 serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
@@ -20,14 +23,18 @@ for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
}
// Build once (no watcher) — the resulting process is a single killable node.
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
// Several runs on their own port pairs share one dist, so they build it once
// beforehand and set E2E_SERVER_PREBUILT: a rebuild under a running server
// swaps its files mid-flight.
if (!process.env.E2E_SERVER_PREBUILT) execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
// playwright.config.ts picks the port; 3001 is the historical default.
PORT: process.env.E2E_API_PORT || '3001',
NODE_ENV: 'development',
}
+3
View File
@@ -8,6 +8,9 @@
<!-- 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" />
+40 -38
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.0",
"version": "4.3.3",
"private": true,
"type": "module",
"scripts": {
@@ -23,6 +23,8 @@
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"help:media": "node e2e/help/run.mjs",
"help:media:promote": "node e2e/help/promote.mjs",
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
@@ -33,74 +35,74 @@
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@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.2.7",
"@fontsource/poppins": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.1.2",
"@simplewebauthn/browser": "^13.3.0",
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"axios": "^1.20.0",
"dexie": "^4.4.6",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"heic-to": "^1.5.2",
"iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"marked": "^18.0.0",
"marked": "^18.0.13",
"plyr": "^3.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"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.2",
"react-router": "^7.18.4",
"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.3.6",
"zustand": "^5.0.14"
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@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",
"@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",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^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.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"jsdom": "^29.1.1",
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prettier": "^3.9.8",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1",
"sharp": "^0.35.4",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.11"
}
+71 -6
View File
@@ -9,6 +9,34 @@ 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,
@@ -19,7 +47,7 @@ export default defineConfig({
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
baseURL: E2E_BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -32,7 +60,7 @@ export default defineConfig({
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
dependencies: ['setup'],
},
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
@@ -43,18 +71,53 @@ export default defineConfig({
{
name: 'seed',
testMatch: /seed\.setup\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
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/.tmp/state.json',
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'],
},
],
@@ -63,7 +126,8 @@ export default defineConfig({
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
port: API_PORT,
env: { E2E_API_PORT: String(API_PORT) },
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
@@ -71,7 +135,8 @@ export default defineConfig({
},
{
command: 'npm run dev',
port: 5173,
port: WEB_PORT,
env: { TREK_DEV_PORT: String(WEB_PORT), TREK_DEV_API: `http://localhost:${API_PORT}` },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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