Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 2654e651c7 style(i18n): wrap the long special theme hints the way Prettier wants 2026-10-18 13:04:19 +02:00
Maurice ce1fa68c84 feat(appearance): Halloween special theme with 3D decor and a costumed mascot
Adds a Special Themes row to Settings > Appearance > Theme. Halloween is a
scheme on the existing data-scheme axis that is always dark: the applier and
the boot script force .dark while it is on, and the color mode control is
dimmed with a hint.

- Palette for the whole app (surfaces, dashboard, Vacay glass, Journey,
  mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css.
- Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on:
  fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop
  on a thread and flee when poked, a pumpkin patch and an ember running along
  the navbar. Figures are pre-rendered 3D sprite sheets.
- Nothing is drawn over a map: the Atlas gets no decor, the trip planner only
  dresses its side panels. Marker clusters turn into lanterns.
- The 3D mascot plays its own Halloween clips in every scene (witch hat, a
  spider on the brim, a lit pumpkin beside it); the SVG fallback wears the
  same costume.
- Reduced motion stills everything, transparency off drops the fog, shared
  pages never show it.
- Navbar, bottom nav, notifications, Atlas and Collections read the dark flag
  off the .dark class instead of settings.dark_mode, so a forced dark theme
  reaches them.
2026-10-18 12:59:36 +02:00
6025 changed files with 77574 additions and 552950 deletions
-3
View File
@@ -26,8 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
*.sgr binary
*.gz binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+131 -326
View File
@@ -9,73 +9,23 @@ permissions:
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job names read "<package>: <what>", so the checks list on a PR sorts by
# package. What each job holds:
#
# Repo: Changed Areas decides what the change needs to run (below)
# Shared: i18n Parity every locale has en's files and keys
# Shared: Coverage shared's tests with coverage
# Server: Types builds shared and the server, typechecks shared,
# the server's src, tests and scripts, smokes the
# production require chain, the strict ratchet,
# the generated entities and Kysely types
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
# Prettier, the plugin facts, the DB call graph,
# the shared ratchet code, the source ratchets
# (size, boundaries, dialect, tx, test SQL, test
# mocks, hand-built services, query API, MCP zod,
# error envelopes, response contracts), open
# request schemas, knip
# Server: Tests n/7 one slice of the server suite, with coverage
# Server: Coverage merges the slices, checks the per-domain
# thresholds, uploads lcov
# Server: S3 Contract (MinIO)
# the storage driver against MinIO
# Server: Postgres Probe the repository SQL against Postgres
# Client: Types & Checks typecheck and the strict ratchet; then the page
# pattern and the source ratchets (RTL, size,
# Prettier, layers, offline path, duplication,
# desktop/phone pairs, skips, i18n keys, theme
# tokens)
# Client: Lint ESLint (typed), errors and warning counts
# Client: Tests n/7 one slice of the client suite, with coverage
# Client: Coverage merges the slices, checks the thresholds,
# uploads lcov
# Client: Bundle the production build, one chunk per map engine
# Client: End-to-End the app in Chromium and WebKit
# Plugin SDK: Types & Coverage
# Repo: Docker Image builds the image and boots it until ready
# Repo: Helm Chart & Compose
# Repo: Sonar Scan SonarCloud analysis, not required
# ci-ok the one required check (below)
#
# Job layout:
# - Everything starts right after changes: the gates and the tests run side
# by side, so a run takes as long as its slowest job and not the gates
# plus the tests. The tests used to wait for the gates, which put ~9
# minutes in front of every test result. The gates were one job per
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
# split where the time is (the server's typechecks, the client's lint),
# so neither outlasts a test shard.
# - The client and server suites run in shards (client-tests,
# server-tests), each running a slice with coverage and uploading a blob
# report; client-coverage and server-coverage merge the blobs, check the
# thresholds against the whole and upload lcov. A red shard is usually a
# failing test, a red merge job usually a threshold. None of the other
# test jobs feeds the scan.
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
# does not say in which order it starts queued ones. A code change starts
# the 14 shards and about a dozen other jobs; seven of those (parity, the
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
# a minute or two, so what queues at the start waits about that long. The
# jobs that run for minutes (shards, gates, e2e, the image) must not
# outnumber the 20 slots, or one of them waits for another to finish and
# the run grows by a whole job: a layout with six gate jobs did exactly
# that. Check the count before adding a long job or a shard.
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
# failing must stop the scan — a missing lcov file would make every line
# of that package report as uncovered, and the dashboard would show a
# collapse that did not happen.
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and the repository
# statements it reaches against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
# secrets, so the two jobs that need them skip themselves: s3-contract
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
@@ -88,7 +38,7 @@ permissions:
# required check that never reports blocks the PR for good.
# - docker-image builds the Dockerfile and boots the result, and
# deploy-manifests lints and renders the Helm chart and checks the
# compose files. Both run beside the gates rather than after them.
# compose files. Both run beside the preflight rather than after it.
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
# check the branch ruleset requires. It fails when any of them failed or
# was cancelled and passes when they passed or were skipped (by changes,
@@ -119,7 +69,7 @@ concurrency:
jobs:
changes:
name: 'Repo: Changed Areas'
name: Changed Areas
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -194,8 +144,36 @@ jobs:
node scripts/ci/changed-areas.mjs --all
fi
plugin-facts:
name: Plugin Permission Facts
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: 'Shared: i18n Parity'
name: i18n Key Parity
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -211,8 +189,8 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
server-types:
name: 'Server: Types'
server-quality:
name: Server Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -261,11 +239,8 @@ jobs:
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
# catches it.
run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
@@ -282,36 +257,6 @@ jobs:
# migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types
server-lint-checks:
name: 'Server: Lint & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -319,19 +264,8 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
- name: Formatting check
# Prettier over src/ and tests/: a file outside
# scripts/format-baseline.json must be formatted, and the list only
@@ -425,12 +359,12 @@ jobs:
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-types-checks because it needs every workspace's
# dependencies, which the client jobs do not install.
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
run: npm run lint:knip
client-types-checks:
name: 'Client: Types & Checks'
client-quality:
name: Client Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -446,7 +380,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -461,6 +395,15 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd client && npm run lint:warnings
- name: Page pattern check
run: cd client && npm run lint:pages
@@ -524,12 +467,15 @@ jobs:
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
run: cd client && npm run theme:lint
client-lint:
name: 'Client: Lint'
plugin-sdk-quality:
name: Plugin SDK Types
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
@@ -537,40 +483,20 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings
- name: Typecheck
run: npm run typecheck
client-bundle:
name: 'Client: Bundle'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client Bundle
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -581,7 +507,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -597,11 +523,10 @@ jobs:
run: cd client && npm run check:gl-split
client-e2e:
name: 'Client: End-to-End'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client End-to-End
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -655,17 +580,17 @@ jobs:
retention-days: 7
s3-contract:
name: 'Server: S3 Contract (MinIO)'
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: changes
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. A run Dependabot triggers gets
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
# wall. Pushes and manual dispatches by anyone else do have secrets, so
# only those two are filtered out.
# only those two are filtered out. (The implicit success() on this
# expression also keeps the job skipped when a preflight fails.)
if: >-
needs.changes.outputs.code == 'true' &&
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
@@ -681,14 +606,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
@@ -746,10 +671,10 @@ jobs:
# artifact seeds the file and a change that fixes a statement can lower it
# without a local Postgres.
postgres-probe:
name: 'Server: Postgres Probe'
name: Postgres Probe
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: changes
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
if: needs.changes.outputs.code == 'true'
services:
postgres:
@@ -802,20 +727,11 @@ jobs:
if-no-files-found: ignore
retention-days: 14
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
# without the thresholds and the coverage reports: a slice's blob carries
# its coverage to server-coverage, which checks the whole. fail-fast is off,
# so one red run shows every failing slice and not just the first.
server-tests:
name: 'Server: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
server-coverage:
name: Server Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -828,63 +744,20 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd server && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: server-blob-${{ matrix.shard }}
path: server/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
server-coverage:
name: 'Server: Coverage'
needs: server-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: server-blob-*
path: server/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd server && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -893,18 +766,11 @@ jobs:
path: server/coverage/
retention-days: 1
# The client suite in seven slices, the same way as server-tests. The two
# suites cost about the same per slice at seven each (~4-6 minutes).
client-tests:
name: 'Client: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
client-coverage:
name: Client Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -919,56 +785,13 @@ jobs:
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd client && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: client-blob-${{ matrix.shard }}
path: client/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
client-coverage:
name: 'Client: Coverage'
needs: client-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: client-blob-*
path: client/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd client && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -978,11 +801,10 @@ jobs:
retention-days: 1
shared-coverage:
name: 'Shared: Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Shared Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -1005,14 +827,11 @@ jobs:
path: shared/coverage/
retention-days: 1
# Typecheck and tests in one job: each takes seconds, and a runner of its
# own would cost more in setup than it saves.
plugin-sdk:
name: 'Plugin SDK: Types & Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
@@ -1031,9 +850,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
run: npm run test:coverage
@@ -1045,7 +861,7 @@ jobs:
retention-days: 1
scan:
name: 'Repo: Sonar Scan'
name: Scan
runs-on: ubuntu-24.04
timeout-minutes: 45
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
@@ -1055,18 +871,9 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The gates run beside the tests now,
# so they are listed here too, and a failed gate still stops the scan.
needs:
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-coverage
- client-coverage
- shared-coverage
- plugin-sdk
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
@@ -1127,7 +934,7 @@ jobs:
# reached dev unseen. It is separate from the vulnerability scan on purpose,
# so a newly published CVE cannot hide a broken build or the other way round.
docker-image:
name: 'Repo: Docker Image'
name: Docker Image
needs: changes
if: needs.changes.outputs.image == 'true'
runs-on: ubuntu-24.04
@@ -1204,7 +1011,7 @@ jobs:
# the output checked against the Kubernetes API schemas. Both tools come
# from their release pages, pinned by version and checksum.
deploy-manifests:
name: 'Repo: Helm Chart & Compose'
name: Helm Chart & Compose
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-24.04
@@ -1275,21 +1082,19 @@ jobs:
ci-ok:
needs:
- changes
- plugin-facts
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-quality
- client-quality
- plugin-sdk-quality
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-tests
- server-coverage
- client-tests
- client-coverage
- shared-coverage
- plugin-sdk
- plugin-sdk-coverage
- docker-image
- deploy-manifests
# always(): without it a failed dependency would skip this job, and the
-2
View File
@@ -55,8 +55,6 @@ coverage
coverage-*/
*.lcov
.nyc_output
# Blob reports of a sharded vitest run (test.yml)
.vitest-reports
# Cache
.npm
+1 -3
View File
@@ -134,10 +134,8 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-lint-checks), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.4
version: 4.3.3
description: Minimal Helm chart for TREK app
appVersion: "4.3.4"
appVersion: "4.3.3"
+2 -2
View File
@@ -102,8 +102,8 @@ env:
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
+1 -1
View File
@@ -21,7 +21,7 @@ in a script.
| 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` |
| Loops | `StepAction.clip`, `dragLoop`, `promote.mjs` | stills are the format. A step whose motion a still cannot show (a route being drawn, a shaping point pulled, a flyover, the compass turning) also gets a short silent loop: frames of the still's window with the step's number and a drawn pointer, joined into `step-<n>.loop.webp` (1440 px, 80 ms a frame, the last held 1.6 s). Register the step under `clips` in the context row; only those are recorded. A browser drag (day plan, lists, road trip) stays a still with its arrow, since a recording leaves the dragged row out. The panel plays it in place of the still, except under reduced motion. No videos with sound, no screen recordings |
| 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
+12 -135
View File
@@ -2,7 +2,6 @@ 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 { deleteUser, resetDefaults } from './fixtures-admin'
import { toggleAddon } from './external'
import { adminContext, adminTabContexts, adminGuides } from '../../src/help/contexts/admin'
import type { HelpGuide } from '../../src/help/types'
@@ -45,12 +44,6 @@ const block = (page: Page, title: string) => page.locator('h3').filter({ hasText
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()
/** A labelled field of the Map card on User Defaults: its label and everything under it. */
const mapField = (page: Page, label: string) => section(page, /^Map$/).locator('label').filter({ hasText: label }).first().locator('xpath=..')
/** The Map engine row's label, which also holds its reset link once the engine is set. */
const engineLabel = (page: Page) => section(page, /^Map$/).locator('label').filter({ hasText: 'Map engine' }).first()
/** The TREK Places API card at the top of API Keys, found by its pill. */
const trekApiCard = (page: Page) => page.getByText('Recommended default', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"rounded-[14px]")][1]')
/** 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]')
@@ -104,8 +97,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
// Back to the seed's two accounts, so the Users hero and the next run look the same.
cleanup: p => deleteUser(p.request, 'lena'),
},
'edit-user': {
guide: guide('edit-user'),
@@ -190,11 +181,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
// ── User Defaults ────────────────────────────────────────────────────────
'default-user-settings': {
guide: guide('default-user-settings'),
'default-map': {
guide: guide('default-map'),
start: opener('admin-defaults'),
steps: [
only(p => section(p, 'Default User Settings')),
only(p => section(p, /^Map$/)),
{
// The reset link only exists next to a field an admin has set.
prepare: async p => {
@@ -206,72 +197,10 @@ const SCRIPTS: Record<string, GuideScript> = {
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 expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
],
cleanup: p => resetDefaults(p.request, ['dark_mode']),
},
'default-map': {
guide: guide('default-map'),
start: opener('admin-defaults'),
steps: [
// The field and the CARTO key under it; the 16:10 frame takes in both.
only(p => mapField(p, 'Map Template')),
{
// Mapbox draws every row the step names (style, token, 3D, quality).
prepare: async p => {
await section(p, /^Map$/).getByRole('button', { name: 'Mapbox (3D)', exact: true }).click()
await expect(section(p, /^Map$/).getByText('High-quality mode', { exact: true })).toBeVisible({ timeout: 10_000 })
await settle(p)
},
target: p => section(p, /^Map$/).getByRole('button', { name: 'Mapbox (3D)', exact: true }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]'),
},
{
// The engine was set in step 2, so its label now carries the reset link.
prepare: async p => {
await expect(engineLabel(p).locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
},
target: p => engineLabel(p),
act: async p => {
await engineLabel(p).locator('button').filter({ hasText: /^reset$/ }).click()
await expect(engineLabel(p).locator('button').filter({ hasText: /^reset$/ })).toHaveCount(0, { timeout: 10_000 })
await settle(p)
},
},
],
cleanup: p => resetDefaults(p.request, ['map_provider', 'mapbox_style', 'maplibre_style']),
},
'own-routing-engine': {
guide: guide('own-routing-engine'),
start: opener('admin-defaults'),
// Typed, never committed: the field saves on blur only when the value changed,
// and emptying it first leaves the saved value (none) as it was.
steps: [
{
prepare: async p => {
await typeInto(p, p.getByLabel('Own routing engine', { exact: true }), 'https://osrm.example.org')
await beat(p, 300)
},
target: p => p.getByLabel('Own routing engine', { exact: true }).locator('xpath=..'),
act: async p => {
await p.getByLabel('Own routing engine', { exact: true }).fill('')
await p.getByLabel('Own routing engine', { exact: true }).blur()
},
},
{
prepare: async p => {
await typeInto(p, p.getByLabel('Own Valhalla instance', { exact: true }), 'https://valhalla.example.org')
await beat(p, 300)
},
target: p => p.getByLabel('Own Valhalla instance', { exact: true }).locator('xpath=..'),
act: async p => {
await p.getByLabel('Own Valhalla instance', { exact: true }).fill('')
await p.getByLabel('Own Valhalla instance', { exact: true }).blur()
},
},
],
},
// ── Personalization ──────────────────────────────────────────────────────
@@ -280,19 +209,15 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('admin-config'),
steps: [
{
// The composer with the name typed and the tick beside it.
prepare: async p => {
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)
},
target: p => p.getByPlaceholder('Template name (e.g. Beach Holiday)').locator('xpath=..'),
act: async p => {
// Leave the seed as it is: Cancel beside the field discards the draft.
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
await input.locator('xpath=..').getByRole('button', { name: 'Cancel' }).click()
// 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)
},
},
@@ -325,12 +250,10 @@ const SCRIPTS: Record<string, GuideScript> = {
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
},
},
// Edit and Delete are always on the row; the pointer rests on Edit.
{
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()
.locator('xpath=ancestor::div[contains(@class,"group")][1]').getByRole('button', { name: 'Edit' }).hover()
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().hover()
},
},
],
@@ -378,15 +301,6 @@ const SCRIPTS: Record<string, GuideScript> = {
only(p => section(p, 'Require two-factor authentication (2FA)')),
],
},
'admin-passkeys': {
guide: guide('admin-passkeys'),
start: opener('admin-settings'),
steps: [
// Not flipped: the switch saves at once, and the seed keeps passkeys off.
only(p => section(p, 'Passkey login').getByRole('button', { name: 'Enable passkey login' }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
only(p => section(p, 'Passkey login')),
],
},
oidc: {
guide: guide('oidc'),
start: opener('admin-settings'),
@@ -399,15 +313,14 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('instance-keys'),
start: opener('admin-settings'),
steps: [
only(p => trekApiCard(p)),
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)
},
// The whole Google block: key, Test and the fold opened under it.
target: p => block(p, 'Google Maps API Key'),
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
},
only(p => block(p, 'Unsplash API Key')),
],
@@ -416,8 +329,8 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('places-transit'),
start: opener('admin-settings'),
steps: [
only(p => block(p, 'Transit Provider')),
only(p => block(p, 'Place search provider')),
only(p => block(p, 'Transit Provider')),
],
},
'file-types': {
@@ -427,29 +340,6 @@ const SCRIPTS: Record<string, GuideScript> = {
only(p => section(p, 'Allowed File Types')),
],
},
'rotate-jwt-secret': {
guide: guide('rotate-jwt-secret'),
start: opener('admin-settings'),
steps: [
{
target: p => section(p, 'Danger Zone').getByRole('button', { name: 'Rotate', exact: true }),
act: async p => {
await section(p, 'Danger Zone').getByRole('button', { name: 'Rotate', exact: true }).click()
await expect(modal(p)).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).locator('> [role="presentation"], > div').first(),
// Never confirmed: rotating would sign the run itself out.
hover: async p => { await modal(p).getByRole('button', { name: 'Rotate & Log out' }).hover() },
act: async p => {
await modal(p).getByRole('button', { name: 'Cancel' }).click()
await expect(modal(p)).toHaveCount(0)
},
},
],
},
// ── Addons ───────────────────────────────────────────────────────────────
'toggle-addon': {
@@ -562,8 +452,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
only(p => section(p, /^Categories$/)),
// Nothing changed, so the button is still greyed; the bar is what the step names.
only(p => p.getByRole('button', { name: 'Save changes' }).locator('xpath=..')),
only(p => section(p, /^Health$/)),
],
},
@@ -584,17 +472,6 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => section(p, 'Admin Ntfy')),
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
// The admin matrix: the card whose title is the tab's own name.
only(p => section(p, /^Notifications$/)),
],
},
'notification-defaults': {
guide: guide('notification-defaults'),
start: opener('admin-notifications'),
steps: [
only(p => p.locator('#notif-defaults')),
// Hovered, not clicked: the tooltip names the next state and the seed keeps its defaults.
only(p => p.locator('#notif-defaults button[data-state]').first()),
],
},
@@ -603,8 +480,8 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('mcp-tokens-admin'),
start: opener('admin-mcp-tokens'),
steps: [
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
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]')),
],
},
+1 -164
View File
@@ -12,8 +12,7 @@ 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 tour left out
* in the Tours tab is counted again in `cleanup`. The countries
* 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.
*/
@@ -209,33 +208,6 @@ async function regionTooltipBox(page: Page, country: string): Promise<Box> {
return box
}
/** The Places badge in a country's detail card ("7 Places"), which opens the places dialog. */
const placesBadge = (page: Page, country: string) =>
detailCard(page, country).getByRole('button', { name: /^\d+ Places?$/ })
/** The dialog of a country's places, named after the country. */
const placesDialog = (page: Page, country: string) => page.getByRole('dialog', { name: country })
/** The Tours tab of the panel (Tours addon), and the tour activities it shows. */
const toursTab = (page: Page) => panel(page).getByRole('button', { name: 'Tours', exact: true })
const tourPane = (page: Page) => page.getByTestId('atlas-tour-activity')
/** The Period pill: its name is the sr-only "Period: " before the value. */
const periodPill = (scope: Locator) => scope.getByRole('button', { name: /^Period:/ })
/** The open list of a pill, portalled to the body and named after what it picks. */
const pillList = (page: Page, label: 'Period' | 'Type') => page.getByRole('group', { name: label, exact: true })
const showToursButton = (page: Page) => tourPane(page).getByRole('button', { name: 'Show tours', exact: true })
const tourDialog = (page: Page) => page.getByRole('dialog', { name: 'My tour activities' })
const firstTourRow = (page: Page) => tourDialog(page).getByTestId('tour-activity-list').locator('> li').first()
/** The tours the user has left out of their activities, by place id. */
async function excludedTours(page: Page): Promise<Set<number>> {
const res = await page.request.get('/api/tours/activity-stats')
if (!res.ok()) throw new Error(`could not read the tour activities: ${res.status()}`)
const { entries } = (await res.json()) as { entries: { place_id: number; excluded: boolean }[] }
return new Set(entries.filter(e => e.excluded).map(e => e.place_id))
}
/** What was left out before the tour guide ran, so `cleanup` counts exactly its own change again. */
let excludedBefore = new Set<number>()
const SCRIPTS: Record<string, GuideScript> = {
'mark-country': {
guide: guide('mark-country'),
@@ -311,40 +283,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'country-places': {
guide: guide('country-places'),
start: openAtlas,
steps: [
{
prepare: async p => { await searchCountry(p, 'Japan') },
target: p => countryHit(p, 'Japan'),
act: async p => {
await pickCountry(p, 'Japan')
await expect(placesBadge(p, 'Japan')).toBeVisible()
},
},
{
target: p => placesBadge(p, 'Japan'),
act: async p => {
await placesBadge(p, 'Japan').click()
await expect(placesDialog(p, 'Japan')).toBeVisible()
await settle(p)
},
},
{
// The whole dialog: the step names the groups by trip, the search
// above them when the list is long, and the trip heading to click.
target: p => placesDialog(p, 'Japan'),
hover: p => placesDialog(p, 'Japan').getByRole('button', { name: /Autumn in Japan/ }).first().hover(),
},
],
cleanup: async p => {
if (await placesDialog(p, 'Japan').count()) {
await placesDialog(p, 'Japan').getByRole('button', { name: 'Close', exact: true }).click()
}
},
},
'planned-countries': {
guide: guide('planned-countries'),
start: openAtlas,
@@ -544,103 +482,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'tour-activity-stats': {
guide: guide('tour-activity-stats'),
start: async p => {
await openAtlas(p)
excludedBefore = await excludedTours(p)
},
steps: [
{
target: toursTab,
act: async p => {
await toursTab(p).click()
// The tab reads live once it is first shown.
await expect(tourPane(p).getByTestId('atlas-tour-activity-distance')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The two pills with the period list open under the first: the step
// names the periods, and they only show while the list is open.
prepare: async p => {
await periodPill(tourPane(p)).click()
await expect(pillList(p, 'Period')).toBeVisible()
await spanOver(p, [await boxOf(periodPill(tourPane(p)).locator('xpath=..')), await boxOf(pillList(p, 'Period'))])
},
target: span,
hover: p => pillList(p, 'Period').getByRole('button', { name: 'Last 12 months', exact: true }).hover(),
act: async p => {
await clearSpan(p)
// All time keeps every tour of the seed in the sums the next steps read.
await pillList(p, 'Period').getByRole('button', { name: 'All time', exact: true }).click()
await expect(pillList(p, 'Period')).toHaveCount(0)
},
},
{
// The distance with its count, the climb and the type columns are
// siblings without a box of their own, so the ring gets one over them.
prepare: async p => {
const pane = tourPane(p)
const parts = [
pane.getByTestId('atlas-tour-activity-distance').locator('xpath=..'),
pane.getByTestId('atlas-tour-activity-ascent'),
pane.getByTestId('atlas-tour-activity-descent'),
...(await pane.locator('[data-testid^="atlas-tour-activity-type-"]').all()),
]
const boxes: Box[] = []
for (const part of parts) {
const box = (await part.count()) ? await part.first().boundingBox() : null
if (box) boxes.push(box)
}
await spanOver(p, boxes)
},
target: span,
// The badge at the distance's corner names the tour count in its tooltip.
hover: async p => {
const count = tourPane(p).getByTestId('atlas-tour-activity-count')
await ((await count.count()) ? count : tourPane(p).getByTestId('atlas-tour-activity-distance')).hover()
},
act: clearSpan,
},
{
// The side column: the Planned and Recorded badges, what does not
// count yet, and the i whose tooltip states the rule.
target: p => showToursButton(p).locator('xpath=..'),
hover: p => tourPane(p).getByRole('button', { name: 'My tour activities', exact: true }).hover(),
},
{
target: showToursButton,
act: async p => {
await showToursButton(p).click()
await expect(tourDialog(p).getByTestId('tour-activity-list')).toBeVisible()
await settle(p)
},
},
{
target: p => firstTourRow(p),
hover: p => firstTourRow(p).locator('[data-testid^="tour-activity-toggle-"]').hover(),
act: async p => {
// Found again by its own id afterwards: the list may sort a left-out tour elsewhere.
const id = await firstTourRow(p).getAttribute('data-testid')
await firstTourRow(p).getByRole('button', { name: 'Leave out', exact: true }).click()
const row = tourDialog(p).getByTestId(id ?? '')
await expect(row.getByText('Left out', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(row.getByRole('button', { name: 'Count again', exact: true })).toBeEnabled()
await settle(p)
},
},
],
cleanup: async p => {
// Count again every tour this guide left out, by the route the button uses.
for (const placeId of await excludedTours(p)) {
if (!excludedBefore.has(placeId)) {
await p.request.put('/api/tours/activity-stats/exclusions', { data: { place_id: placeId, excluded: false } })
}
}
},
},
'dawarich-countries': {
guide: guide('dawarich-countries'),
start: async p => {
@@ -768,10 +609,6 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureAtlasFixtures(request)
// Planned and recorded tours of several types on past, dated days, for the
// Tours tab of the panel. Loaded lazily: the file belongs to the Tours guides.
const { ensureToursFixtures } = await import('./tour-fixtures')
await ensureToursFixtures(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)
+10 -223
View File
@@ -1,13 +1,8 @@
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, nudgeClock, VIEWPORT, OUT_DIR, type GuideScript } from './guide'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
import { ensureExtraTrips } from './fixtures'
import { ensureToursFixtures } from './tour-fixtures'
import {
ensureTourTargetTrip, ensureSecondList, deleteList, sendInviteToMe, withdrawInvitedList,
TOUR_TARGET_TRIP, SECOND_LIST, INVITED_LIST,
} from './fixtures-collections'
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
import type { HelpGuide } from '../../src/help/types'
@@ -18,17 +13,12 @@ import type { HelpGuide } from '../../src/help/types'
* 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. The two Tours guides at
* the end work on the tour the Tours fixtures keep in "Dolomites ideas".
* into a trip comes out again in that guide's cleanup.
*/
const KYOTO = 'Kyoto shortlist'
const LISBON = 'Lisbon coffee'
const JAPAN = 'Autumn in Japan'
/** The list the Tours fixtures keep a whole tour in. */
const DOLOMITES = 'Dolomites ideas'
/** The two temples the duplicate guide copies into a second list. */
const DUPLICATED = ['Kinkaku-ji Temple', 'Kiyomizu-dera Temple']
/**
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
@@ -69,21 +59,9 @@ const importTrip = (page: Page, title: string) => modal(page).getByRole('button'
* is on screen.
*/
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
/**
* A place row. Kept tours wear the row class too and come first, so they are
* left out here; `tourRows` finds them.
*/
const placeRows = (page: Page) => page.locator('.col-lrow:not(.col-lrow-tour)')
const placeRow = (page: Page, name: string) => placeRows(page).filter({ hasText: name }).first()
/** The whole tours a list keeps, the first rows above its places. */
const tourRows = (page: Page) => page.locator('.col-lrow-tour')
/** The search box of the open list: on the map where there is one, above the list where there is none. */
const listSearch = (page: Page) => page.locator('.col-map-search:visible, .col-search:visible').first()
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
const placeRows = (page: Page) => page.locator('.col-lrow')
const selectionBar = (page: Page) => page.locator('.col-selbar')
/** The pending invite to the list Mira shares, in the lists rail. */
const inviteCard = (page: Page) => rail(page).locator('.col-invite').filter({ hasText: INVITED_LIST.name })
/** The target trip's row under Into the trip in the take-over dialog. */
const takeOverTrip = (page: Page) => modal(page).getByRole('radio').filter({ hasText: TOUR_TARGET_TRIP })
/** The detail sheet beside the list. */
const detail = (page: Page) => page.locator('.col-detail').first()
/** The map beside the list, with its floating controls. */
@@ -116,9 +94,6 @@ async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Prom
}
}
/** The tour the take-over made in the target trip, removed again in its cleanup. */
let takenTour: { tripId: number; placeId: number } | null = null
/** 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. */
@@ -141,12 +116,14 @@ async function blurFocus(page: Page): Promise<void> {
* 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 lets the running animation see
* that its time is up and finish, exactly where it would have ended.
* 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 nudgeClock(page, 10_000)
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)
}
/**
@@ -286,7 +263,7 @@ async function selectPlaces(page: Page, names: string[]): Promise<void> {
/** 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(/^(\.\.\.|Search)$/)
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()
@@ -591,53 +568,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
'move-to-list': {
guide: guide('move-to-list'),
start: async p => {
await ensureSecondList(p.request)
await openList(p, KYOTO)
},
steps: [
{
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
act: async p => {
await selectPlaces(p, DUPLICATED)
await beat(p, 300)
},
},
{
// Duplicate rather than Move: the two temples stay on the list the
// guides after this one picture.
target: p => selectionBar(p).getByRole('button', { name: 'Duplicate to list' }),
act: async p => {
await selectionBar(p).getByRole('button', { name: 'Duplicate to list' }).click()
await expect(modal(p)).toBeVisible()
await expect(modal(p).getByRole('button').filter({ hasText: SECOND_LIST.name })).toBeVisible()
// A long list of lists starts with its search focused.
await blurFocus(p)
await settle(p)
},
},
{
target: modalCard,
act: async p => {
await modal(p).getByRole('button').filter({ hasText: SECOND_LIST.name }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
await listRow(p, SECOND_LIST.name).click()
await expect(hero(p)).toContainText(SECOND_LIST.name)
await expect(p.locator('.col-loading')).toHaveCount(0)
for (const name of DUPLICATED) await expect(placeRow(p, name)).toBeVisible()
// A list that had no places mounts its map now, on the window's camera.
await pageReady(p, { reframe: true })
},
},
],
cleanup: async p => {
await deleteList(p.request, SECOND_LIST.name)
},
},
'copy-to-trip': {
guide: guide('copy-to-trip'),
start: async p => {
@@ -719,37 +649,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'accept-list-invite': {
guide: guide('accept-list-invite'),
start: async p => {
// Mira makes a list of her own and invites the demo account before the page loads.
await sendInviteToMe(p.request)
await openCollections(p)
await expect(inviteCard(p)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: inviteCard,
},
{
target: p => inviteCard(p).getByRole('button', { name: 'Accept' }),
act: async p => {
await inviteCard(p).getByRole('button', { name: 'Accept' }).click()
await expect(listRow(p, INVITED_LIST.name)).toBeVisible({ timeout: 15_000 })
await listRow(p, INVITED_LIST.name).click()
await expect(hero(p)).toContainText(INVITED_LIST.name)
await expect(p.locator('.col-loading')).toHaveCount(0)
for (const place of INVITED_LIST.places) await expect(placeRow(p, place.name)).toBeVisible()
await pageReady(p, { reframe: true })
},
},
],
cleanup: async () => {
// Her list goes again, and with it the demo account's membership.
await withdrawInvitedList()
},
},
'export-list': {
guide: guide('export-list'),
start: p => openList(p, KYOTO),
@@ -861,118 +760,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
},
'tour-collection-add-to-trip': {
guide: guide('tour-collection-add-to-trip'),
start: async p => {
await ensureToursFixtures(p.request)
await ensureTourTargetTrip(p.request)
await openList(p, DOLOMITES)
await expect(tourRows(p).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => tourRows(p).first().getByRole('button', { name: 'Add to a trip' }),
act: async p => {
await tourRows(p).first().getByRole('button', { name: 'Add to a trip' }).click()
await expect(modal(p)).toBeVisible()
await expect(takeOverTrip(p)).toBeVisible({ timeout: 15_000 })
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByRole('radiogroup', { name: 'Into the trip' }),
act: async p => {
await takeOverTrip(p).click()
await expect(takeOverTrip(p)).toHaveAttribute('aria-checked', 'true')
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByRole('radiogroup', { name: 'Waypoints' }),
act: async p => {
// With its waypoints where the kept tour has any; Line only otherwise.
const withPoints = modal(p).getByTestId('kept-tour-points-with')
const choice = (await withPoints.isEnabled()) ? withPoints : modal(p).getByTestId('kept-tour-points-without')
await choice.click()
await expect(choice).toHaveAttribute('aria-checked', 'true')
await blurFocus(p)
await settle(p)
},
},
{
target: p => modal(p).getByTestId('kept-tour-confirm'),
act: async p => {
const tripId = await tripIdOf(p, TOUR_TARGET_TRIP)
const answer = p.waitForResponse(r => r.url().includes('/tours/from-collection') && r.request().method() === 'POST')
await modal(p).getByTestId('kept-tour-confirm').click()
const res = await answer
if (!res.ok()) throw new Error(`the take-over answered ${res.status()}: ${await res.text()}`)
const body = (await res.json()) as { tour: { place_id: number } }
takenTour = { tripId, placeId: body.tour.place_id }
await expect(modal(p).getByRole('button', { name: 'Open trip' })).toBeVisible({ timeout: 15_000 })
await blurFocus(p)
await settle(p)
},
},
],
cleanup: async p => {
if (await modal(p).count()) await p.keyboard.press('Escape')
// The new tour leaves the trip again, so the trip screens keep their seed.
if (takenTour) {
await deleteTripPlaces(p, takenTour.tripId, [takenTour.placeId])
takenTour = null
}
},
},
'tour-collection-manage': {
guide: guide('tour-collection-manage'),
start: async p => {
await ensureToursFixtures(p.request)
await openList(p, DOLOMITES)
await expect(tourRows(p).first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
steps: [
{
target: p => tourRows(p).first(),
},
{
target: listSearch,
act: async p => {
const name = ((await tourRows(p).first().locator('.t').textContent()) ?? '').trim()
const word = name.split(/\s+/)[0] ?? ''
if (!word) throw new Error('the kept tour has no name to search for')
await typeInto(p, listSearch(p).locator('input'), word)
await expect(tourRows(p).first()).toBeVisible()
await blurFocus(p)
await settle(p)
},
},
{
target: p => tourRows(p).first().locator('[data-testid^="kept-tour-remove-"]'),
act: async p => {
const before = await tourRows(p).count()
await tourRows(p).first().locator('[data-testid^="kept-tour-remove-"]').click()
// The bin carries the name as its label; the dialog's button carries it as text.
const confirm = p.getByRole('button', { name: 'Remove from the collection' }).filter({ hasText: 'Remove from the collection' })
await expect(confirm).toBeVisible()
await confirm.click()
await expect(tourRows(p)).toHaveCount(before - 1, { timeout: 15_000 })
await listSearch(p).locator('input').fill('')
await pageReady(p)
},
},
],
cleanup: async p => {
// The tour goes back into the list for the next run and the Tours screens.
await ensureToursFixtures(p.request)
},
},
}
/** The list file the export guide downloads and the import guide reads back. */
+16 -189
View File
@@ -3,10 +3,8 @@ 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 { ensureDashboardFixtures, tripIdByTitle, LISBON_FLIGHT } from './fixtures-dashboard'
import { dashboardGuides, dashboardContext } from '../../src/help/contexts/dashboard'
import type { HelpGuide } from '../../src/help/types'
import { coverPhoto } from '../screenshots/photos'
/**
* Actions for the dashboard guides. Each script is keyed by the guide id from
@@ -56,7 +54,7 @@ async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, mont
await page.getByRole('button', { name: dayLabel, exact: true }).click()
}
/** One of the four actions in the top-right corner of a card's photo. */
/** 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. */
@@ -66,35 +64,6 @@ const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
const JAPAN = 'Autumn in Japan'
/** The trip the date-less guide makes and its cleanup drops again. */
const IDEA_TRIP = 'Patagonia, someday'
/** A field block of the trip form by the label above it (the label is not tied to its input). */
const formBlock = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).locator('xpath=..')
/** A widget in the right column by its heading. */
const widget = (page: Page, title: string) => page.locator('.tool').filter({ has: page.locator('.tool-title', { hasText: title }) })
/** Drop every trip with this exact title over the API, so the grid is back to the seed. */
async function dropTrips(page: Page, title: string): Promise<void> {
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 ?? [])
for (const t of trips.filter(x => x.title === title)) {
const dropped = await page.request.delete(`/api/trips/${t.id}`)
if (!dropped.ok()) throw new Error(`could not drop "${t.title}": ${dropped.status()}`)
}
}
/** The Norway trip's reminder before the reminder guide changed it, put back by its cleanup. */
let norwayReminder = 0
/** The Lisbon flight in the Upcoming reservations widget (fixtures-dashboard.ts). */
const upcomingRow = (page: Page) => widget(page, 'Upcoming reservations').locator('.upc-item').filter({ hasText: LISBON_FLIGHT })
/** A clock in the Timezones widget by its city. */
const clockRow = (page: Page, city: string) => widget(page, 'Timezones').locator('.tz-row').filter({ hasText: city })
const SCRIPTS: Record<string, GuideScript> = {
'search-trips': {
guide: guide('search-trips'),
@@ -160,43 +129,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
'dateless-trip': {
guide: guide('dateless-trip'),
start: async p => {
await dropTrips(p, IDEA_TRIP)
await openDashboard(p)
},
steps: [
{
target: p => p.locator('.add-trip-card'),
act: async p => {
await p.locator('.add-trip-card').click()
await expect(modal(p)).toBeVisible()
await typeInto(p, titleField(p), IDEA_TRIP)
},
},
{
// Only there while both dates are empty, which they are on a new trip.
target: p => formBlock(p, 'Number of Days'),
act: async p => {
await formBlock(p, 'Number of Days').locator('input').fill('10')
await beat(p, 300)
},
},
{
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, IDEA_TRIP)).toBeVisible({ timeout: 15_000 })
await expect(card(p, IDEA_TRIP).getByText('Open dates')).toBeVisible()
await card(p, IDEA_TRIP).scrollIntoViewIfNeeded()
await settle(p)
},
},
],
cleanup: async p => { await dropTrips(p, IDEA_TRIP) },
},
'edit-trip': {
guide: guide('edit-trip'),
start: openDashboard,
@@ -224,13 +156,7 @@ const SCRIPTS: Record<string, GuideScript> = {
'cover-image': {
guide: guide('cover-image'),
// The showcase gives Lisbon its photo; the guide starts from a trip without
// one and puts the same photo back, so the next guide sees the seed again.
start: async p => {
const id = await tripIdByTitle(p.request, LISBON)
await p.request.put(`/api/trips/${id}`, { data: { cover_image: null } })
await openDashboard(p)
},
start: openDashboard,
steps: [
{
target: p => cardAction(p, LISBON, 'Edit'),
@@ -239,7 +165,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
act: async p => {
await modal(p).locator('input[type="file"]').setInputFiles((await coverPhoto('lisbon')) ?? (await coverFixture()))
await modal(p).locator('input[type="file"]').setInputFiles(await coverFixture())
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
},
},
@@ -255,48 +181,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'trip-reminder': {
guide: guide('trip-reminder'),
start: async p => {
const id = await tripIdByTitle(p.request, NORWAY)
const res = await p.request.get(`/api/trips/${id}`)
const body = (await res.json()) as { trip?: { reminder_days?: number }; reminder_days?: number }
norwayReminder = body.trip?.reminder_days ?? body.reminder_days ?? 0
await openDashboard(p)
},
steps: [
{
target: p => cardAction(p, NORWAY, 'Edit'),
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
},
{
// The presets only show while trip reminders are on (ensureDashboardFixtures).
prepare: async p => {
await expect(formBlock(p, 'Reminder').getByRole('button', { name: 'Custom' })).toBeVisible()
},
target: p => formBlock(p, 'Reminder'),
act: async p => {
await formBlock(p, 'Reminder').getByRole('button', { name: 'Custom' }).click()
await formBlock(p, 'Reminder').locator('input').fill('14')
await expect(formBlock(p, 'Reminder').getByText('days before departure')).toBeVisible()
await beat(p, 300)
},
},
{
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 })
},
},
],
cleanup: async p => {
const id = await tripIdByTitle(p.request, NORWAY)
const res = await p.request.put(`/api/trips/${id}`, { data: { reminder_days: norwayReminder } })
if (!res.ok()) throw new Error(`could not put the Norway reminder back: ${res.status()}`)
},
},
'duplicate-trip': {
guide: guide('duplicate-trip'),
start: openDashboard,
@@ -409,63 +293,22 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openDashboard,
steps: [
{
target: p => p.getByRole('button', { name: 'Subscribe to all trips' }),
act: async p => {
await p.getByRole('button', { name: 'Subscribe to all trips' }).click()
const enable = p.getByRole('button', { name: 'Enable calendar subscription' })
const turnOff = p.getByRole('button', { name: 'Turn off' })
await expect(enable.or(turnOff)).toBeVisible({ timeout: 15_000 })
// A run that died after enabling left a feed behind; start from none.
if (await turnOff.isVisible()) {
await turnOff.click()
await expect(enable).toBeVisible({ timeout: 15_000 })
}
},
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')).toBeVisible({ timeout: 15_000 })
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
},
},
{
// Open the manual links too, so the picture shows every way the step names.
prepare: async p => {
await p.getByText('Or copy a link manually', { exact: true }).click()
await beat(p, 400)
},
target: p => p.getByRole('dialog').last(),
},
],
cleanup: async p => {
const turnOff = p.getByRole('button', { name: 'Turn off' })
if (await turnOff.isVisible()) {
await turnOff.click()
await expect(p.getByRole('button', { name: 'Enable calendar subscription' })).toBeVisible({ timeout: 15_000 })
}
await p.keyboard.press('Escape')
},
},
'upcoming-reservations': {
guide: guide('upcoming-reservations'),
start: async p => {
await openDashboard(p)
await expect(upcomingRow(p)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: p => widget(p, 'Upcoming reservations'),
},
{
target: p => upcomingRow(p),
act: async p => {
await upcomingRow(p).click()
await expect(p).toHaveURL(/\/trips\/\d+/, { 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: {
@@ -535,36 +378,21 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openDashboard,
steps: [
{
// The picker opens under the + and lists the zones by their IANA names.
prepare: async p => {
await p.getByRole('button', { name: 'Add timezone' }).click()
await widget(p, 'Timezones').getByRole('button', { name: 'Search timezone…' }).click()
await typeInto(p, p.getByLabel('Search', { exact: true }), 'Lisbon')
await expect(p.getByRole('button', { name: 'Europe/Lisbon', exact: true })).toBeVisible()
},
target: p => widget(p, 'Timezones'),
target: p => p.getByRole('button', { name: 'Add timezone' }),
act: async p => {
await p.getByRole('button', { name: 'Europe/Lisbon', exact: true }).click()
await expect(clockRow(p, 'Lisbon')).toBeVisible()
await p.getByRole('button', { name: 'Add timezone' }).click()
await beat(p, 600)
},
},
{
prepare: async p => {
await clockRow(p, 'Lisbon').hover()
await beat(p, 300)
},
target: p => clockRow(p, 'Lisbon').locator('.tz-del'),
act: async p => {
await clockRow(p, 'Lisbon').locator('.tz-del').click()
await expect(clockRow(p, 'Lisbon')).toHaveCount(0)
// 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'),
},
],
cleanup: async p => {
// Take the clock out again should the guide have stopped half way.
const row = clockRow(p, 'Lisbon')
if (await row.count()) { await row.hover(); await row.locator('.tz-del').click() }
},
},
}
@@ -574,7 +402,6 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureExtraTrips(request)
await ensureDashboardFixtures(request)
})
test('every registered dashboard guide has a script, and only those', async () => {
-29
View File
@@ -1,29 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
/**
* Small API helpers the admin guides use to put the seed back after a guide
* changed something (a user it created, a default it set). Kept out of
* `fixtures.ts`, which every screen shares.
*/
/** Deletes the account with this username, if there is one. */
export async function deleteUser(api: APIRequestContext, username: string): Promise<void> {
const res = await api.get('/api/admin/users');
if (!res.ok()) throw new Error(`could not list the users: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { users?: { id: number; username: string }[] };
const user = (body.users ?? []).find((u) => u.username === username);
if (!user) return;
const del = await api.delete(`/api/admin/users/${user.id}`);
if (!del.ok()) throw new Error(`could not delete user "${username}": ${del.status()} ${await del.text()}`);
}
/**
* Returns these default user settings to TREK's built-in value, the way the
* reset link beside a field does (one field per request, as the page sends it).
*/
export async function resetDefaults(api: APIRequestContext, keys: string[]): Promise<void> {
for (const key of keys) {
const res = await api.put('/api/admin/default-user-settings', { data: { [key]: null } });
if (!res.ok()) throw new Error(`could not reset the default "${key}": ${res.status()} ${await res.text()}`);
}
}
-139
View File
@@ -1,139 +0,0 @@
import { request as apiRequest, type APIRequestContext } from '@playwright/test';
import { E2E_BASE_URL } from '../../playwright.config';
import { SPARE_TRIP } from './fixtures';
/**
* What the Collections guides need beyond the seed and the shared fixtures:
* a trip a kept tour can go into, a second list for the duplicate guide and a
* list another user invites the demo account to. Every helper reads first and
* only writes what is missing, so a retried guide finds the same state.
*/
type Row = { id: number; title?: string; name?: string };
const rows = <T>(body: unknown, key: string): T[] =>
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
/** The trip the kept tour is taken into: the Alps trip the Journey guides use as well. */
export const TOUR_TARGET_TRIP = SPARE_TRIP.title;
export async function ensureTourTargetTrip(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const trips = rows<Row>(await res.json(), 'trips');
const have = trips.find((t) => t.title === SPARE_TRIP.title);
if (have) return have.id;
const created = await api.post('/api/trips', { data: SPARE_TRIP });
if (!created.ok())
throw new Error(`could not create "${SPARE_TRIP.title}": ${created.status()} ${await created.text()}`);
const body = (await created.json()) as { trip?: { id: number }; id?: number };
const id = body.trip?.id ?? body.id;
if (id == null) throw new Error(`"${SPARE_TRIP.title}" came back without an id`);
return id;
}
// ── Lists over the API ─────────────────────────────────────────────────────────
export async function listIdOf(api: APIRequestContext, name: string): Promise<number | null> {
const res = await api.get('/api/addons/collections');
if (!res.ok()) throw new Error(`GET collections: ${res.status()}`);
const lists = rows<Row>(await res.json(), 'collections');
return lists.find((l) => l.name === name)?.id ?? null;
}
export async function createList(
api: APIRequestContext,
name: string,
color: string,
description?: string
): Promise<number> {
const res = await api.post('/api/addons/collections', { data: { name, color, description } });
if (!res.ok()) throw new Error(`could not create the list "${name}": ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { id?: number; collection?: { id: number } };
const id = body.id ?? body.collection?.id;
if (id == null) throw new Error(`the list "${name}" came back without an id`);
return id;
}
export async function deleteList(api: APIRequestContext, name: string): Promise<void> {
const id = await listIdOf(api, name);
if (id == null) return;
const res = await api.delete(`/api/addons/collections/${id}`);
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE list "${name}": ${res.status()}`);
}
/** The empty list the duplicate guide copies two temples into; removed again in its cleanup. */
export const SECOND_LIST = { name: 'Kyoto temples', color: '#0ea5e9' };
export async function ensureSecondList(api: APIRequestContext): Promise<void> {
if ((await listIdOf(api, SECOND_LIST.name)) == null) await createList(api, SECOND_LIST.name, SECOND_LIST.color);
}
// ── An invite from another user ───────────────────────────────────────────────
/** The seeded member who shares a list with the demo account. */
const INVITER = { email: 'mira@example.com', password: 'DemoSeed12345!' };
/** Mira's list, with two places, so the accepted invite opens on something real. */
export const INVITED_LIST = {
name: 'Osaka street food',
color: '#ef4444',
description: 'Takoyaki, okonomiyaki and the stalls of Kuromon.',
places: [
{ name: 'Kuromon Market', lat: 34.6655, lng: 135.5068, address: '2 Chome-4-1 Nipponbashi, Chuo Ward, Osaka' },
{ name: 'Dotonbori', lat: 34.6687, lng: 135.5013, address: 'Dotonbori, Chuo Ward, Osaka' },
],
};
/**
* A request context acting as the inviter. Its own context with a bearer
* token: a login on the shared one would set the session cookie there, and
* every later request would be hers.
*/
async function asInviter(): Promise<APIRequestContext> {
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined });
const login = await anon.post('/api/auth/login', { data: INVITER });
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 ${INVITER.email}`);
return apiRequest.newContext({
baseURL: E2E_BASE_URL,
storageState: undefined,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
/**
* Mira makes her list afresh and invites the demo account as an editor. A list
* left over from an earlier attempt is deleted first, so the invite is always
* pending when the guide starts.
*/
export async function sendInviteToMe(api: APIRequestContext): Promise<void> {
const me = await api.get('/api/auth/me');
const myId = ((await me.json()) as { user: { id: number } }).user.id;
const mira = await asInviter();
try {
await deleteList(mira, INVITED_LIST.name);
const id = await createList(mira, INVITED_LIST.name, INVITED_LIST.color, INVITED_LIST.description);
for (const place of INVITED_LIST.places) {
const res = await mira.post('/api/addons/collections/places', { data: { collection_id: id, ...place } });
if (!res.ok())
throw new Error(`could not add "${place.name}" to "${INVITED_LIST.name}": ${res.status()} ${await res.text()}`);
}
const invite = await mira.post('/api/addons/collections/invite', {
data: { collection_id: id, user_id: myId, role: 'editor' },
});
if (!invite.ok()) throw new Error(`could not invite the demo account: ${invite.status()} ${await invite.text()}`);
} finally {
await mira.dispose();
}
}
/** The inviter deletes her list again, which takes it off the demo account's rail. */
export async function withdrawInvitedList(): Promise<void> {
const mira = await asInviter();
try {
await deleteList(mira, INVITED_LIST.name);
} finally {
await mira.dispose();
}
}
-76
View File
@@ -1,76 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { at, day } from '../dates';
import { EXTRA_TRIPS } from './fixtures';
/**
* What the dashboard guides need beyond the seed and `ensureExtraTrips`.
*
* The seeded Japan trip ends on the picture day, so every one of its bookings
* is behind us and the Upcoming reservations widget would read "Nothing booked
* yet." The Lisbon weekend, 25 days out, gets three bookings of its own: a
* flight, a table that is not confirmed yet (so the Pending tag shows) and a
* day trip. Trip reminders are switched on, which is the instance default, so
* the reminder guide finds the presets and not the "disabled" hint.
*/
/** The flight, the hotel and the Sintra train come from the showcase (showcase.ts); the guides add a dinner. */
export const LISBON_FLIGHT = 'TP579 FRA → LIS';
export const LISBON_BOOKINGS = [
{
title: 'Dinner at Cervejaria Ramiro',
type: 'restaurant',
status: 'pending',
location: 'Av. Almirante Reis 1, Lisbon',
offset: 25,
time: '20:30',
confirmation_number: '',
},
] as const;
const listIn = <T>(body: unknown, key: string): T[] =>
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
async function json(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<unknown> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return res.json();
}
/** Id of a trip by its title; throws when it is missing. */
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
const trip = listIn<{ id: number; title: string }>(
await json(await api.get('/api/trips'), 'list trips'),
'trips'
).find((t) => t.title === title);
if (!trip) throw new Error(`fixture trip "${title}" is missing`);
return trip.id;
}
export async function ensureDashboardFixtures(api: APIRequestContext): Promise<void> {
const settings = await api.put('/api/auth/app-settings', { data: { notify_trip_reminder: 'true' } });
if (!settings.ok())
throw new Error(`could not switch trip reminders on: ${settings.status()} ${await settings.text()}`);
const tripId = await tripIdByTitle(api, EXTRA_TRIPS[0].title);
const reservations = listIn<{ title: string }>(
await json(await api.get(`/api/trips/${tripId}/reservations`), 'list reservations'),
'reservations'
);
const days = listIn<{ id: number; date?: string | null }>(
await json(await api.get(`/api/trips/${tripId}/days`), 'list days'),
'days'
);
for (const { offset, time, ...booking } of LISBON_BOOKINGS) {
if (reservations.some((r) => r.title === booking.title)) continue;
const dayId = days.find((d) => d.date === day(offset))?.id;
const created = await api.post(`/api/trips/${tripId}/reservations`, {
data: {
...booking,
confirmation_number: booking.confirmation_number || undefined,
day_id: dayId,
reservation_time: at(offset, time),
},
});
if (!created.ok())
throw new Error(`could not create "${booking.title}": ${created.status()} ${await created.text()}`);
}
}
-148
View File
@@ -1,148 +0,0 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { day } from '../dates';
import { disconnectDawarich, ensureDawarichConnection } from './external';
/**
* What the journal guides about a recorded day need beyond the seed: the
* Dawarich from `media.env` connected, a handful of stays on one day of the
* trip, and those stays synced into TREK, so the day of the journal carries
* its stay row, its route button and its take-over button.
*
* The recording itself is `dawarich-track.ts`, uploaded by
* `ensureDawarichConnection`. Dawarich detects stays from points only in a
* background job of its own, so the stays are put there here, as visits, on
* the Arashiyama day: the times and places are the ones the recording dwells
* at, so the stays sit on the route.
*
* Everything a guide leaves is taken back in `releaseJournalDawarich`: the
* stays still waiting are dismissed (the trip planner lists waiting stays at
* the head of its Places column), a kept route is removed, the automatic
* preparation is switched off, entries the guide wrote are deleted and the
* addon goes off again. The next guide's `connectJournalDawarich` brings the
* dismissed stays back.
*/
/** The day the stays are on: the trip's sixth day, Arashiyama and Nishiki. */
export const STAY_DAY = day(-4);
const JST = '+09:00';
const STAYS = [
{ name: 'Togetsukyo Bridge', lat: 35.013, lng: 135.6776, from: '09:30', to: '10:00' },
{ name: 'Arashiyama Bamboo Grove', lat: 35.017, lng: 135.6716, from: '10:10', to: '11:10' },
{ name: 'Nishiki Market', lat: 35.005, lng: 135.7649, from: '12:30', to: '14:00' },
{ name: 'Gion', lat: 35.0037, lng: 135.7752, from: '14:20', to: '15:20' },
];
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 ok<T>(res: APIResponse, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
const instant = (time: string) => new Date(`${STAY_DAY}T${time}:00${JST}`).toISOString();
/** The stays on the day as visits in Dawarich; those already there by name are left alone. */
async function ensureDawarichStays(): Promise<void> {
const base = env('HELP_MEDIA_DAWARICH_URL');
const key = env('HELP_MEDIA_DAWARICH_API_KEY');
const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
const from = instant('00:00');
const to = instant('23:59');
const listed = await fetch(`${base}/api/v1/visits?start_at=${from}&end_at=${to}`, { headers });
if (!listed.ok) throw new Error(`Dawarich visits ${STAY_DAY}: HTTP ${listed.status} ${await listed.text()}`);
const body = (await listed.json()) as { name?: string }[] | { visits?: { name?: string }[] };
const have = new Set((Array.isArray(body) ? body : (body.visits ?? [])).map((v) => v.name));
for (const stay of STAYS) {
if (have.has(stay.name)) continue;
const created = await fetch(`${base}/api/v1/visits`, {
method: 'POST',
headers,
body: JSON.stringify({
visit: {
name: stay.name,
latitude: stay.lat,
longitude: stay.lng,
started_at: instant(stay.from),
ended_at: instant(stay.to),
},
}),
});
if (!created.ok) throw new Error(`Dawarich visit "${stay.name}": HTTP ${created.status} ${await created.text()}`);
}
}
interface Suggestion {
id: number;
state: string;
}
async function suggestions(api: APIRequestContext, state: 'new' | 'dismissed'): Promise<Suggestion[]> {
const body = await ok<{ suggestions: Suggestion[] }>(
await api.get(`/api/integrations/dawarich/suggestions?state=${state}`),
`list ${state} stays`
);
return body.suggestions;
}
async function setState(api: APIRequestContext, list: Suggestion[], state: 'new' | 'dismissed'): Promise<void> {
for (const s of list) {
await ok(
await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state } }),
`set stay ${s.id} ${state}`
);
}
}
/** The ids of the journal's written entries, for `releaseJournalDawarich` to keep. */
export async function writtenEntryIds(api: APIRequestContext, journeyId: number): Promise<Set<number>> {
const { entries } = await ok<{ entries: { id: number; type: string }[] }>(
await api.get(`/api/journeys/${journeyId}/entries`),
'list entries'
);
return new Set(entries.filter((e) => e.type !== 'skeleton').map((e) => e.id));
}
/** The addon on, the user connected, the stays in Dawarich and synced into TREK, all waiting again. */
export async function connectJournalDawarich(api: APIRequestContext, journeyId: number): Promise<void> {
await ensureDawarichConnection(api);
await ensureDawarichStays();
await ok(await api.post('/api/integrations/dawarich/sync'), 'sync Dawarich');
await setState(api, await suggestions(api, 'dismissed'), 'new');
await ok(
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
'switch the preparation off'
);
}
/** Everything a recorded-day guide changed, put back; `keep` are the entries that were there before it. */
export async function releaseJournalDawarich(
api: APIRequestContext,
journeyId: number,
keep: Set<number>
): Promise<void> {
await setState(api, await suggestions(api, 'new'), 'dismissed');
const { days } = await ok<{ days: { date: string }[] }>(
await api.get(`/api/journeys/${journeyId}/recordings`),
'list kept days'
);
for (const kept of days) {
await ok(
await api.delete(`/api/journeys/${journeyId}/recordings/${kept.date}`),
`remove the route of ${kept.date}`
);
}
await ok(
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
'switch the preparation off'
);
for (const id of await writtenEntryIds(api, journeyId)) {
if (!keep.has(id)) await ok(await api.delete(`/api/journeys/entries/${id}`), `delete entry ${id}`);
}
await disconnectDawarich(api);
}
-158
View File
@@ -1,158 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { asMember, PARTNER } from './fixtures-vacay';
/**
* The in-app notifications the notification guides read, mark and delete.
*
* The seed already leaves the admin a few "New Message" notifications, one per
* chat line mira and jonas posted in the Japan trip. On top of those the
* members do two more things a trip's people really do, through their own
* bearer tokens: jonas saves a booking of the Japan trip unchanged (the
* "Booking Updated" notification, with View Trip) and mira shares her Vacay
* calendar with the admin (the "Vacay Calendar Shared" one, with View Vacay).
* Neither changes anything another picture shows; removeNotificationFixtures
* takes the share and both notifications back.
*
* Every helper is idempotent: a guide that deleted one gets it again at its start.
*/
const INBOX = '/api/notifications/in-app';
const VACAY = '/api/addons/vacay';
/** The member who changes the booking. Not mira: two notifications from one sender read like one. */
export const BOOKER = { username: 'jonas', email: 'jonas@example.com', password: PARTNER.password };
/** A booking the seed puts on the Japan trip; jonas saves it as it is. */
const BOOKING = 'LH716 FRA → HND';
/** The titles as the list renders them (`notif.*.title` in en). */
export const BOOKING_TITLE = 'Booking Updated';
export const SHARE_TITLE = 'Vacay Calendar Shared';
export const MESSAGE_TITLE = 'New Message';
const BOOKING_KEY = 'notif.booking_change.title';
const SHARE_KEY = 'notif.vacay_share.title';
interface InboxRow {
id: number;
title_key: string;
sender_username: string | null;
is_read: boolean | number;
}
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
}
/** The admin's newest notifications, as many as the server hands out at once. */
export async function inbox(api: APIRequestContext): Promise<InboxRow[]> {
const res = await api.get(`${INBOX}?limit=50`);
await ok(res, 'could not load the notifications');
return ((await res.json()) as { notifications: InboxRow[] }).notifications;
}
const find = (rows: InboxRow[], key: string, sender: string): InboxRow | undefined =>
rows.find((n) => n.title_key === key && n.sender_username === sender);
async function adminId(api: APIRequestContext): Promise<number> {
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
}
/** jonas saves the seeded flight unchanged; the server tells every other member. */
async function bookingChangeFromJonas(): Promise<void> {
const { tripId } = seededTrip();
const jonas = await asMember(BOOKER);
try {
const list = await jonas.get(`/api/trips/${tripId}/reservations`);
await ok(list, 'jonas could not load the bookings');
const { reservations } = (await list.json()) as { reservations: { id: number; title: string }[] };
const booking = reservations.find((r) => r.title === BOOKING) ?? reservations[0];
if (!booking) throw new Error('the seeded trip has no booking for jonas to save');
await ok(
await jonas.put(`/api/trips/${tripId}/reservations/${booking.id}`, { data: { title: booking.title } }),
'jonas could not save the booking'
);
} finally {
await jonas.dispose();
}
}
/** mira shares her Vacay calendar with the admin, as she would from her own Vacay. */
async function shareFromMira(api: APIRequestContext): Promise<void> {
const target = await adminId(api);
const mira = await asMember(PARTNER);
try {
const res = await mira.post(`${VACAY}/shares`, { data: { user_id: target } });
// Already shared: the notification was deleted, the share is still there.
// Share again from scratch so the server sends a fresh one.
if (!res.ok() && (await res.text()).includes('Already shared')) {
const shares = (await (await mira.get(`${VACAY}/shares`)).json()) as {
outgoing?: { id: number; user_id: number }[];
};
for (const s of (shares.outgoing ?? []).filter((x) => x.user_id === target))
await mira.delete(`${VACAY}/shares/${s.id}`);
await ok(
await mira.post(`${VACAY}/shares`, { data: { user_id: target } }),
'mira could not share her calendar again'
);
} else {
await ok(res, 'mira could not share her calendar');
}
} finally {
await mira.dispose();
}
}
/** Wait until the fire-and-forget send has written the row. */
async function waitFor(api: APIRequestContext, key: string, sender: string): Promise<InboxRow> {
for (let i = 0; i < 20; i++) {
const row = find(await inbox(api), key, sender);
if (row) return row;
await new Promise((res) => setTimeout(res, 250));
}
throw new Error(`no "${key}" notification from ${sender} arrived`);
}
/**
* The two notifications from the members, both unread, the share newest. A
* guide before may have read or deleted one; this brings it back as it was.
*/
export async function ensureNotificationFixtures(api: APIRequestContext): Promise<void> {
let rows = await inbox(api);
if (!find(rows, BOOKING_KEY, BOOKER.username)) await bookingChangeFromJonas();
await waitFor(api, BOOKING_KEY, BOOKER.username);
rows = await inbox(api);
if (!find(rows, SHARE_KEY, PARTNER.username)) await shareFromMira(api);
await waitFor(api, SHARE_KEY, PARTNER.username);
for (const n of await inbox(api)) {
const ours =
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
if (ours && !n.is_read) continue;
if (ours) await ok(await api.put(`${INBOX}/${n.id}/unread`), 'could not mark a notification unread');
}
}
/** The ids of every unread notification, to give back after a guide that reads them all. */
export async function unreadIds(api: APIRequestContext): Promise<number[]> {
return (await inbox(api)).filter((n) => !n.is_read).map((n) => n.id);
}
/** Mark these unread again; one that is gone by now is skipped. */
export async function markUnread(api: APIRequestContext, ids: number[]): Promise<void> {
for (const id of ids) await api.put(`${INBOX}/${id}/unread`);
}
/** Back to the seed: mira's share and both notifications from the members go. */
export async function removeNotificationFixtures(api: APIRequestContext): Promise<void> {
const shares = (await (await api.get(`${VACAY}/shares`)).json()) as { incoming: { id: number; username: string }[] };
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
await ok(await api.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
}
for (const n of await inbox(api)) {
const ours =
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
if (ours) await api.delete(`${INBOX}/${n.id}`);
}
}
-221
View File
@@ -1,221 +0,0 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { toggleAddon } from './external';
import { pluginPackage, seededTrip } from './fixtures';
/**
* What the settings guides need beyond the seed: a wanderer to connect to, a
* plugin so the Plugins tab exists, and two parked offline changes so the
* Sync conflicts card and the failed changes have something to show.
*
* The wanderer guides follow the pattern of `external.ts`: the address and the
* token come from `e2e/help/media.env`, and without them those guides fail at
* their start while every other guide runs as before.
*
* # wanderer (Settings, Integrations: wanderer). A token from wanderer under
* # Settings, Account, API tokens; it starts with wanderer_key_.
* HELP_MEDIA_WANDERER_URL=http://192.168.1.10:3030
* HELP_MEDIA_WANDERER_TOKEN=wanderer_key_...
*
* TREK's wanderer connection takes a URL and an API token, nothing else (see
* `wandererSettingsRequestSchema`), so no user name or password is needed. As
* with the other services, the address has to be the machine's LAN address and
* the media backend needs ALLOW_INTERNAL_NETWORK=true.
*/
export function mediaEnv(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(/\/+$/, '');
}
// ── wanderer ────────────────────────────────────────────────────────────────
export const wandererUrl = (): string => mediaEnv('HELP_MEDIA_WANDERER_URL');
export const wandererToken = (): string => mediaEnv('HELP_MEDIA_WANDERER_TOKEN');
/** The admin's wanderer switch under Tours, as it was before the guide turned it on. */
let wandererWasOn: boolean | null = null;
/**
* The Tours addon on and wanderer allowed under it, so the wanderer card shows
* in Integrations, with no connection yet. Remembers the admin switch, so
* `disconnectWanderer` puts it back the way the seed had it.
*/
export async function allowWanderer(api: APIRequestContext): Promise<void> {
// Both variables first: a missing one should fail here, not halfway into a picture.
wandererUrl();
wandererToken();
await toggleAddon(api, 'tours', true);
if (wandererWasOn === null) {
const current = await api.get('/api/admin/tours-wanderer');
wandererWasOn = current.ok() ? !!((await current.json()) as { enabled?: boolean }).enabled : false;
}
const res = await api.put('/api/admin/tours-wanderer', { data: { enabled: true } });
if (!res.ok()) throw new Error(`could not allow wanderer under Tours: ${res.status()} ${await res.text()}`);
await api.delete('/api/integrations/wanderer/settings');
}
/** Connected through the API and tested, loudly, for the guides that start from a connection. */
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
await allowWanderer(api);
const saved = await api.put('/api/integrations/wanderer/settings', {
data: { url: wandererUrl(), token: wandererToken(), allowInsecureTls: false },
});
if (!saved.ok()) throw new Error(`could not save the wanderer connection: ${saved.status()} ${await saved.text()}`);
const test = await api.post('/api/integrations/wanderer/test', { data: {} });
const body = (await test.json()) as { ok?: boolean; code?: string };
if (!body.ok)
throw new Error(
`wanderer is not reachable from the media backend: ${body.code ?? test.status()} (is ALLOW_INTERNAL_NETWORK=true?)`
);
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
}
/** The watch off, the connection gone, and the admin switch back where it was. */
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
await api.delete('/api/integrations/wanderer/settings');
if (wandererWasOn === false) await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
wandererWasOn = null;
}
// ── Plugins ─────────────────────────────────────────────────────────────────
/**
* The Plugins tab only exists while a plugin is active. The seed installs two
* from the registry, which needs the network, and the admin file uninstalls
* Trip Doctor in its cleanup; so when no plugin is active any more, the SDK's
* Trip Doctor example is sideloaded and switched on. Answers whether it did,
* so the caller can take it away again.
*/
export async function ensureActivePlugin(api: APIRequestContext): Promise<boolean> {
const feed = await api.get('/api/plugins');
const { plugins = [] } = (await feed.json()) as { plugins?: { id: string }[] };
if (plugins.length > 0) return false;
const upload = await api.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()}`);
const active = await api.post('/api/admin/plugins/trip-doctor/activate', { data: { consent: true } });
if (!active.ok()) throw new Error(`could not activate the plugin: ${active.status()} ${await active.text()}`);
return true;
}
export async function removeSideloadedPlugin(api: APIRequestContext): Promise<void> {
await api.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } });
}
// ── Offline: parked changes ─────────────────────────────────────────────────
/** The two rows this file parks in the offline queue; their ids make them easy to take out again. */
const CONFLICT_ID = 'help-media-conflict-nishiki';
const FAILED_ID = 'help-media-failed-todaiji';
export const CONFLICT_PLACE = 'Nishiki Market';
export const FAILED_PLACE = 'Todai-ji';
async function placeId(api: APIRequestContext, tripId: number, name: string): Promise<number> {
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[];
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.name === name);
if (!place) throw new Error(`the seeded trip has no place "${name}"`);
return place.id;
}
/**
* A change that clashes with the server and one the server keeps refusing,
* written straight into this browser's offline queue (Dexie table
* `mutationQueue` of `trek-offline-u<userId>`), the way the mutation queue
* parks them. Neither is ever replayed on its own: a parked row waits for
* Keep mine, Keep theirs, Try again or Discard, none of which the guide
* clicks. Needs the app loaded once, so the database exists.
*/
export async function parkOfflineChanges(page: Page): Promise<void> {
const { tripId } = seededTrip();
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
const conflictPlace = await placeId(page.request, tripId, CONFLICT_PLACE);
const failedPlace = await placeId(page.request, tripId, FAILED_PLACE);
const now = Date.now();
const rows = [
{
id: CONFLICT_ID,
tripId,
method: 'PUT',
url: `/trips/${tripId}/places/${conflictPlace}`,
body: { name: CONFLICT_PLACE, notes: 'Go before 10, the stalls open early.' },
createdAt: now - 60_000,
status: 'conflict',
attempts: 1,
lastError: 'conflict',
resource: 'places',
entityId: conflictPlace,
baseUpdatedAt: null,
conflictServer: { name: CONFLICT_PLACE },
conflictAt: now - 30_000,
schemaVersion: 1,
},
{
id: FAILED_ID,
tripId,
method: 'PUT',
url: `/trips/${tripId}/places/${failedPlace}`,
body: { name: FAILED_PLACE, notes: 'Deer crackers at the gate.' },
createdAt: now - 50_000,
status: 'failed',
attempts: 5,
lastError: 'HTTP 500',
resource: 'places',
entityId: failedPlace,
baseUpdatedAt: null,
schemaVersion: 1,
},
];
await page.evaluate(
async ({ name, rows }) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const open = indexedDB.open(name);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
try {
if (!db.objectStoreNames.contains('mutationQueue'))
throw new Error(`${name} has no mutationQueue; was the app loaded?`);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('mutationQueue', 'readwrite');
for (const row of rows) tx.objectStore('mutationQueue').put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
},
{ name: `trek-offline-u${me.user.id}`, rows }
);
}
/** Takes the two parked rows out again, whatever the guide did with them. */
export async function clearParkedChanges(page: Page): Promise<void> {
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
await page.evaluate(
async ({ name, ids }) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const open = indexedDB.open(name);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
try {
if (!db.objectStoreNames.contains('mutationQueue')) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('mutationQueue', 'readwrite');
for (const id of ids) tx.objectStore('mutationQueue').delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
},
{ name: `trek-offline-u${me.user.id}`, ids: [CONFLICT_ID, FAILED_ID] }
);
}
-72
View File
@@ -1,72 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
/**
* What the two note guides of the Collab tab need beyond `ensureCollabFixtures`.
* Kept apart from the shared fixtures because nothing outside
* `trip-collab.guide.ts` reads it.
*/
/**
* The note `manage-notes` opens, pins, edits and finally deletes. It is made
* fresh at the start of that guide rather than in the seed, so no other picture
* of the tab (the hero among them) carries a note that is about to go.
*/
export const MANAGE_NOTE = {
title: 'Onsen etiquette',
category: 'Accommodation',
color: '#ef4444',
content:
'Wash at the stools **before** you get into the bath.\n\n- The small towel stays out of the water\n- No swimwear\n- Ask at the front desk about tattoos',
website: 'https://www.japan.travel/en/guide/onsen/',
};
/** The line the edit step adds to it. */
export const MANAGE_NOTE_EDIT = '- Drink water afterwards, the baths run hot';
/**
* The category and colour every seeded note had before `note-categories` renames
* and recolours them (`e2e/screenshots/seed.ts`, step 11b), by title.
*/
export const SEEDED_NOTE_CATEGORIES: Record<string, { category: string; color: string }> = {
'Rail passes': { category: 'Transport', color: '#3b82f6' },
'Ryokan etiquette': { category: 'Accommodation', color: '#ef4444' },
'Rainy-day alternatives': { category: 'Ideas', color: '#22c55e' },
};
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`;
async function notesOf(
api: APIRequestContext
): Promise<{ id: number; title: string; category: string | null; color: string | null }[]> {
const res = await api.get(`${collab()}/notes`);
const body = (await res.json()) as {
notes?: { id: number; title: string; category: string | null; color: string | null }[];
};
return body.notes ?? [];
}
/** Removes every note of that title. */
export async function removeNotes(api: APIRequestContext, title: string): Promise<void> {
for (const note of (await notesOf(api)).filter((n) => n.title === title)) {
await api.delete(`${collab()}/notes/${note.id}`);
}
}
/** One open, unpinned MANAGE_NOTE; a copy left by a run that stopped short is thrown away first. */
export async function ensureManageNote(api: APIRequestContext): Promise<void> {
await removeNotes(api, MANAGE_NOTE.title);
const created = await api.post(`${collab()}/notes`, { data: MANAGE_NOTE });
if (!created.ok())
throw new Error(`could not write the note "${MANAGE_NOTE.title}": ${created.status()} ${await created.text()}`);
}
/** Puts every seeded note back into its seeded category and colour. Safe to run when nothing changed. */
export async function restoreNoteCategories(api: APIRequestContext): Promise<void> {
for (const note of await notesOf(api)) {
const seeded = SEEDED_NOTE_CATEGORIES[note.title];
if (!seeded || (note.category === seeded.category && note.color === seeded.color)) continue;
const res = await api.put(`${collab()}/notes/${note.id}`, { data: seeded });
if (!res.ok()) throw new Error(`could not restore the note "${note.title}": ${res.status()} ${await res.text()}`);
}
}
-121
View File
@@ -1,121 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { day } from '../dates';
import { seededTrip } from './fixtures';
/**
* What the receipts guide of the Costs tab needs beyond the shared fixtures: an
* expense of its own to attach a receipt to, and the receipt itself. Nothing in
* the repository may carry a real person's paperwork, so the till slip is drawn
* here, the way `fixtures.ts` draws its documents: a white strip of thermal
* paper with the shop, the lines and the total, which is what a phone photo of
* a receipt cropped to the paper looks like.
*/
/** The expense the receipt belongs to; its total is the slip's total. */
export const RAMEN = {
name: 'Ichiran ramen in Shibuya',
category: 'food',
total_price: 4450,
currency: 'JPY',
expense_date: day(-6),
};
/** The file the guide attaches, by the name the editor and the preview show. */
export const RECEIPT_FILE = 'ichiran-receipt.png';
const LINES: [string, string, string][] = [
['Ramen, classic', '3', '2,940'],
['Kaedama (extra noodles)', '2', '420'],
['Matcha almond tofu', '2', '700'],
['Soft-boiled egg', '3', '390'],
];
const tmpDir = (): string => {
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
return dir;
};
const esc = (v: string) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* The till slip as a PNG in `e2e/.tmp`. Drawn on every call: the receipt names
* its day, which is relative to the picture day, so a file left over from an
* earlier run would carry that run's date.
*/
export async function receiptFixture(): Promise<string> {
const stamp = RAMEN.expense_date.replace(/-/g, '/');
const file = path.join(tmpDir(), RECEIPT_FILE);
const W = 1000;
const H = 1500;
const paperX = 200;
const paperW = 600;
const left = paperX + 48;
const right = paperX + paperW - 48;
const mono = 'font-family="\'Courier New\', Courier, monospace"';
const text = (x: number, y: number, s: string, opts = '') =>
`<text x="${x}" y="${y}" ${mono} ${opts.includes('font-size') ? '' : 'font-size="24"'} fill="#1f2328" ${opts}>${esc(s)}</text>`;
const rule = (y: number) =>
`<line x1="${left}" y1="${y}" x2="${right}" y2="${y}" stroke="#1f2328" stroke-width="2" stroke-dasharray="8 6"/>`;
const rows: string[] = [];
let y = 560;
for (const [name, qty, price] of LINES) {
rows.push(text(left, y, name));
rows.push(text(left + 330, y, `x${qty}`));
rows.push(text(right, y, price, 'text-anchor="end"'));
y += 52;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect width="${W}" height="${H}" fill="#d9dce1"/>
<rect x="${paperX + 10}" y="70" width="${paperW}" height="${H - 140}" fill="#000" opacity="0.12" rx="4"/>
<rect x="${paperX}" y="60" width="${paperW}" height="${H - 140}" fill="#fcfcfa" rx="4"/>
${text(W / 2, 170, 'ICHIRAN', 'font-size="46" font-weight="bold" text-anchor="middle"')}
${text(W / 2, 214, 'Shibuya store', 'text-anchor="middle"')}
${text(W / 2, 252, '1-22-7 Jinnan, Shibuya-ku, Tokyo', 'font-size="20" text-anchor="middle"')}
${text(W / 2, 284, 'Tel 03-3463-3667', 'font-size="20" text-anchor="middle"')}
${rule(330)}
${text(left, 380, `${stamp} 13:42`)}
${text(right, 380, 'No. 0418', 'text-anchor="end"')}
${text(left, 424, 'Booth 14-16')}
${text(right, 424, 'Guests 3', 'text-anchor="end"')}
${rule(470)}
${rows.join('\n ')}
${rule(y)}
${text(left, y + 60, 'TOTAL', 'font-size="32" font-weight="bold"')}
${text(right, y + 60, '¥4,450', 'font-size="32" font-weight="bold" text-anchor="end"')}
${text(left, y + 104, '(incl. 10% tax)', 'font-size="20"')}
${text(right, y + 104, '¥404', 'font-size="20" text-anchor="end"')}
${text(left, y + 160, 'Paid by card')}
${text(right, y + 160, '¥4,450', 'text-anchor="end"')}
${rule(y + 206)}
${text(W / 2, y + 260, 'Thank you, please come again', 'font-size="20" text-anchor="middle"')}
${text(W / 2, y + 296, 'Open 24 hours', 'font-size="20" text-anchor="middle"')}
</svg>`;
await sharp(Buffer.from(svg)).png().toFile(file);
return file;
}
type ApiFile = { id: number; original_name: string };
/**
* The receipt off the trip again, trash included. Remove receipt in the editor
* only unlinks it, and deleting the expense leaves it too, so without this the
* Files tab of every later picture would carry a ramen receipt.
*/
export async function removeReceiptFiles(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) throw new Error(`could not list the trip's files: ${res.status()}`);
const { files = [] } = (await res.json()) as { files?: ApiFile[] };
for (const file of files.filter((f) => f.original_name === RECEIPT_FILE)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
-64
View File
@@ -1,64 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_FOLDER, resetDocSync } from './external';
import { drawPdf } from './fixtures';
/**
* What the binding-settings guide needs beyond the Files fixtures: the trip
* already bound to the Nextcloud folder, so the guide opens on the card and
* not on the connection form that `files-sync` walks through. It takes the same
* three calls the dialog makes (save the connection, list the folders, bind
* one), starting from no binding at all, so it can run any number of times.
* The store's address and app password come from media.env (external.ts).
*/
interface Scope {
scopeKey: string;
label: string;
remoteRootId: string | null;
remoteRootPath: string | null;
}
async function ok<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: HTTP ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
/** Binds the trip to the fixture folder in Nextcloud with the dialog's defaults; returns the link's id. */
export async function ensureNextcloudBinding(api: APIRequestContext, tripId: number): Promise<number> {
await resetDocSync(api, tripId);
await ensureNextcloudFolder(drawPdf);
const base = `/api/trips/${tripId}/docsync`;
const connection = await ok<{ id: number }>(
await api.put(`${base}/connections`, { data: NEXTCLOUD_CONNECTION() }),
'save the Nextcloud connection'
);
const { scopes = [] } = await ok<{ scopes?: Scope[] }>(
await api.get(`${base}/connections/${connection.id}/scopes`),
'list the Nextcloud folders'
);
// The picker finds the folder by its text the same way (folderRow in the guide).
const scope =
scopes.find((s) => s.label === NEXTCLOUD_FOLDER) ?? scopes.find((s) => s.label.includes(NEXTCLOUD_FOLDER));
if (!scope)
throw new Error(`no folder "${NEXTCLOUD_FOLDER}" in the store (found: ${scopes.map((s) => s.label).join(', ')})`);
const link = await ok<{ id: number }>(
await api.post(`${base}/links`, {
data: {
connectionId: connection.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
},
}),
'bind the trip to the folder'
);
return link.id;
}
-135
View File
@@ -1,135 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
/**
* Putting the packing list back the way the seed left it.
*
* The seed's list is long (six lists, most of it ticked) and several guides
* change it in ways that are hard to undo one by one: a list dragged to the
* front, pieces counted in, a weight typed, items imported or brought in by a
* template. So the trip-lists script takes one snapshot of the list before the
* first guide and every packing guide puts that snapshot back, before it starts
* and again when it is done. Nothing here hard-codes the seed's names, so a
* richer seed needs no change in this file.
*/
export interface PackingSnapshotRow {
id: number;
name: string;
category: string | null;
checked: number;
quantity: number;
weight_grams: number | null;
packed_quantity: number | null;
bag_id: number | null;
is_private: number;
sort_order: number;
}
type RawRow = Partial<PackingSnapshotRow> & { id: number; name: string };
function normalise(row: RawRow): PackingSnapshotRow {
return {
id: row.id,
name: row.name,
category: row.category ?? null,
checked: row.checked ? 1 : 0,
quantity: row.quantity || 1,
weight_grams: row.weight_grams ?? null,
packed_quantity: row.packed_quantity ?? null,
bag_id: row.bag_id ?? null,
is_private: row.is_private ? 1 : 0,
sort_order: row.sort_order ?? 0,
};
}
async function packingRows(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/packing`);
if (!res.ok()) throw new Error(`could not read the packing list: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { items?: RawRow[] };
return (body.items ?? []).map(normalise).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
}
/** The packing list as it is now, in its order. */
export async function snapshotPacking(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
return packingRows(api);
}
const differs = (a: PackingSnapshotRow, b: PackingSnapshotRow): boolean =>
a.name !== b.name ||
a.category !== b.category ||
a.checked !== b.checked ||
a.quantity !== b.quantity ||
a.weight_grams !== b.weight_grams ||
a.packed_quantity !== b.packed_quantity ||
a.bag_id !== b.bag_id;
/**
* Make the trip's packing list equal to the snapshot again: what a guide added
* goes, what it deleted comes back, ticks, quantities, counts and weights are
* written back, and the order is restored. Cheap when nothing changed: one read.
*/
export async function restorePacking(api: APIRequestContext, snapshot: PackingSnapshotRow[]): Promise<void> {
const { tripId } = seededTrip();
const current = await packingRows(api);
const wanted = new Set(snapshot.map((row) => row.id));
for (const row of current.filter((r) => !wanted.has(r.id))) {
await api.delete(`/api/trips/${tripId}/packing/${row.id}`);
}
const ids: number[] = [];
for (const row of snapshot) {
const now = current.find((r) => r.id === row.id);
if (now) {
ids.push(row.id);
if (!differs(now, row)) continue;
// Naming `checked` resets the piece counter on the server, which is what a
// seeded row has: none. A partial count is written after it when there was one.
const data: Record<string, unknown> = {
name: row.name,
checked: row.checked,
quantity: row.quantity,
weight_grams: row.weight_grams,
};
if (row.category) data.category = row.category;
if (now.bag_id !== row.bag_id) data.bag_id = row.bag_id;
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data });
if (!res.ok()) throw new Error(`could not restore "${row.name}": ${res.status()} ${await res.text()}`);
if (row.packed_quantity != null) {
await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { packed_quantity: row.packed_quantity } });
}
continue;
}
const created = await api.post(`/api/trips/${tripId}/packing`, {
data: {
name: row.name,
...(row.category ? { category: row.category } : {}),
visibility: row.is_private ? 'personal' : 'common',
quantity: row.quantity,
weight_grams: row.weight_grams,
checked: row.checked,
},
});
if (!created.ok())
throw new Error(`could not re-create "${row.name}": ${created.status()} ${await created.text()}`);
const { item } = (await created.json()) as { item: { id: number } };
ids.push(item.id);
}
const after = await packingRows(api);
const inOrder = after.map((r) => r.id);
if (inOrder.length === ids.length && inOrder.every((id, i) => id === ids[i])) return;
await api.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds: ids } });
}
/** Untick one item for a guide that starts from an item still to pack. */
export async function untickPacking(api: APIRequestContext, name: string): Promise<void> {
const { tripId } = seededTrip();
const row = (await packingRows(api)).find((r) => r.name === name);
if (!row) throw new Error(`no packing item "${name}" in the seed`);
if (!row.checked) return;
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { checked: 0 } });
if (!res.ok()) throw new Error(`could not untick "${name}": ${res.status()} ${await res.text()}`);
}
-71
View File
@@ -1,71 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { TOUR_NAMES } from './tour-fixtures';
/**
* What the tour guide on the plan map needs beyond `ensureToursFixtures`: one
* of the trip's tours on a day, so the day's route has a tour to run to and on
* from. The Tours fixtures may already put one there; then nothing is added.
* Otherwise the Fushimi Inari hike goes onto day 5, the day that already holds
* Fushimi Inari Taisha, and `removeTourFromDay` takes exactly that assignment
* out again in `afterAll`, so the screens after this one see the seed as it was.
*/
interface DayAssignment {
id: number;
place_id: number;
place?: { name?: string };
}
/** The tour names, whatever shape the Tours fixtures export them in (a list or a map of them). */
export function tourNames(): string[] {
const values = Object.values(TOUR_NAMES as unknown as Record<string, unknown>);
return values.filter((v): v is string => typeof v === 'string');
}
export interface TourOnDay {
/** The day's number in the days column, 1-based. */
day: number;
/** The tour's name, as the list and its card show it. */
name: string;
/** The assignment this fixture created, to remove again; null when the tour was already on a day. */
created: { dayId: number; id: number } | null;
}
async function assignmentsOf(api: APIRequestContext, tripId: number, dayId: number): Promise<DayAssignment[]> {
const res = await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`);
if (!res.ok()) throw new Error(`could not read the assignments of day ${dayId}: ${res.status()}`);
const body = (await res.json()) as { assignments?: DayAssignment[] };
return body.assignments ?? [];
}
export async function ensureTourOnDay(api: APIRequestContext): Promise<TourOnDay> {
const { tripId, dayIds } = seededTrip();
const names = tourNames();
if (!names.length) throw new Error('the Tours fixtures export no tour names');
for (let i = 0; i < dayIds.length; i++) {
const onDay = (await assignmentsOf(api, tripId, dayIds[i])).find((a) => names.includes(a.place?.name ?? ''));
if (onDay?.place?.name) return { day: i + 1, name: onDay.place.name, created: null };
}
const res = await api.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 ?? []);
const tours = places.filter((p) => names.includes(p.name));
const tour = tours.find((p) => /fushimi/i.test(p.name)) ?? tours[0];
if (!tour) throw new Error(`none of the tours (${names.join(', ')}) is in the trip`);
const dayId = dayIds[4];
const created = await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: tour.id } });
if (!created.ok())
throw new Error(`could not put "${tour.name}" on day 5: ${created.status()} ${await created.text()}`);
const { assignment } = (await created.json()) as { assignment: { id: number } };
return { day: 5, name: tour.name, created: { dayId, id: assignment.id } };
}
export async function removeTourFromDay(api: APIRequestContext, onDay: TourOnDay | null): Promise<void> {
if (!onDay?.created) return;
const { tripId } = seededTrip();
await api.delete(`/api/trips/${tripId}/days/${onDay.created.dayId}/assignments/${onDay.created.id}`);
}
-58
View File
@@ -1,58 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { coverFixture, seededTrip } from './fixtures';
/**
* What the place card's own guides need beyond the shared fixtures: a photo
* already attached to a place, so the round picture offers From attached files.
* The cover drawing is a mountain ridge at sunset, which is what an evening on
* Mount Inari looks like, so it goes onto Fushimi Inari Taisha.
*/
export const PHOTO_PLACE = 'Fushimi Inari Taisha';
export const PHOTO_FILE = 'inari-evening.jpg';
type ApiFile = { id: number; original_name: string; place_id?: number | null };
async function tripFiles(api: APIRequestContext): Promise<ApiFile[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) throw new Error(`could not list the trip's files: ${res.status()}`);
const body = (await res.json()) as { files?: ApiFile[] };
return body.files ?? [];
}
async function placeId(api: APIRequestContext, name: string): Promise<number> {
const { tripId } = seededTrip();
const res = await api.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 ?? []);
const found = places.find((p) => p.name === name);
if (!found) throw new Error(`the seeded place "${name}" is missing`);
return found.id;
}
/** Attach the photo to the place, once. */
export async function ensurePlacePhoto(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeId(api, PHOTO_PLACE);
if ((await tripFiles(api)).some((f) => f.original_name === PHOTO_FILE && f.place_id === id)) return;
const res = await api.post(`/api/trips/${tripId}/files`, {
multipart: {
file: { name: PHOTO_FILE, mimeType: 'image/jpeg', buffer: readFileSync(await coverFixture()) },
place_id: String(id),
},
});
if (!res.ok()) throw new Error(`could not attach ${PHOTO_FILE}: ${res.status()} ${await res.text()}`);
}
/** Take the photo off the trip again, trash included, and the picture made from it. */
export async function removePlacePhoto(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeId(api, PHOTO_PLACE);
await api.put(`/api/trips/${tripId}/places/${id}`, { data: { image_url: null } });
for (const file of (await tripFiles(api)).filter((f) => f.original_name === PHOTO_FILE)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
-75
View File
@@ -1,75 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { ensureDawarichConnection, toggleAddon } from './external';
/**
* What the places column's own guides need beyond the seed and `fixtures.ts`.
* Not a `.guide.ts`, so importing it registers no tests.
*/
interface Suggestion {
id: number;
state: string;
name: string;
acceptedPlaceId: number | null;
}
async function tripSuggestions(api: APIRequestContext, tripId: number): Promise<Suggestion[]> {
const res = await api.get(`/api/integrations/dawarich/suggestions?tripId=${tripId}`);
if (!res.ok()) throw new Error(`could not list the Dawarich stays: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as { suggestions?: Suggestion[] };
return body.suggestions ?? [];
}
/**
* Dawarich connected and its stays of the seeded trip read into TREK, at
* least two of them waiting, so the guide can take one over and put another
* aside. The connection keeps automatic checks off (`ensureDawarichConnection`);
* this asks once, by hand, the way Check now does. What an earlier attempt
* accepted or dismissed is taken back first, so a rerun starts from the same list.
*/
export async function ensureDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
await ensureDawarichConnection(api);
const synced = await api.post('/api/integrations/dawarich/sync');
if (!synced.ok())
throw new Error(`could not read the stays from Dawarich: ${synced.status()} ${await synced.text()}`);
await undoDawarichStays(api, tripId);
const waiting = (await tripSuggestions(api, tripId)).filter((s) => s.state === 'new');
if (waiting.length < 2) {
throw new Error(
`Dawarich gave ${waiting.length} waiting stays for the trip; the guide needs two. ` +
'Does the instance detect visits in the uploaded recording (Dawarich > Visits)?'
);
}
}
/** Puts every dismissed stay of the trip back into review. */
export async function resetDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
for (const s of await tripSuggestions(api, tripId)) {
if (s.state !== 'dismissed') continue;
const res = await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state: 'new' } });
if (!res.ok()) throw new Error(`could not put back the stay ${s.name}: ${res.status()} ${await res.text()}`);
}
}
/**
* Tours is on for every install, and with it on the places column has no
* Import file. The file guide pictures the column of an install that switched
* it off, and switches it back on afterwards.
*/
export const setTours = (api: APIRequestContext, enabled: boolean): Promise<void> => toggleAddon(api, 'tours', enabled);
/**
* Takes back what the Dawarich guide did: the places it accepted are deleted,
* which returns their stays to review on the server, and the dismissed stays
* are put back. By the ids the stays carry, never by name: a stay is often
* named like a place of the seed, and deleting by name would take that one too.
*/
export async function undoDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
for (const s of await tripSuggestions(api, tripId)) {
if (s.state !== 'accepted' || s.acceptedPlaceId == null) continue;
const res = await api.delete(`/api/trips/${tripId}/places/${s.acceptedPlaceId}`);
if (!res.ok() && res.status() !== 404)
throw new Error(`could not delete the place from ${s.name}: ${res.status()} ${await res.text()}`);
}
await resetDawarichStays(api, tripId);
}
-337
View File
@@ -1,337 +0,0 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { at, day } from '../dates';
import { seededTrip } from './fixtures';
import { PICTURE_DAY } from './guide';
/**
* What a few road trip guides need beyond `ensureRoadtripFixtures`, each one
* written by the guide that needs it and taken away again in its cleanup: the
* other guides of the screen draw the same rail, and a fuel stop or a ferry
* left behind would change their drive.
*
* Day N of "Autumn in Japan" is `day(N - 10)`: the trip ends on the picture day.
*/
type Place = { id: number; name: string };
type Assignment = { id: number; place_id: number };
async function placesOf(api: APIRequestContext): Promise<Place[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as { places?: Place[] } | Place[];
return Array.isArray(body) ? body : (body.places ?? []);
}
export async function assignmentsOn(api: APIRequestContext, dayIndex: number): Promise<Assignment[]> {
const { tripId, dayIds } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`);
const body = (await res.json()) as { assignments?: Assignment[] };
return body.assignments ?? [];
}
export async function placeIdOf(api: APIRequestContext, name: string): Promise<number | null> {
return (await placesOf(api)).find((p) => p.name === name)?.id ?? null;
}
// ── A fuel stop on day 4, for the fill-level guide ───────────────────────────
/** A motorway service area on the Tomei, between Tokyo and Hakone. */
export const FUEL_STOP = {
name: 'Ebina Service Area',
lat: 35.4178,
lng: 139.3955,
address: 'Ebina, Kanagawa',
description: 'Fuel and food on the Tomei Expressway, halfway to Hakone.',
stop_type: 'fuel',
};
/**
* Put the service area on day 4 between Shinjuku Gyoen and Hakone Shrine, the
* leg it really lies on. Idempotent: an existing one is only put back in place.
*/
export async function ensureFuelStop(api: APIRequestContext): Promise<void> {
const { tripId, dayIds } = seededTrip();
let id = await placeIdOf(api, FUEL_STOP.name);
if (id === null) {
const created = await api.post(`/api/trips/${tripId}/places`, { data: FUEL_STOP });
if (!created.ok())
throw new Error(`could not create "${FUEL_STOP.name}": ${created.status()} ${await created.text()}`);
id = await placeIdOf(api, FUEL_STOP.name);
}
if (id === null) throw new Error(`"${FUEL_STOP.name}" is missing after creating it`);
if (!(await assignmentsOn(api, 3)).some((a) => a.place_id === id)) {
const pinned = await api.post(`/api/trips/${tripId}/days/${dayIds[3]}/assignments`, { data: { place_id: id } });
if (!pinned.ok())
throw new Error(`could not put the fuel stop on day 4: ${pinned.status()} ${await pinned.text()}`);
}
// Right before Hakone Shrine, so the drive is Shinjuku, the service area, Hakone.
const hakone = await placeIdOf(api, 'Hakone Shrine');
const day4 = await assignmentsOn(api, 3);
const fuel = day4.find((a) => a.place_id === id);
const rest = day4.filter((a) => a !== fuel);
const slot = rest.findIndex((a) => a.place_id === hakone);
if (fuel && slot >= 0) {
const orderedIds = [...rest.slice(0, slot), fuel, ...rest.slice(slot)].map((a) => a.id);
await api.put(`/api/trips/${tripId}/days/${dayIds[3]}/assignments/reorder`, { data: { orderedIds } });
}
}
export async function removeFuelStop(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const id = await placeIdOf(api, FUEL_STOP.name);
if (id !== null) await api.delete(`/api/trips/${tripId}/places/${id}`);
}
// ── A ferry on day 3, for the bookings guide ─────────────────────────────────
/** The boat across Lake Ashi, between the two Hakone stops of day 3. */
export const FERRY = {
title: 'Lake Ashi cruise Togendai → Moto-Hakone',
from: { name: 'Togendai Port', lat: 35.2386, lng: 139.0128, time: '11:50' },
to: { name: 'Moto-Hakone Port', lat: 35.1973, lng: 139.025, time: '12:25' },
};
type Booking = { id: number; title: string };
async function bookingsOf(api: APIRequestContext): Promise<Booking[]> {
const { tripId } = seededTrip();
const res = await api.get(`/api/trips/${tripId}/reservations`);
const body = (await res.json()) as { reservations?: Booking[] } | Booking[];
return Array.isArray(body) ? body : (body.reservations ?? []);
}
/**
* A ferry booking with both piers located, so the drive ends at one and starts
* again at the other. 11:50 leaves the half hour of check-in a ferry is pinned
* ahead of the timetable after the stop at Lake Ashi, so the badge reads on time.
*/
export async function ensureFerry(api: APIRequestContext): Promise<void> {
if ((await bookingsOf(api)).some((b) => b.title === FERRY.title)) return;
const { tripId, dayIds } = seededTrip();
const created = await api.post(`/api/trips/${tripId}/reservations`, {
data: {
title: FERRY.title,
type: 'ferry',
status: 'confirmed',
day_id: dayIds[2],
end_day_id: dayIds[2],
reservation_time: at(-7, FERRY.from.time),
reservation_end_time: at(-7, FERRY.to.time),
confirmation_number: 'HKN-3307',
location: FERRY.from.name,
endpoints: [
{
role: 'from',
sequence: 0,
name: FERRY.from.name,
lat: FERRY.from.lat,
lng: FERRY.from.lng,
timezone: 'Asia/Tokyo',
local_date: day(-7),
local_time: FERRY.from.time,
},
{
role: 'to',
sequence: 1,
name: FERRY.to.name,
lat: FERRY.to.lat,
lng: FERRY.to.lng,
timezone: 'Asia/Tokyo',
local_date: day(-7),
local_time: FERRY.to.time,
},
],
},
});
if (!created.ok()) throw new Error(`could not create the ferry: ${created.status()} ${await created.text()}`);
}
export async function removeFerry(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
for (const booking of (await bookingsOf(api)).filter((b) => b.title === FERRY.title)) {
await api.delete(`/api/trips/${tripId}/reservations/${booking.id}`);
}
}
// ── A night found along the route, taken away again ──────────────────────────
/** Cancel the stays booked for a place, with their bookings, then the place. */
export async function removeNightAt(api: APIRequestContext, name: string): Promise<void> {
if (!name) return;
const { tripId } = seededTrip();
const id = await placeIdOf(api, name);
if (id === null) return;
const res = await api.get(`/api/trips/${tripId}/accommodations`);
const body = (await res.json()) as { accommodations?: { id: number; place_id: number }[] };
for (const stay of (body.accommodations ?? []).filter((a) => a.place_id === id)) {
await api.delete(`/api/trips/${tripId}/accommodations/${stay.id}`);
}
await api.delete(`/api/trips/${tripId}/places/${id}`);
}
// ── The Google Maps import, answered without asking Google ───────────────────
/** A route from Hakone round Mount Fuji, the way the preview reads one. */
export const GOOGLE_ROUTE = {
url: 'https://maps.app.goo.gl/hakoneFujiFiveLakes',
stops: [
{ name: 'Gotemba Premium Outlets', lat: 35.3076, lng: 138.964 },
{ name: 'Oshino Hakkai', lat: 35.4602, lng: 138.8327 },
{ name: 'Lake Kawaguchi', lat: 35.5171, lng: 138.7519 },
{ name: 'Fuji viewpoint (dropped pin)', lat: null, lng: null },
],
};
/**
* The preview is the one request that goes to Google, so it is answered here;
* the import itself is TREK's own and runs for real.
*/
export async function stubGooglePreview(page: Page): Promise<void> {
await page.route('**/api/roadtrip/google-maps-preview', (route) =>
route.fulfill({ status: 200, json: { stops: GOOGLE_ROUTE.stops } })
);
}
export async function removeImportedStops(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const names = GOOGLE_ROUTE.stops.filter((s) => s.lat !== null).map((s) => s.name);
for (const place of (await placesOf(api)).filter((p) => names.includes(p.name))) {
await api.delete(`/api/trips/${tripId}/places/${place.id}`);
}
}
// ── Current warnings, answered without the live feeds ────────────────────────
/** A storm over Hakone and the Izu peninsula, the shape GDACS reports one in. */
export const HAZARD_TITLE = 'Tropical storm, green alert';
/**
* The warnings come live from DWD and GDACS, and neither has to have anything
* near Japan on the day the pictures are taken. The feed is answered here.
*/
export async function stubHazards(page: Page): Promise<void> {
const now = PICTURE_DAY.toISOString();
const until = new Date(PICTURE_DAY.getTime() + 36 * 3600_000).toISOString();
// On the context, not the page: in dev the service worker makes this GET itself,
// and only the context's routes see what a service worker asks for.
await page.context().route('**/api/trips/*/roadtrip/hazards*', (route) =>
route.fulfill({
status: 200,
json: {
fetchedAt: now,
sources: [
{ source: 'DWD', status: 'ok' },
{ source: 'GDACS', status: 'ok' },
],
hazards: [
{
id: 'gdacs-tc-demo',
source: 'GDACS',
title: HAZARD_TITLE,
description: 'Heavy rain and gusts of up to 90 km/h around Hakone and the Izu peninsula.',
updatedAt: now,
validUntil: until,
url: 'https://www.gdacs.org/',
alertScore: 0.5,
geometry: {
type: 'Polygon',
coordinates: [
[
[138.82, 35.02],
[139.18, 34.98],
[139.36, 35.16],
[139.3, 35.4],
[138.98, 35.46],
[138.78, 35.26],
[138.82, 35.02],
],
],
},
},
],
},
})
);
}
export async function hideHazards(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
await api.put(`/api/trips/${tripId}/roadtrip/preferences`, { data: { roadtrip_show_hazards: false } });
}
// ── The flight out, as the drive reads it ────────────────────────────────────
/** The seed's flight; it leaves Frankfurt on day 1, after day 1's first stops in Tokyo. */
const FLIGHT_TITLE = 'LH716 FRA → HND';
type Endpoint = {
role: string;
sequence: number;
name: string;
code?: string | null;
lat: number;
lng: number;
timezone?: string | null;
local_date?: string | null;
local_time?: string | null;
};
type FlightRow = {
id: number;
title: string;
reservation_time: string | null;
reservation_end_time: string | null;
day_id: number | null;
end_day_id: number | null;
endpoints?: Endpoint[];
};
let flightAsSeeded: FlightRow | null = null;
async function flightOf(api: APIRequestContext): Promise<FlightRow | null> {
return ((await bookingsOf(api)) as unknown as FlightRow[]).find((b) => b.title === FLIGHT_TITLE) ?? null;
}
async function putFlight(api: APIRequestContext, id: number, data: Partial<FlightRow>): Promise<void> {
const { tripId } = seededTrip();
const res = await api.put(`/api/trips/${tripId}/reservations/${id}`, { data });
if (!res.ok()) throw new Error(`could not move the flight: ${res.status()} ${await res.text()}`);
}
/**
* The drive starts where the flight lands. The seed has it leave Frankfurt at
* 13:05 on day 1, after Senso-ji, so the road trip would drive from Tokyo to
* Frankfurt and back. For these pictures it is the overnight flight it should
* be, into Haneda at 06:50 on day 1, and only its Haneda end is located.
*/
export async function overnightFlight(api: APIRequestContext): Promise<void> {
const flight = await flightOf(api);
if (!flight) throw new Error(`no booking "${FLIGHT_TITLE}" to move overnight`);
flightAsSeeded ??= flight;
const ends = flight.endpoints ?? [];
await putFlight(api, flight.id, {
reservation_time: at(-10, '17:55'),
reservation_end_time: at(-9, '06:50'),
// The server keeps a booking on the trip's first day even when it leaves the
// evening before, so the drive would still start in Frankfurt. A ride without a
// located end at both sides is left to the day plan, so the Frankfurt end goes.
endpoints: ends.filter((e) => e.role !== 'from').map((e) => ({ ...e, local_date: day(-9), local_time: '06:50' })),
});
const moved = await flightOf(api);
if ((moved?.endpoints ?? []).some((e) => e.role === 'from')) {
throw new Error(`the flight still starts in Frankfurt: ${JSON.stringify(moved?.endpoints)}`);
}
}
/** The flight back as the seed has it, for the screens after these. */
export async function restoreFlight(api: APIRequestContext): Promise<void> {
const seeded = flightAsSeeded;
if (!seeded) return;
await putFlight(api, seeded.id, {
reservation_time: seeded.reservation_time,
reservation_end_time: seeded.reservation_end_time,
day_id: seeded.day_id,
end_day_id: seeded.end_day_id,
endpoints: seeded.endpoints,
});
flightAsSeeded = null;
}
@@ -1,190 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { short } from '../dates';
import { drawPdf, seededTrip } from './fixtures';
import { TOUR_NAMES, tourDayOffset } from './tour-fixtures';
/**
* What the tour card's guides need beyond `ensureToursFixtures`: a packing
* category of the via ferrata gear for the ferrata tour to be linked to (two
* of its items packed, so the bar is partly full), the hut booking a guide
* uploads to the hut trek, and the small API helpers the guides clean up with.
* Everything here is idempotent: it finds its rows by name before it makes them.
*/
/** The packing category the ferrata tour gets linked to in `tour-packing-link`. */
export const FERRATA_PACKING = {
category: 'Via ferrata',
items: ['Helmet', 'Climbing harness', 'Via ferrata set', 'Leather gloves', 'Head torch'],
packed: ['Helmet', 'Climbing harness'],
};
/** The confirmation the files guide puts next to the hut trek. */
export const HUT_BOOKING_FILE = 'rifugio-puez-half-board.pdf';
type Res = { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> };
async function json<T>(res: Res, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 500)}`);
return (await res.json()) as T;
}
interface TourRow {
place_id: number;
name: string;
}
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
/** A tour's place id by its name; throws when the fixtures did not make it. */
export async function tourIdOf(api: APIRequestContext, tripId: number, name: string): Promise<number> {
const found = (await toursOf(api, tripId)).find((t) => t.name === name);
if (!found) throw new Error(`no tour "${name}" in trip ${tripId}: ensureToursFixtures did not make it`);
return found.place_id;
}
interface PackingItem {
id: number;
name: string;
category: string | null;
checked?: number | boolean;
}
async function packingOf(api: APIRequestContext, tripId: number): Promise<PackingItem[]> {
const body = await json<{ items?: PackingItem[] } | PackingItem[]>(
await api.get(`/api/trips/${tripId}/packing`),
'packing'
);
return Array.isArray(body) ? body : (body.items ?? []);
}
/**
* The via ferrata gear in the Dolomites packing list, two of it packed, and
* the ferrata tour without a packing link, which is where the guide starts.
*/
export async function ensureTourDetailFixtures(api: APIRequestContext, tripId: number): Promise<void> {
let items = await packingOf(api, tripId);
for (const name of FERRATA_PACKING.items) {
if (items.some((i) => i.name === name && i.category === FERRATA_PACKING.category)) continue;
await json(
await api.post(`/api/trips/${tripId}/packing`, {
data: { name, category: FERRATA_PACKING.category, visibility: 'common' },
}),
`packing item ${name}`
);
}
items = await packingOf(api, tripId);
for (const name of FERRATA_PACKING.packed) {
const item = items.find((i) => i.name === name && i.category === FERRATA_PACKING.category);
if (item && !item.checked) {
await json(await api.put(`/api/trips/${tripId}/packing/${item.id}`, { data: { checked: true } }), `pack ${name}`);
}
}
await unlinkPacking(api, tripId, await tourIdOf(api, tripId, TOUR_NAMES.ferrata));
}
/** Take a tour's packing link away; a tour without one is left as it is. */
export async function unlinkPacking(api: APIRequestContext, tripId: number, placeId: number): Promise<void> {
const res = await api.delete(`/api/trips/${tripId}/tours/${placeId}/packing-link`);
if (!res.ok() && res.status() !== 404)
throw new Error(`unlink packing: ${res.status()} ${(await res.text()).slice(0, 300)}`);
}
/** Delete every tour of the trip with this name (the copies a guide made). Tours are places. */
export async function deleteToursNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
for (const tour of (await toursOf(api, tripId)).filter((t) => t.name === name)) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/** Take the tours of this name out of a collection again. */
export async function unkeepTour(api: APIRequestContext, collectionId: number, name: string): Promise<void> {
const res = await api.get(`/api/addons/collections/${collectionId}/tours`);
if (!res.ok()) return;
const { tours = [] } = (await res.json()) as { tours?: { id: number; name: string }[] };
for (const kept of tours.filter((t) => t.name === name)) {
await api.delete(`/api/addons/collections/${collectionId}/tours/${kept.id}`);
}
}
/** Remove an uploaded file from the trip, out of the bin as well. */
export async function deleteTripFilesNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
const res = await api.get(`/api/trips/${tripId}/files`);
if (!res.ok()) return;
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] };
for (const file of files.filter((f) => f.original_name === name)) {
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
}
}
/** The Fushimi Inari hike of the Japan trip and its linked recordings, as the tour detail reports them. */
async function inariActivities(
api: APIRequestContext
): Promise<{ hikeId: number; activities: { id: number; track: { place_id: number } }[] }> {
const { tripId } = seededTrip();
const hikeId = await tourIdOf(api, tripId, TOUR_NAMES.inariHike);
const detail = await json<{ activities?: { id: number; track: { place_id: number } }[] }>(
await api.get(`/api/trips/${tripId}/tours/${hikeId}`),
'the Fushimi Inari hike'
);
return { hikeId, activities: detail.activities ?? [] };
}
/**
* Unlink the recording from the planned hike, so `tour-activity-link` starts
* from a tour with no actual track. The recording itself stays in the trip as
* an import, which is what the guide then picks.
*/
export async function unlinkInariRecording(api: APIRequestContext): Promise<void> {
const { tripId } = seededTrip();
const { hikeId, activities } = await inariActivities(api);
for (const activity of activities) {
await json(
await api.delete(`/api/trips/${tripId}/tours/${hikeId}/activities/${activity.id}`),
'unlink the recording'
);
}
}
/**
* Take the recording out of the Japan trip again once the activity guides are
* done: `ensureTourRecording` is opt-in because the recording is one more
* tour in a trip every other screen photographs.
*/
export async function removeInariRecording(api: APIRequestContext): Promise<void> {
try {
await unlinkInariRecording(api);
} catch (err) {
console.warn(`[tour-detail] the recording could not be unlinked before it is removed: ${String(err)}`);
}
await deleteToursNamed(api, seededTrip().tripId, TOUR_NAMES.inariRecording);
}
/** The hut's half-board confirmation, drawn on every call so its date follows the trip. */
export function hutBookingFixture(): string {
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, HUT_BOOKING_FILE);
writeFileSync(
file,
drawPdf({
issuer: 'Rifugio Puez',
title: 'Booking confirmation, half board',
reference: 'Booking PZ-2417, Puez-Odle Nature Park, 2475 m',
fields: [
['Guests', '2 adults, alpine club members'],
['Night', `${short(tourDayOffset(4))}, one night`],
['Room', 'Dormitory, 6 beds, 2 places'],
['Board', 'Dinner at 18:30, breakfast from 06:30'],
['Deposit', 'EUR 40 paid, rest in cash at the hut'],
],
footer: 'Cancel by phone up to 48 hours before arrival. No card payment at the hut.',
})
);
return file;
}
-148
View File
@@ -1,148 +0,0 @@
import { expect, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, settle } from './guide';
import { JAPAN_TRIP, TOUR_TRIP } from './tour-fixtures';
import { tripReady } from './trip-shared';
/**
* What the hike and run help pictures need besides `tour-fixtures.ts`: a way
* into the Tours tab of the Dolomites or the Japan trip, a tour opened in the
* editor, a route that has finished planning, and the tours a guide saved
* taken away again. Nothing here creates data of its own; the two tours the
* plan guides save through the UI are deleted by name in their cleanup.
*/
/** The two tours the plan guides save, by the names they type. */
export const SAVED_BY_GUIDES = {
hike: 'Up to Rifugio Firenze',
run: 'Trail run to Rifugio Firenze',
} as const;
async function tripIdOf(page: Page, title: string): Promise<number> {
const res = await page.request.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const found = trips.find((t) => t.title === title);
if (!found) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
return found.id;
}
/**
* The Tours tab of a trip, with its list on the right loaded. `tripReady`
* waits for the Trip settings pill, which every tab of the v5 header carries.
* The account's draft of the trip goes first: the planner restores it on load,
* so a draft the guide before left behind (its page can still write one in the
* second after its cleanup) would open the editor on that tour instead of the
* neutral rail, and a click on a tour row would then ask to discard it.
*/
export async function openToursTab(page: Page, trip: 'summer' | 'japan' = 'summer'): Promise<number> {
const id = await tripIdOf(page, trip === 'japan' ? JAPAN_TRIP : TOUR_TRIP);
await page.request.delete(`/api/trips/${id}/tour-draft`).catch(() => undefined);
await page.goto(`/trips/${id}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
await page.waitForTimeout(1200);
return id;
}
/** The left column: the neutral rail and the editor both carry this name. */
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
/** The right column, Tours of this trip. */
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip', exact: true });
/** A tour's row in Tours of this trip. */
export const tourRow = (page: Page, name: string) =>
toursRail(page)
.getByRole('option', { name: new RegExp(`^${name}`) })
.first();
/** Words the status dot beside the editor's title carries while it is still working. */
const BUSY = /^(Calculating[a-z ]* route|Loading elevation|Waiting to calculate|Saving)/;
/** The route of the open tour has been planned and its heights are in. */
export async function routeSettled(page: Page, timeout = 120_000): Promise<void> {
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout });
await expect(page.getByTestId('tour-planner-status').getByLabel(BUSY)).toHaveCount(0, { timeout });
await settle(page);
}
/** Open a saved tour from the right column in the editor and wait for its route. */
export async function openTour(page: Page, name: string): Promise<void> {
await tourRow(page, name).click();
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('#tour-planner-name')).toHaveValue(name, { timeout: 30_000 });
await routeSettled(page);
await page.waitForTimeout(800);
await settle(page);
}
/** Plan new tour from the neutral rail, and the editor that opens. */
export async function startNewTour(page: Page): Promise<void> {
await editorRail(page).getByRole('button', { name: 'Plan new tour' }).click();
await expect(editorRail(page).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
timeout: 15_000,
});
await settle(page);
}
/** Pick a tour type in the Tour type field. */
export async function pickTourType(page: Page, type: 'hike' | 'run'): Promise<void> {
const option = page.getByTestId(`tour-type-option-${type}`);
if (!(await option.isVisible().catch(() => false))) await page.getByTestId('tour-type-select').click();
await option.click();
await settle(page);
}
/** Add stops from the trip's own places, through Trip places under the waypoints. */
export async function addTripPlaces(page: Page, ...names: string[]): Promise<void> {
const picker = page.getByTestId('trip-place-picker');
if (!(await picker.isVisible().catch(() => false))) await page.getByTestId('tour-planner-add-place').click();
await expect(picker).toBeVisible();
const search = picker.getByPlaceholder("Search the trip's places");
for (const name of names) {
await search.fill(name);
await picker.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
await page.waitForTimeout(400);
}
await search.fill('');
await picker.getByRole('button', { name: 'Close', exact: true }).click();
await expect(picker).toHaveCount(0);
}
/** The Details section of the editor, unfolded. */
export async function openDetails(page: Page): Promise<void> {
const show = editorRail(page).getByRole('button', { name: 'Show details', exact: true });
if (await show.isVisible().catch(() => false)) await show.click();
await expect(page.locator('#tour-planner-details')).toBeVisible();
}
/** Delete the tours a guide saved through the UI, so the next guide finds the list of the seed. */
export async function deleteToursNamed(page: Page, tripId: number, ...names: string[]): Promise<void> {
const res = await page.request.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return;
const { tours = [] } = (await res.json()) as { tours?: { place_id: number; name: string }[] };
for (const tour of tours.filter((t) => names.includes(t.name))) {
await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/**
* Forget the editor's unsaved draft of the trip: on the account, where the
* planner keeps it now and restores it on the next page, and the copy older
* builds left in this browser, so a reload starts clean.
*/
export async function forgetDraft(page: Page, tripId: number): Promise<void> {
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page
.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Storage blocked: nothing was kept either.
}
}, tripId)
.catch(() => undefined);
}
-404
View File
@@ -1,404 +0,0 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_GPX, TOUR_NAMES, TOUR_TRIP, tourGpxFixture } from './tour-fixtures';
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
/**
* What the horse ride guides share: the way into the Tours tab with the
* Seiser Alm in view, the horse routes the Horse routes card finds there, and
* the GPX file of a recorded ride. Not a `.guide.ts`, so importing it
* registers no tests.
*
* The horse routes come from OpenStreetMap through the server (TREK Places,
* else Overpass), and a fresh database has neither cached. What the area has
* is asked once, in `beforeAll`: when it holds a section long enough to ride
* and a second one a short way off its end (the gap guide needs both), that
* answer is what the card is given for the whole run, so every picture shows
* the same list. When it does not, the card is given two routes cut from the
* ride the fixtures saved on the Seiser Alm, whose line follows real ways
* (a horse route section where one was found, farm tracks otherwise).
*/
type LatLng = [number, number];
interface HorseSection {
number: number;
geometry: LatLng[];
length_m: number;
access: { value: string; length_m: number }[];
signposted: 'with' | 'against' | null;
}
interface HorseRoute {
id: string;
name: string | null;
ref: string | null;
osmUrl: string;
continues: boolean;
sections: HorseSection[];
}
interface HorseAnswer {
routes: HorseRoute[];
truncated: boolean;
source: 'openstreetmap';
attribution: string;
}
/** Which route and section each guide takes, by the test ids the card and the dialog carry. */
export interface HorsePick {
routeId: string;
section: number;
}
export interface HorsePlan {
answer: HorseAnswer;
/** The section the ride starts with. */
first: HorsePick;
/** A section that starts away from the end of `first`, for the gap guide. */
gap: HorsePick;
/** Where the answer came from, for the run's log. */
source: 'live' | 'demo';
}
/** The name the plan guide saves its new ride under; its cleanup deletes it. */
export const NEW_RIDE = 'Morning ride on the Seiser Alm';
/** The recorded ride the import guide brings in, and its file. */
export const RECORDED_RIDE = { name: 'Rasciesa ride', file: 'rasciesa-ride.gpx' };
// ── The trip ──────────────────────────────────────────────────────────────────
export async function tourTripId(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const found = trips.find((t) => t.title === TOUR_TRIP);
if (!found) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
return found.id;
}
interface TourListRow {
place_id: number;
name: string;
tour_type?: string;
}
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourListRow[]> {
const res = await api.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return [];
return ((await res.json()) as { tours?: TourListRow[] }).tours ?? [];
}
/** Delete the tours a guide made, by name. A tour is a place, and goes the way a place goes. */
export async function deleteToursNamed(api: APIRequestContext, ...names: string[]): Promise<void> {
const tripId = await tourTripId(api);
for (const tour of (await toursOfTrip(api, tripId)).filter((t) => names.includes(t.name))) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
/** Drop the caller's unsaved editor draft of the trip; a trip without one answers 404, which is fine. */
export async function forgetTourDraft(api: APIRequestContext, tripId?: number): Promise<void> {
const id = tripId ?? (await tourTripId(api));
const res = await api.delete(`/api/trips/${id}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[trip-tour-horse] could not drop the tour draft: ${res.status()}`);
}
const rawKey = (page: Page) =>
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing)\.[A-Za-z0-9.]+$/);
/** The trip tab bar's Tours tab: a plain button named after the tab. */
export const toursTabButton = (page: Page) => page.getByRole('button', { name: 'Tours', exact: true }).first();
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) =>
tripTours(page).getByRole('option').filter({ hasText: name }).first();
/**
* Let a pan of the map land. Leaflet times it from `+new Date()` and the clock
* is pinned to the picture day, so a pan starts and never gets anywhere; moving
* the pinned clock on a few seconds ends it, as `finishMapPan` does on the
* road trip screen.
*/
export async function finishMapPan(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/**
* Open the Dolomites trip on the Tours tab with the Seiser Alm in view.
*
* A new draft keeps the map where it is, and the planner opens on the area the
* plan map showed last (`usePlannerFocusPoints`), so the plan is opened on day 2,
* the day of the horse ride, and the Tours tab is reached by its button rather
* than by the address: a page load would forget that area.
*
* The editor's unsaved draft is kept on the account per trip now, not in the
* browser, so a draft another guide left on this trip comes back into the left
* rail on load: Plan new tour is then gone, and a click on a saved tour asks
* first whether to discard it. It is dropped before the page opens.
*/
export async function openToursTab(page: Page): Promise<number> {
const tripId = await tourTripId(page.request);
await forgetTourDraft(page.request, tripId);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
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 tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await selectDay(page, 2);
await closeDayDetails(page);
await finishMapPan(page);
await toursTabButton(page).click();
await expect(tripTours(page)).toBeVisible({ timeout: 30_000 });
await expect(
tourRow(page, TOUR_NAMES.horse),
`no "${TOUR_NAMES.horse}": did ensureToursFixtures make it?`
).toBeVisible({
timeout: 30_000,
});
await finishMapPan(page);
return tripId;
}
/** The saved ride open in the editor, its line framed. */
export async function openSavedRide(page: Page): Promise<void> {
await openToursTab(page);
const row = tourRow(page, TOUR_NAMES.horse);
await row.click();
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(page.getByRole('region', { name: 'Horse routes' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await finishMapPan(page);
}
// ── The horse routes ──────────────────────────────────────────────────────────
const R = 6_371_000;
function metres(a: LatLng, b: LatLng): number {
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function lengthOf(line: LatLng[]): number {
return line.slice(1).reduce((sum, p, i) => sum + metres(line[i], p), 0);
}
/** The stretch of `line` from `from` to `to`, as shares of its length. */
function cut(line: LatLng[], from: number, to: number): LatLng[] {
const total = lengthOf(line);
const out: LatLng[] = [];
let run = 0;
for (let i = 0; i < line.length; i++) {
if (i > 0) run += metres(line[i - 1], line[i]);
const share = run / total;
if (share >= from && share <= to) out.push(line[i]);
}
return out;
}
function lineOf(raw: unknown): LatLng[] {
const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((p): p is number[] => Array.isArray(p) && p.length >= 2)
.map((p) => [Number(p[0]), Number(p[1])] as LatLng);
}
/** The saved ride's line, from its detail or, failing that, from the trip's places. */
async function savedRideLine(api: APIRequestContext, tripId: number): Promise<LatLng[]> {
const ride = (await toursOfTrip(api, tripId)).find((t) => t.name === TOUR_NAMES.horse);
if (!ride) throw new Error(`no "${TOUR_NAMES.horse}": call ensureToursFixtures first`);
const detail = await api.get(`/api/trips/${tripId}/tours/${ride.place_id}`);
if (detail.ok()) {
const body = (await detail.json()) as { tour?: { route_geometry?: unknown }; route_geometry?: unknown };
const line = lineOf(body.tour?.route_geometry ?? body.route_geometry);
if (line.length >= 2) return line;
}
const res = await api.get(`/api/trips/${tripId}/places`);
const body = (await res.json()) as
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === ride.place_id);
const line = lineOf(place?.route_geometry);
if (line.length < 2) throw new Error(`"${TOUR_NAMES.horse}" has no line to cut horse routes from`);
return line;
}
function bboxAround(line: LatLng[], pad: number): string {
const lats = line.map((p) => p[0]);
const lngs = line.map((p) => p[1]);
return [Math.min(...lats) - pad, Math.min(...lngs) - pad, Math.max(...lats) + pad, Math.max(...lngs) + pad]
.map((v) => v.toFixed(5))
.join(',');
}
/** Where a section starts when the dialog offers it to a ride ending at `end` (`preferReversed`). */
function offeredStart(section: HorseSection, end: LatLng | null): LatLng {
const first = section.geometry[0];
const last = section.geometry[section.geometry.length - 1];
if (!end) return section.signposted === 'against' ? last : first;
return metres(end, last) < metres(end, first) ? last : first;
}
function offeredEnd(section: HorseSection, end: LatLng | null): LatLng {
const start = offeredStart(section, end);
const first = section.geometry[0];
return start === first ? section.geometry[section.geometry.length - 1] : first;
}
/**
* The two picks the guides need from an answer, or null where it cannot give
* them: a first section of at least 600 m on an empty ride, and another
* section whose nearer end lies between 100 m and 3 km from where that one
* ends, so the dialog names a gap that is worth bridging by hand.
*/
function picksOf(answer: HorseAnswer): { first: HorsePick; gap: HorsePick } | null {
const all = answer.routes.flatMap((route) => route.sections.map((section) => ({ route, section })));
const first = all.filter((x) => x.section.length_m >= 600).sort((a, b) => b.section.length_m - a.section.length_m)[0];
if (!first) return null;
const end = offeredEnd(first.section, null);
let best: { pick: HorsePick; gap: number } | null = null;
for (const { route, section } of all) {
if (route.id === first.route.id && section.number === first.section.number) continue;
if (section.length_m < 300) continue;
const gap = metres(end, offeredStart(section, end));
if (gap > 100 && gap < 3000 && (!best || gap < best.gap))
best = { pick: { routeId: route.id, section: section.number }, gap };
}
if (!best) return null;
return { first: { routeId: first.route.id, section: first.section.number }, gap: best.pick };
}
/** Two routes along the saved ride's own line: two sections end to end, and one a short gap further on. */
function demoAnswer(line: LatLng[]): HorseAnswer {
const [lat, lng] = line[Math.floor(line.length / 2)];
const osmUrl = `https://www.openstreetmap.org/#map=14/${lat.toFixed(4)}/${lng.toFixed(4)}`;
const section = (
number: number,
from: number,
to: number,
access: [string, number][],
signposted: HorseSection['signposted']
): HorseSection => {
const geometry = cut(line, from, to);
const length = lengthOf(geometry);
return {
number,
geometry,
length_m: Math.round(length),
access: access.map(([value, share]) => ({ value, length_m: Math.round(length * share) })),
signposted,
};
};
return {
routes: [
{
id: 'relation/1',
name: 'Seiser Alm bridle path',
ref: null,
osmUrl,
continues: true,
sections: [
section(
1,
0,
0.38,
[
['designated', 0.8],
['unknown', 0.2],
],
'with'
),
section(2, 0.38, 0.62, [['yes', 1]], null),
],
},
{
id: 'relation/2',
name: 'Saltria horse trail',
ref: null,
osmUrl,
continues: false,
sections: [
section(
1,
0.72,
1,
[
['permissive', 0.6],
['unknown', 0.4],
],
null
),
],
},
],
truncated: false,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
};
}
/** The horse routes every guide of the screen gets, worked out once per run. */
export async function horsePlan(api: APIRequestContext): Promise<HorsePlan> {
const tripId = await tourTripId(api);
const line = await savedRideLine(api, tripId);
try {
const res = await api.get(`/api/trips/${tripId}/tours/horse-routes?bbox=${bboxAround(line, 0.02)}`, {
timeout: 60_000,
});
if (res.ok()) {
const answer = (await res.json()) as HorseAnswer;
const picks = answer.routes?.length ? picksOf(answer) : null;
if (picks) return { answer, ...picks, source: 'live' };
}
} catch {
// Neither TREK Places nor Overpass answered: the demo routes below.
}
const answer = demoAnswer(line);
const picks = picksOf(answer);
if (!picks) throw new Error(`"${TOUR_NAMES.horse}" is too short to cut two horse routes from`);
console.warn(
'[trip-tour-horse] no usable horse routes on the Seiser Alm; the card is given two cut from the saved ride'
);
return { answer, ...picks, source: 'demo' };
}
/** Hand the card this answer whatever view it asks for, so the list is the same in every picture. */
export async function serveHorseRoutes(page: Page, plan: HorsePlan): Promise<void> {
// On the context, not the page: in dev the service worker makes this GET itself,
// and only the context's routes see what a service worker asks for.
await page.context().route(/\/api\/trips\/\d+\/tours\/horse-routes/, (route) => route.fulfill({ json: plan.answer }));
}
/** A route's title as the card and the dialog print it. */
export function routeTitle(plan: HorsePlan, routeId: string): string {
const route = plan.answer.routes.find((r) => r.id === routeId);
return route?.name ?? route?.ref ?? 'Unnamed horse route';
}
// ── The recorded ride ─────────────────────────────────────────────────────────
/**
* A ride recorded on a GPS device, as a file to import: the recorded Rasciesa
* track of the fixtures, named as a ride. Its points, heights and times stay
* those of the recording.
*/
export async function recordedRideFile(): Promise<string> {
const source = await tourGpxFixture('rasciesa');
const file = path.join(path.dirname(source), RECORDED_RIDE.file);
const gpx = readFileSync(source, 'utf8').split(TOUR_GPX.rasciesa.name).join(RECORDED_RIDE.name);
writeFileSync(file, gpx);
return file;
}
@@ -1,203 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { TOUR_GPX, TOUR_NAMES, tourGpxFixture } from './tour-fixtures';
/**
* What the "getting tours in" guides (`trip-tour-import.guide.ts`) need on top
* of `ensureToursFixtures`: a few API helpers to put the Dolomites tours back
* after a guide imported, planned over or took one over, and the wanderer
* instance the trail import is pictured against.
*
* wanderer is a service outside TREK, set up like the ones in `external.ts`:
* its address and an API token come from `e2e/help/media.env`
* (HELP_MEDIA_WANDERER_URL, HELP_MEDIA_WANDERER_TOKEN, and
* HELP_MEDIA_WANDERER_INSECURE=true for a self-signed certificate). The
* fixture uploads its own trails into that account, so the picture does not
* depend on what happens to be in it. Without the variables only the wanderer
* guide fails, loudly, at its start. The address has to be the machine's LAN
* address and the media backend needs ALLOW_INTERNAL_NETWORK=true, as for the
* other services (README, "The guides that show another service").
*/
// ── TREK ──────────────────────────────────────────────────────────────────────
export interface TourRowLite {
place_id: number;
name: string;
has_waypoints?: boolean;
has_recorded_track?: boolean;
}
async function ok<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 400)}`);
return (await res.json()) as T;
}
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
const { trips } = await ok<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === title);
if (!trip) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
return trip.id;
}
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourRowLite[]> {
return (await ok<{ tours: TourRowLite[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
export async function tourNamed(
api: APIRequestContext,
tripId: number,
name: string
): Promise<TourRowLite | undefined> {
return (await toursOfTrip(api, tripId)).find((t) => t.name === name);
}
/** Deletes every tour of the trip with one of these names; a tour is a place, so it goes the way of a place. */
export async function deleteToursNamed(api: APIRequestContext, tripId: number, ...names: string[]): Promise<void> {
for (const tour of await toursOfTrip(api, tripId)) {
if (!names.includes(tour.name)) continue;
const res = await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
if (!res.ok()) console.warn(`[tour-import] could not delete "${tour.name}": ${res.status()}`);
}
}
/** The editor's draft is kept on the server per trip and user; a guide that left one behind would restore it into the next. */
export async function forgetTourDraft(api: APIRequestContext, tripId: number): Promise<void> {
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[tour-import] could not drop the tour draft of trip ${tripId}: ${res.status()}`);
}
/**
* Puts an import that was planned over in place back to its recorded track,
* which is what the seed has. Nothing to do for a tour that is still as
* imported.
*/
export async function restoreRecordedTrack(api: APIRequestContext, tripId: number, name: string): Promise<void> {
const tour = await tourNamed(api, tripId, name);
if (!tour?.has_recorded_track) return;
const res = await api.post(`/api/trips/${tripId}/tours/${tour.place_id}/restore-recorded`);
if (!res.ok())
throw new Error(`could not restore the recorded track of "${name}": ${res.status()} ${await res.text()}`);
}
// ── wanderer ──────────────────────────────────────────────────────────────────
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(/\/+$/, '');
}
/**
* The trails the fixture keeps in the wanderer account, by name, each drawn
* from a recorded walk of `tour-fixtures.ts` near the Dolomites trip. The
* guide imports the first; the second is there so the list is a list.
*/
export const WANDERER_TRAILS = [
{ name: TOUR_GPX.rasciesa.name, gpx: 'rasciesa' as const },
{ name: 'Seceda ridge from Col Raiser', gpx: 'seceda' as const },
];
/** The trail the guide imports into the Dolomites trip. */
export const WANDERER_TRAIL = WANDERER_TRAILS[0].name;
interface WandererHit {
id: string;
name: string;
}
async function wandererSearch(base: string, token: string, q: string): Promise<WandererHit[]> {
const res = await fetch(`${base}/api/v1/search/trails`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ q, options: { limit: 20 } }),
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`wanderer search: HTTP ${res.status} ${await res.text()}`);
return ((await res.json()) as { hits?: WandererHit[] }).hits ?? [];
}
/** The GPX under the trail's own name: wanderer names an uploaded trail after its track. */
function renamed(file: string, name: string): string {
return readFileSync(file, 'utf8').replace(/<name>[^<]*<\/name>/g, `<name>${name}</name>`);
}
async function upload(base: string, token: string, name: string, gpx: string): Promise<void> {
const headers = { Authorization: `Bearer ${token}` };
// wanderer's documented upload takes the file as the request body.
const raw = await fetch(`${base}/api/v1/trail/upload`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/gpx+xml' },
body: gpx,
signal: AbortSignal.timeout(60_000),
});
if (raw.ok) return;
// Older instances read it as a form field instead.
const form = new FormData();
form.append('file', new Blob([gpx], { type: 'application/gpx+xml' }), `${name}.gpx`);
const multipart = await fetch(`${base}/api/v1/trail/upload`, {
method: 'PUT',
headers,
body: form,
signal: AbortSignal.timeout(60_000),
});
if (!multipart.ok)
throw new Error(`wanderer upload of "${name}": HTTP ${raw.status} / ${multipart.status} ${await multipart.text()}`);
}
/** Puts the fixture's trails into the account the token belongs to, once, and waits until the search finds them. */
export async function ensureWandererTrails(): Promise<void> {
const base = env('HELP_MEDIA_WANDERER_URL');
const token = env('HELP_MEDIA_WANDERER_TOKEN');
for (const trail of WANDERER_TRAILS) {
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) continue;
await upload(base, token, trail.name, renamed(await tourGpxFixture(trail.gpx), trail.name));
// The search is an index the upload reaches a moment later.
for (let i = 0; i < 30; i++) {
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) break;
if (i === 29) throw new Error(`wanderer never listed the uploaded trail "${trail.name}"`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
/** The admin switch under Tours, then the signed-in user's connection, tested loudly. */
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
await ensureWandererTrails();
await ok(await api.put('/api/admin/tours-wanderer', { data: { enabled: true } }), 'switch wanderer on');
await ok(
await api.put('/api/integrations/wanderer/settings', {
data: {
url: env('HELP_MEDIA_WANDERER_URL'),
token: env('HELP_MEDIA_WANDERER_TOKEN'),
allowInsecureTls: process.env.HELP_MEDIA_WANDERER_INSECURE === 'true',
},
}),
'save the wanderer connection'
);
const test = await ok<{ ok: boolean; code?: string }>(
await api.post('/api/integrations/wanderer/test', { data: {} }),
'test the wanderer connection'
);
if (!test.ok)
throw new Error(
`wanderer is not reachable from the media backend: ${test.code ?? 'unknown'} (is ALLOW_INTERNAL_NETWORK=true?)`
);
}
/** Takes the connection away and switches wanderer off again, as every other screen expects it. */
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
await api.delete('/api/integrations/wanderer/settings');
await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
}
/** The recorded walk the import guides pick in the file chooser. */
export const importFile = async (): Promise<string> => path.resolve(await tourGpxFixture('rasciesa'));
export const IMPORTED_NAME = TOUR_GPX.rasciesa.name;
export const RECORDED_NAME = TOUR_NAMES.recorded;
-154
View File
@@ -1,154 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { settle } from './guide';
import { openTourTrip, TOUR_NAMES } from './tour-fixtures';
/**
* What the Tours-tab map guides share: the locators of the layer pill, the
* 3D dock and the card that searches along a tour, and the way into the
* planner with one tour open. Nothing here writes to the server: every guide
* on this screen only looks, or changes the open draft without saving it,
* and a draft lives in the browser's storage, which every test starts empty.
*/
/** The tour by its role, for the guides: the hike over the via ferrata is the default, the hut trek has named stops. */
export const MAP_TOUR = TOUR_NAMES.ferrata;
export const ALONG_TOUR = TOUR_NAMES.hutToHut;
export const PADDLE_TOUR = TOUR_NAMES.paddle;
/** The ski week's tour with pistes around it: the Sella Ronda where the piste router planned it, else the winter walk. */
export const SKI_TOURS = [TOUR_NAMES.skiRonda, TOUR_NAMES.winterWalk];
/** The right rail of the Tours tab, and one tour's row in it. */
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) =>
tripTours(page).getByRole('option').filter({ hasText: name }).first();
/**
* The layer pill at the bottom left of the map (`TourMapLayerSwitcher`): a
* group named after the setting it mirrors, "Map Style". The flat map and the
* 3D map each render exactly one.
*/
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
/** A base map's segment; its name is its visible word on the flat map, its aria-label in the compact 3D pill. */
export const baseSegment = (page: Page, label: 'Map' | 'Topo' | 'Hiking' | 'Cycling' | 'Satellite') =>
layerPill(page).getByRole('button', { name: label, exact: true });
export const terrainToggle = (page: Page) => layerPill(page).getByRole('button', { name: '3D', exact: true });
export const pistesToggle = (page: Page) => page.getByTestId('map-overlay-pistes');
export const seamarksToggle = (page: Page) => page.getByTestId('map-overlay-seamarks');
export const pisteKeyButton = (page: Page) => page.getByTestId('piste-key-button');
/** The key's popover, a portal group named like its button. */
export const pisteKeyPanel = (page: Page) => page.getByRole('group', { name: 'Piste key' });
/** The 3D map's own canvas (MapLibre), and the controls in its bottom row. */
export const terrainCanvas = (page: Page) => page.locator('.maplibregl-canvas');
export const playButton = (page: Page) => page.getByRole('button', { name: 'Play flyover', exact: true });
export const pauseButton = (page: Page) => page.getByRole('button', { name: 'Pause', exact: true });
export const restartButton = (page: Page) => page.getByRole('button', { name: 'Start over', exact: true });
export const overviewButton = (page: Page) => page.getByRole('button', { name: 'Overview', exact: true });
export const speedGroup = (page: Page) => page.getByRole('group', { name: 'Speed' });
export const reliefSlider = (page: Page) => page.getByRole('slider', { name: 'Relief' });
export const gradeSwitch = (page: Page) => page.getByRole('button', { name: 'Grade', exact: true });
export const gradeMix = (page: Page) => page.getByRole('button', { name: 'Grade mix', exact: true });
/** The unfolded bands above the grade mix pill (`id="tour-terrain-grades"`). */
export const gradeBands = (page: Page) => page.locator('#tour-terrain-grades');
/** "Along the tour": a RailCard, a section named by its title. */
export const alongCard = (page: Page) => page.getByRole('region', { name: 'Along the tour' });
export const alongCategories = (page: Page) => page.getByTestId('tour-poi-categories');
/** The open category list, a portal group named "What to look for". */
export const alongCategoryList = (page: Page) => page.getByRole('group', { name: 'What to look for' });
export const alongCorridor = (page: Page) =>
alongCard(page).getByRole('group', { name: 'Within this distance of the route' });
export const alongSearch = (page: Page) => page.getByTestId('tour-poi-search');
export const alongList = (page: Page) => page.getByTestId('tour-poi-list');
/** A place found that is not a stop of the tour yet: its row carries a distance, not the "In the tour" pill. */
export const alongCandidates = (page: Page) =>
alongList(page)
.locator('li')
.filter({ hasNot: page.getByText('In the tour', { exact: true }) });
export const poiCard = (page: Page) => page.getByTestId('tour-poi-card-map');
export const poiSlot = (page: Page) => page.getByTestId('tour-poi-slot');
export const poiAdd = (page: Page) => page.getByTestId('tour-poi-add');
export const poiOutcome = (page: Page) => page.getByTestId('tour-poi-outcome');
export const poiUndo = (page: Page) => page.getByTestId('tour-poi-undo');
/**
* Open the Tours tab of the Dolomites (or the ski week) and open `name` in the
* planner: its row is selected and the layer pill shows the maps of its type.
* A multi-day tour opens whole, as a click on its row does.
*/
export async function openTourInPlanner(page: Page, name: string, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
await openTourTrip(page, { trip, tab: 'tour-planner' });
const row = tourRow(page, name);
await expect(row, `no tour "${name}" in the Tours tab: did ensureToursFixtures make it?`).toBeVisible({
timeout: 30_000,
});
await row.click();
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(layerPill(page)).toBeVisible();
// The saved route is drawn and framed once the planner has it; the summary card says so.
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** The first of the ski week's tours that is in the trip. */
export async function openSkiTour(page: Page): Promise<string> {
await openTourTrip(page, { trip: 'ski', tab: 'tour-planner' });
for (const name of SKI_TOURS) {
if (await tourRow(page, name).count()) {
await openTourInPlanner(page, name, 'ski');
return name;
}
}
throw new Error(`none of ${SKI_TOURS.join(', ')} is in the ski week: did ensureToursFixtures make them?`);
}
/** Switch the open tour to 3D and wait until the landscape is drawn and the dock is there. */
export async function enterTerrain(page: Page): Promise<void> {
await terrainToggle(page).click();
await expect(terrainCanvas(page)).toBeVisible({ timeout: 60_000 });
await expect(overviewButton(page)).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Wait until the corridor has loaded and Play flyover replaces the ring. */
export async function waitForFlyover(page: Page): Promise<void> {
await expect(playButton(page).or(pauseButton(page))).toBeVisible({ timeout: 120_000 });
}
/** Ask "Along the tour" with what it starts with (the first three kinds, 500 m) and wait for the list. */
export async function searchAlong(page: Page): Promise<void> {
await alongCard(page).scrollIntoViewIfNeeded();
await alongSearch(page).click();
await expect(alongSearch(page)).toHaveText(/Search again/, { timeout: 90_000 });
await expect(alongList(page), 'the search along the tour found nothing (Overpass or the places index)').toBeVisible();
}
/**
* The first place found whose card can take it in as a stop: one that is not
* in the tour already and whose suggested slot is not on a fixed stretch.
* Tries them in their card, closes the card again and returns the row's index
* among `alongCandidates`, for `candidateRow`.
*/
export async function pickAddablePlace(page: Page): Promise<number> {
const rows = alongCandidates(page);
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i++) {
await candidateRow(page, i).click();
await expect(poiCard(page)).toBeVisible();
const addable = await poiAdd(page).isEnabled();
await closeOverlay(page);
await expect(poiCard(page)).toBeHidden();
if (addable) return i;
}
throw new Error('no place along the tour can be added as a stop');
}
/** The button of the `index`-th place found that is not in the tour yet. */
export const candidateRow = (page: Page, index: number): Locator =>
alongCandidates(page).nth(index).getByRole('button').first();
/** Close whatever popover or card is open over the map. */
export async function closeOverlay(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
@@ -1,382 +0,0 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { day } from '../dates';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_NAMES, TOUR_TRIP, TOUR_TRIP_DATES } from './tour-fixtures';
/**
* What the paddle tour pictures need beyond `ensureToursFixtures`.
*
* The boat tour "Rowing round Lago di Braies" on day 9 of "Summer in the
* Dolomites" is the one every paddle picture shows. Its four legs are drawn by
* hand along the shore, because a fresh database holds no water data, and the
* pictures keep it that way: TREK fetches a region's water data by itself
* when a leg cannot be planned, and an admin planning sets that download
* going at once. `holdPaddleDownloads` switches that off for the run and
* `restorePaddleDownloads` puts the setting back.
*
* Three guides need a little more, and each makes it in its `start` and takes
* it away in its `cleanup` (and `afterAll` again, for a run that failed):
*
* - the wind forecast needs a day within the next two weeks, which the
* summer trip does not have, so `ensureWindTrip` makes a short trip from
* tomorrow with the boat tour taken over onto its first day;
* - the checklist lists what is saved, so `ensureCarryCopy` makes a copy of
* the boat tour for that guide to save a portage and a lock on;
* - the recorded track is a GPX file, written by `paddleGpxFixture` from the
* boat tour's own line, with the scatter and the times a watch records.
*/
export const PADDLE_TOUR = TOUR_NAMES.paddle;
/** The trip the wind guide makes and deletes again: two days from tomorrow. */
export const WIND_TRIP = 'Weekend at Lago di Braies';
/** The copy the checklist guide saves a portage and a lock on. */
export const CARRY_COPY = 'Lago di Braies with a carry';
/** The recorded track the import guide brings in, by its file and the name inside it. */
export const PADDLE_GPX = { name: 'Lago di Braies kayak round', file: 'lago-di-braies-kayak-round.gpx' } as const;
async function json<T>(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
interface TourRow {
place_id: number;
name: string;
tour_type?: string;
}
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number | null> {
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
return trips.find((t) => t.title === title)?.id ?? null;
}
export async function summerTripId(api: APIRequestContext): Promise<number> {
const id = await tripIdByTitle(api, TOUR_TRIP);
if (id === null) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
return id;
}
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
.tours;
}
/** The boat tour's place id in the summer trip. */
export async function paddleTourId(api: APIRequestContext): Promise<number> {
const tripId = await summerTripId(api);
const tour = (await toursOf(api, tripId)).find((t) => t.name === PADDLE_TOUR);
if (!tour)
throw new Error(
`no tour "${PADDLE_TOUR}": the Overpass shore of Lago di Braies was not there when the fixtures ran`
);
return tour.place_id;
}
/** Deletes the tours of a trip the test picks; a tour is a place, and goes the way a place goes. */
export async function deleteToursWhere(
api: APIRequestContext,
tripId: number,
pick: (tour: TourRow) => boolean
): Promise<void> {
for (const tour of (await toursOf(api, tripId)).filter(pick)) {
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
}
}
// ── The editor's draft ────────────────────────────────────────────────────────
/**
* The planner keeps an unsaved edit on the account per trip and restores it on
* the next page load. A paddle guide that stops half way (its cleanup does not
* run then) would leave its carry and lock in that draft, and the next guide
* would open on it: the editor instead of the neutral rail, and a click on a
* tour row asking Discard unsaved tour changes. A trip without a draft answers
* 404, which is what is wanted.
*/
export async function forgetPaddleDraft(api: APIRequestContext): Promise<void> {
const tripId = await summerTripId(api);
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
if (!res.ok() && res.status() !== 404)
console.warn(`[trip-tour-paddle] could not drop the tour draft: ${res.status()}`);
}
/**
* The same before a guide opens its page, and the copy older builds kept in the
* browser as `tour-draft-<tripId>`, dropped before the app reads it.
*/
export async function forgetPaddleDraftBeforeLoad(page: Page): Promise<void> {
await forgetPaddleDraft(page.request);
await page.addInitScript(() => {
try {
for (const key of Object.keys(localStorage)) if (key.startsWith('tour-draft-')) localStorage.removeItem(key);
} catch {
// Storage blocked: there is no draft to restore either.
}
});
}
// ── The download switch ───────────────────────────────────────────────────────
type AutoLoad = { mode: 'auto' | 'ask' | 'off'; max_mb: number };
let heldFrom: AutoLoad | null = null;
/**
* No water data downloads while the paddle pictures run. Without this, the
* first leg the planner cannot plan makes the server fetch the smallest
* Geofabrik extract around Lago di Braies (all of north-east Italy) and build
* a water network from it, for minutes, because the picture user is an admin.
*/
export async function holdPaddleDownloads(api: APIRequestContext): Promise<void> {
const current = await json<AutoLoad>(await api.get('/api/admin/paddle-regions/settings'), 'paddle region settings');
if (heldFrom === null) heldFrom = current;
if (current.mode !== 'off') {
await json(
await api.put('/api/admin/paddle-regions/settings', { data: { mode: 'off', max_mb: current.max_mb } }),
'hold paddle downloads'
);
}
}
export async function restorePaddleDownloads(api: APIRequestContext): Promise<void> {
if (heldFrom === null) return;
await api.put('/api/admin/paddle-regions/settings', { data: heldFrom });
heldFrom = null;
}
// ── The wind trip ─────────────────────────────────────────────────────────────
export async function deleteTripsNamed(api: APIRequestContext, title: string): Promise<void> {
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
for (const trip of trips.filter((t) => t.title === title)) await api.delete(`/api/trips/${trip.id}`);
}
/**
* A two-day trip from tomorrow with the boat tour taken over onto its first
* day at 10:00, so its Wind card has a forecast to show. Made fresh every time.
*/
export async function ensureWindTrip(
api: APIRequestContext
): Promise<{ tripId: number; placeId: number; name: string }> {
await deleteTripsNamed(api, WIND_TRIP);
const sourceTrip = await summerTripId(api);
const source = await paddleTourId(api);
const { trip } = await json<{ trip: { id: number } }>(
await api.post('/api/trips', {
data: {
title: WIND_TRIP,
description: 'Two quiet days at the lake, out on the water if the wind allows.',
start_date: day(1),
end_date: day(2),
currency: 'EUR',
},
}),
`create "${WIND_TRIP}"`
);
const taken = await json<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${trip.id}/tours/take-over`, {
data: { source_trip_id: sourceTrip, source_place_id: source },
}),
'take the boat tour over'
);
const { days } = await json<{ days: { id: number; date?: string | null }[] }>(
await api.get(`/api/trips/${trip.id}/days`),
'days'
);
const first = [...days].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '') || a.id - b.id)[0];
if (!first) throw new Error(`"${WIND_TRIP}" has no days`);
await json(
await api.post(`/api/trips/${trip.id}/days/${first.id}/assignments`, { data: { place_id: taken.tour.place_id } }),
'put the boat tour on day 1'
);
const { assignments = [] } = await json<{ assignments?: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${trip.id}/days/${first.id}/assignments`),
'assignments'
);
const row = assignments.find((a) => a.place_id === taken.tour.place_id);
if (row) await api.put(`/api/trips/${trip.id}/assignments/${row.id}/time`, { data: { place_time: '10:00' } });
const name = (await toursOf(api, trip.id)).find((t) => t.place_id === taken.tour.place_id)?.name ?? PADDLE_TOUR;
return { tripId: trip.id, placeId: taken.tour.place_id, name };
}
// ── The copy for the checklist ────────────────────────────────────────────────
/** A fresh copy of the boat tour in the summer trip, on no day, for a guide to save changes on. */
export async function ensureCarryCopy(api: APIRequestContext): Promise<number> {
const tripId = await summerTripId(api);
await deleteToursWhere(api, tripId, (t) => t.name === CARRY_COPY);
const source = await paddleTourId(api);
const copy = await json<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${tripId}/tours/${source}/copy`, {
data: { name: CARRY_COPY },
}),
'copy the boat tour'
);
return copy.tour.place_id;
}
// ── The recorded track ────────────────────────────────────────────────────────
type Point = [number, number];
function metres(a: Point, b: Point): number {
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return 6_371_000 * 2 * Math.asin(Math.sqrt(s));
}
function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function addDays(iso: string, n: number): string {
const d = new Date(`${iso}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/** The points of a GPX file, track points or route points, whichever order its attributes come in. */
function gpxPoints(gpx: string): Point[] {
const points: Point[] = [];
for (const match of gpx.matchAll(/<(?:trkpt|rtept)\b([^>]*)>/g)) {
const lat = /\blat="([-\d.]+)"/.exec(match[1]);
const lon = /\blon="([-\d.]+)"/.exec(match[1]);
if (lat && lon) points.push([Number(lat[1]), Number(lon[1])]);
}
return points;
}
/**
* A kayak round of Lago di Braies as a watch records it, made from the boat
* tour's own line (its GPX export): a point every 10 m or so, a few metres of
* GPS scatter, 5.5 km/h on the water with a twenty minute stop on the far
* shore, on a morning last summer. Written to `e2e/.tmp/` and returned as a path.
*/
export async function paddleGpxFixture(api: APIRequestContext): Promise<string> {
const tripId = await summerTripId(api);
const placeId = await paddleTourId(api);
const res = await api.get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`);
if (!res.ok()) throw new Error(`export the boat tour: ${res.status()}`);
const line = gpxPoints(await res.text());
if (line.length < 8) throw new Error('the boat tour exported no line to record a track from');
const dense: Point[] = [line[0]];
for (let i = 1; i < line.length; i++) {
const a = line[i - 1];
const b = line[i];
const n = Math.max(1, Math.round(metres(a, b) / 10));
for (let k = 1; k <= n; k++) dense.push([a[0] + ((b[0] - a[0]) * k) / n, a[1] + ((b[1] - a[1]) * k) / n]);
}
const rnd = seeded(4242);
// 09:40 in the Dolomites in summer is 07:40 UTC.
let t = Date.parse(`${addDays(TOUR_TRIP_DATES.start, -358)}T07:40:00Z`);
const pause = Math.floor(dense.length * 0.55);
const rows = dense.map((p, i) => {
if (i > 0) t += (metres(dense[i - 1], p) / 1000 / (5.5 * (0.9 + rnd() * 0.2))) * 3_600_000;
if (i === pause) t += 20 * 60_000;
const lat = p[0] + (rnd() - 0.5) * 0.00005;
const lng = p[1] + (rnd() - 0.5) * 0.00007;
const ele = 1494 + (rnd() - 0.5) * 2;
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '')}</time></trkpt>`;
});
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${PADDLE_GPX.name}</name></metadata>
<trk><name>${PADDLE_GPX.name}</name><type>kayaking</type><trkseg>
${rows.join('\n')}
</trkseg></trk>
</gpx>
`;
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, PADDLE_GPX.file);
writeFileSync(file, gpx);
return file;
}
/** Whether a tour is the recorded track the import guide brings in, whichever name the server gave it. */
export const isPaddleImport = (tour: { name: string }): boolean => /kayak[\s-]round/i.test(tour.name);
// ── On the page ───────────────────────────────────────────────────────────────
/** The Tours tab of a trip by its id, the way `openTourTrip` opens the summer one. */
export async function openToursOf(page: Page, tripId: number): Promise<void> {
await page.goto(`/trips/${tripId}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('listbox', { name: 'Tours of this trip' })).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* Let a pan or a fit of the map land. Leaflet times its animations from
* `+new Date()`, and the clock is pinned to the picture day, so they start and
* never finish; moving the pinned clock on a few seconds ends them.
*/
export async function finishMapMove(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/**
* A spot on the drawn tour line that the pointer can really reach, about
* `fraction` of the way along it: the longest line in Leaflet's overlay pane,
* sampled near that point until the map itself, not something over it, takes
* a click there. A box centre would be no use, the lake's middle is water
* nobody paddled.
*/
export async function pointOnLine(page: Page, fraction: number): Promise<{ x: number; y: number }> {
const found = await page.evaluate((wanted: number) => {
const paths = Array.from(document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path'));
let line: SVGPathElement | null = null;
let longest = 0;
for (const path of paths) {
const length = path.getTotalLength();
if (length > longest) {
longest = length;
line = path;
}
}
if (!line || longest < 40) return null;
const ctm = line.getScreenCTM();
if (!ctm) return null;
for (let step = 0; step <= 20; step++) {
for (const sign of [1, -1]) {
const f = wanted + sign * step * 0.005;
if (f <= 0.02 || f >= 0.98) continue;
const at = line.getPointAtLength(longest * 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 marking tool listens on the map's own click and lays the spot onto
// the track itself, and the drawn line is not interactive while it is on,
// so the pointer lands on whatever map layer lies under the line (the
// vector base map's canvas, a tile). That point is reachable as long as
// the map gets it, not a panel, a card, a control or a marker that keeps
// the click for itself.
const hit = document.elementFromPoint(x, y);
if (
hit &&
hit.closest('.leaflet-container') &&
!hit.closest('.leaflet-control-container, .leaflet-marker-pane, .leaflet-popup-pane, .leaflet-tooltip-pane')
) {
return { x, y };
}
}
}
return null;
}, fraction);
if (!found) throw new Error(`no reachable point on the drawn line near ${Math.round(fraction * 100)} %`);
return found;
}
@@ -1,463 +0,0 @@
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
import { TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
import { tripReady } from './trip-shared';
/**
* What the Tours-tab planner pictures need beyond `ensureToursFixtures`: a
* plain day hike to edit, the spots on real paths a new tour is drawn
* through, the way into the Tours tab, and the projection from a spot on the
* earth to a pixel of the map. Not a `.guide.ts`, so importing it registers
* no tests.
*
* Everything is cut from the line of the Puez-Odle hut trek, which the tour
* fixtures routed along real paths: the spare hike is its last stretch, from
* the Rifugio Firenze down to the Col Raiser gondola, and the points a guide
* clicks onto the map lie on the same line, so the router always finds a way
* between them and no picture shows a point it cannot reach.
*/
type Api = APIRequestContext;
type LatLng = [number, number];
type LatLngEle = [number, number, number];
/** The tours this screen makes and takes away again. */
export const PLANNER_TOURS = {
/** A plain four-stop day hike, on no day; the editing guides open it and never keep their changes. */
spare: 'Rifugio Firenze to Col Raiser',
/** A copy of the spare that the delete guide deletes. */
old: 'Rifugio Firenze to Col Raiser (old plan)',
/** The tour the save guide draws and saves. */
drawn: 'Cisles meadow walk',
} as const;
export const SPARE_DESCRIPTION = 'From the hut across the Cisles meadows and down to the Col Raiser gondola.';
/** What the description reads after the "someone else saved it" write of the conflict guide. */
export const REMOTE_DESCRIPTION = 'Mira: we take the gondola down, the last stretch is closed this week.';
/** The query the drawing guide types into the map search; the hut is where its points lie. */
export const SEARCH_QUERY = 'Rifugio Firenze';
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
return (await res.json()) as T;
}
export async function tripIdOf(api: Api): Promise<number> {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === TOUR_TRIP);
if (!trip) throw new Error(`no trip "${TOUR_TRIP}": ensureToursFixtures has not run`);
return trip.id;
}
interface PlaceRow {
id: number;
name: string;
lat?: number | null;
lng?: number | null;
route_geometry?: unknown;
}
async function placesOf(api: Api, tripId: number): Promise<PlaceRow[]> {
const res = await body<{ places?: PlaceRow[] } | PlaceRow[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
return Array.isArray(res) ? res : (res.places ?? []);
}
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
return tours.find((t) => t.name === name)?.place_id ?? null;
}
export async function removeTour(api: Api, tripId: number, name: string): Promise<void> {
for (;;) {
const id = await tourIdOf(api, tripId, name);
if (!id) return;
const res = await api.delete(`/api/trips/${tripId}/places/${id}`);
if (!res.ok()) throw new Error(`delete "${name}": ${res.status()}`);
}
}
// ── The line everything is cut from ───────────────────────────────────────────
function metres(a: readonly number[], b: readonly number[]): number {
const rad = Math.PI / 180;
const x = (b[1] - a[1]) * rad * Math.cos(((a[0] + b[0]) / 2) * rad);
const y = (b[0] - a[0]) * rad;
return Math.hypot(x, y) * 6_371_000;
}
/** The hut trek's line, the index where it passes the Rifugio Firenze, and the distance along it at every point. */
export interface HutLine {
line: LatLngEle[];
hut: number;
along: number[];
}
export async function hutLine(api: Api, tripId: number): Promise<HutLine> {
const places = await placesOf(api, tripId);
const trek = places.find((p) => p.name === TOUR_NAMES.hutToHut);
const hutPlace = places.find((p) => p.name === TOUR_PLACES.firenze);
const raw = trek?.route_geometry;
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as LatLngEle[];
if (!trek || line.length < 10)
throw new Error(`"${TOUR_NAMES.hutToHut}" has no route line: did ensureToursFixtures make it?`);
if (hutPlace?.lat == null || hutPlace.lng == null) throw new Error(`no place "${TOUR_PLACES.firenze}" with a spot`);
const spot = [hutPlace.lat, hutPlace.lng];
let hut = 0;
line.forEach((point, i) => {
if (metres(point, spot) < metres(line[hut], spot)) hut = i;
});
const along = [0];
for (let i = 1; i < line.length; i++) along.push(along[i - 1] + metres(line[i - 1], line[i]));
return { line, hut, along };
}
/** The index of the point `offset` metres along the line from the hut (negative: before it), kept on the line. */
function indexAt(geo: HutLine, offset: number): number {
const wanted = geo.along[geo.hut] + offset;
let best = 0;
geo.along.forEach((d, i) => {
if (Math.abs(d - wanted) < Math.abs(geo.along[best] - wanted)) best = i;
});
return best;
}
/** The spot `offset` metres along the hut trek from the Rifugio Firenze, on its path. */
export function spotAt(geo: HutLine, offset: number): LatLng {
const p = geo.line[indexAt(geo, offset)];
return [p[0], p[1]];
}
/** Where the drawing guide clicks: down the path to the hut and on past it. */
export const DRAWN_OFFSETS = [-420, -120, 240, 560];
/** Where the shaping guide pulls its point: back up the approach to the hut. */
export const SHAPING_OFFSET = -320;
/** The spare hike: the hut trek's last stretch, cut at four stops, with its own heights. */
export function spareBody(geo: HutLine, description = SPARE_DESCRIPTION): Record<string, unknown> {
const route = geo.line.slice(geo.hut);
const length = geo.along[geo.along.length - 1] - geo.along[geo.hut];
const cuts = [0, Math.round(route.length / 3), Math.round((2 * route.length) / 3), route.length - 1];
const waypoints = cuts.map((index, i) => ({
lat: route[index][0],
lng: route[index][1],
role: i === 0 ? 'start' : i === cuts.length - 1 ? 'end' : 'via',
sequence: i,
}));
let gain = 0;
let loss = 0;
for (let i = 1; i < route.length; i++) {
const step = route[i][2] - route[i - 1][2];
if (step > 0) gain += step;
else loss -= step;
}
// DIN 33466, as the planner's own estimate reckons a hike.
const flat = length / 1000 / 4;
const vertical = gain / 300 + loss / 500;
const hours = Math.max(flat, vertical) + Math.min(flat, vertical) / 2;
return {
name: PLANNER_TOURS.spare,
tour_type: 'hike',
max_hiking_difficulty: 2,
route_geometry: route,
waypoints,
duration_seconds: Math.round(hours * 3600),
break_additional_minutes: 30,
description,
};
}
/**
* The spare hike, made where it is missing. With `reset` an existing one is
* written back to how it was made, after a guide saved a change into it.
*/
export async function ensureSpare(api: Api, opts: { reset?: boolean } = {}): Promise<number> {
const tripId = await tripIdOf(api);
const geo = await hutLine(api, tripId);
const existing = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
if (existing) {
if (opts.reset)
await body(
await api.put(`/api/trips/${tripId}/tours/${existing}`, { data: spareBody(geo) }),
'reset the spare hike'
);
return existing;
}
const created = await body<{ tour: { place_id: number } }>(
await api.post(`/api/trips/${tripId}/tours`, { data: spareBody(geo) }),
'create the spare hike'
);
return created.tour.place_id;
}
/** Someone else saves the spare: the same tour with another description, a revision further on. */
export async function saveSpareElsewhere(api: Api): Promise<void> {
const tripId = await tripIdOf(api);
const id = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
if (!id) throw new Error('no spare hike to save elsewhere');
const geo = await hutLine(api, tripId);
await body(
await api.put(`/api/trips/${tripId}/tours/${id}`, { data: spareBody(geo, REMOTE_DESCRIPTION) }),
'save the spare elsewhere'
);
}
/** The copy the delete guide deletes, made where it is missing. */
export async function ensureOldPlan(api: Api): Promise<void> {
const tripId = await tripIdOf(api);
if (await tourIdOf(api, tripId, PLANNER_TOURS.old)) return;
const spare = await ensureSpare(api);
await body(
await api.post(`/api/trips/${tripId}/tours/${spare}/copy`, { data: { name: PLANNER_TOURS.old } }),
'copy the old plan'
);
}
/** Take a tour off one day, as the fixtures had it before a guide put it there. */
export async function offDay(api: Api, tripId: number, dayIndex: number, placeId: number): Promise<void> {
const { days } = await body<{ days: { id: number; date?: string | null; day_number?: number }[] }>(
await api.get(`/api/trips/${tripId}/days`),
'days'
);
const sorted = [...days].sort(
(a, b) => (a.date ?? '').localeCompare(b.date ?? '') || (a.day_number ?? 0) - (b.day_number ?? 0) || a.id - b.id
);
const dayId = sorted[dayIndex]?.id;
if (!dayId) return;
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
'assignments'
);
for (const a of assignments.filter((row) => row.place_id === placeId)) {
await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
}
}
/**
* Forget the planner's unsaved draft: on the account, where the planner keeps
* it now, and the copy older builds left in the browser, and the folds of the
* rail cards, so the next guide starts from the defaults.
*/
export async function forgetPlannerState(page: Page): Promise<void> {
const tripId = await tripIdOf(page.request);
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page
.evaluate((id) => {
try {
for (const key of Object.keys(window.localStorage)) {
if (
key === `tour-draft-${id}` ||
key.startsWith('trek.tours.fold.') ||
key === 'trek.tours.planner.detailsOpen'
) {
window.localStorage.removeItem(key);
}
}
} catch {
// No storage, nothing kept.
}
}, tripId)
.catch(() => undefined);
}
// ── Where things are ──────────────────────────────────────────────────────────
/** The right rail, "Tours of this trip", and a tour's row in its list (the option is named after the tour). */
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
export const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
/** The editor rail; its section is named after the planner while a tour is drawn or edited. */
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
export const waypointsCard = (page: Page) => page.getByRole('region', { name: 'Waypoints', exact: true });
export const waypointRows = (page: Page) => page.getByTestId('tour-planner-waypoint-row');
export const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
export const mapSearch = (page: Page) => page.getByRole('combobox', { name: 'Search place or address' });
/** A numbered pin of the open tour on the map, by its number; a shaping point carries none. */
export const pin = (page: Page, n: number) =>
page.locator('.tour-planner-waypoint-marker').filter({ hasText: new RegExp(`^${n}$`) });
export const shapingPin = (page: Page) => page.locator('.tour-planner-waypoint-marker').filter({ hasNotText: /\d/ });
/** The one context menu that is open; its entries are plain buttons. */
export const openMenu = (page: Page) => page.getByRole('menu');
export const menuItem = (page: Page, name: string) => openMenu(page).getByRole('button', { name, exact: true });
/** The row's own controls show on hover or once it is selected: select it, then open its menu. */
export async function openRowMenu(page: Page, row: Locator): Promise<void> {
await row.hover();
await row.getByRole('button', { name: 'More actions', exact: true }).click();
await expect(openMenu(page)).toBeVisible();
await settle(page);
}
// ── Getting there ─────────────────────────────────────────────────────────────
const rawKey = (page: Page) => page.getByText(/^(common|planner|places|trip|tours|map|undo)\.[A-Za-z0-9.]+$/);
/**
* The Dolomites trip on its Tours tab, as the reader finds it: nothing open in
* the editor, the trip's tours listed on the right. Waits for the Trip
* settings pill, as `tripReady` does (Share moved into it in v5).
*/
export async function openPlanner(page: Page): Promise<number> {
const tripId = await tripIdOf(page.request);
await forgetPlannerState(page);
await page.goto(`/trips/${tripId}?tab=tour-planner`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
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 tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await expect(page.getByRole('button', { name: 'Plan new tour', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(1200);
return tripId;
}
/**
* Let the map's camera land. Leaflet times a pan from `+new Date()`, which the
* pictures pin to one instant, so a pan starts and never gets anywhere; moving
* the pinned clock on ends it on the next frame. The clock only moves on, so
* the same instant never comes twice.
*/
export async function landMap(page: Page): Promise<void> {
await page.waitForTimeout(400);
await nudgeClock(page);
await page.waitForTimeout(1200);
await settle(page);
}
/** The route is planned and its heights are in: the summary is up and the status dot has nothing to say. */
export async function waitRouted(page: Page): Promise<void> {
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
await landMap(page);
}
/** The Topo map, whose raster tiles show the paths and let a spot be found on the screen. */
export async function useTopo(page: Page): Promise<void> {
const topo = layerPill(page).getByRole('button', { name: 'Topo', exact: true });
await expect(topo).toBeVisible({ timeout: 15_000 });
if ((await topo.getAttribute('aria-pressed')) !== 'true') await topo.click();
await expect(page.locator('img.leaflet-tile[src*="opentopomap"]').first()).toBeAttached({ timeout: 20_000 });
await settle(page);
}
/** Open a saved tour of the list in the editor and wait for its route, on the Topo map. */
export async function openSaved(page: Page, name: string): Promise<void> {
const row = tourRow(page, name);
await expect(row, `no tour "${name}" in the Tours tab`).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await waitRouted(page);
await useTopo(page);
}
/** Close the editor, throwing the unsaved changes away, so the next guide finds the rail as the reader does. */
export async function closeEditor(page: Page): Promise<void> {
const back = page.getByRole('button', { name: 'Back to Tours', exact: true });
if (await back.isVisible().catch(() => false)) {
await back.click();
const discard = page.getByRole('button', { name: 'Discard changes', exact: true });
if (await discard.isVisible({ timeout: 2_000 }).catch(() => false)) await discard.click();
}
await forgetPlannerState(page);
}
// ── From the earth to the screen ──────────────────────────────────────────────
/**
* The pixel of a spot on the map. Read off a raster tile: its address names
* its zoom and its column and row, its box says where it sits, and Web
* Mercator does the rest. Exact at any zoom and through any pan, and it
* needs nothing of Leaflet's that the page does not show anyway.
*/
export async function screenOf(page: Page, spot: LatLng): Promise<{ x: number; y: number }> {
const at = await page.evaluate(([lat, lng]) => {
for (const img of Array.from(
document.querySelectorAll('.leaflet-tile-pane img.leaflet-tile')
) as HTMLImageElement[]) {
const m = /\/(\d+)\/(\d+)\/(\d+)(?:@\dx)?\.(?:png|jpe?g|webp)/.exec(img.src);
const box = img.getBoundingClientRect();
if (!m || box.width === 0) continue;
const [z, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])];
const n = 2 ** z;
const s = Math.sin((lat * Math.PI) / 180);
const wx = ((lng + 180) / 360) * n;
const wy = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n;
return { x: box.left + (wx - tx) * box.width, y: box.top + (wy - ty) * box.height };
}
return null;
}, spot);
if (!at) throw new Error('no raster tile on the map to place a spot by: is the Topo map on?');
// Between the two rails and under the search box, or a click would land on a panel.
const rails = await page.evaluate(() => {
const left = document.querySelector('[aria-label="Plan a tour"]')?.getBoundingClientRect();
const right = document.querySelector('aside[aria-label="Tours of this trip"]')?.getBoundingClientRect();
return { left: left?.right ?? 0, right: right?.left ?? window.innerWidth };
});
if (at.x < rails.left + 24 || at.x > rails.right - 24 || at.y < 120 || at.y > 1000) {
throw new Error(`spot ${spot.join(', ')} lands at ${Math.round(at.x)}, ${Math.round(at.y)}, outside the open map`);
}
return at;
}
/**
* A pointer movement in even steps with a frame each, for a loop: from where
* the mouse is to `to`, eased like a hand.
*/
export async function glide(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
frame: () => Promise<void>,
steps = 10
): Promise<void> {
for (let i = 1; i <= steps; i++) {
const k = i / steps;
const ease = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
await page.mouse.move(from.x + (to.x - from.x) * ease, from.y + (to.y - from.y) * ease, { steps: 2 });
await frame();
}
}
/** The centre of an element on the screen. */
export async function centreOf(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox();
if (!box) throw new Error('the element has no box');
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/**
* A spot on the drawn route, `share` of the way along its widest hit band:
* the invisible band Leaflet lays along the line to take the click that adds
* a shaping point.
*/
export async function spotOnRoute(page: Page, share = 0.3): Promise<{ x: number; y: number }> {
const at = await page.evaluate((k) => {
const bands = Array.from(document.querySelectorAll('.leaflet-overlay-pane path.cursor-copy')) as SVGPathElement[];
const band = bands.sort((a, b) => b.getTotalLength() - a.getTotalLength())[0];
const ctm = band?.getScreenCTM();
if (!band || !ctm) return null;
const p = band.getPointAtLength(band.getTotalLength() * k);
const s = new DOMPoint(p.x, p.y).matrixTransform(ctm);
return { x: s.x, y: s.y };
}, share);
if (!at) throw new Error('no drawn route to click on');
return at;
}
/** The drawn route's click band, for the ring: the widest one. */
export const routeBand = (page: Page) => page.locator('.leaflet-overlay-pane path.cursor-copy').first();
-226
View File
@@ -1,226 +0,0 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { SKI_TRIP, SKI_TRIP_DATES, TOUR_NAMES } from './tour-fixtures';
/**
* What the Ski & winter help pictures need beyond `ensureToursFixtures`: a
* recorded ski track to import through the UI, and the names of what the
* guides make, so their cleanup can take it away again.
*
* The track follows the line of the "Vallunga winter walk" tour, which
* `ensureToursFixtures` draws along the real Vallunga path (by the piste
* router where it answers, else along the OpenStreetMap way), so the recording
* lies on the valley's cross-country trail and not on a line drawn here. It is
* recorded a year before the ski week, at a classic skier's pace.
*/
/** The track the import guide brings in, by the name in its file. */
export const SKI_TRACK = { name: 'Vallunga cross-country loop', file: 'vallunga-cross-country-loop.gpx' };
/** The tour the planning guide saves, deleted again by its cleanup. */
export const PLANNED_SKI_TOUR = 'Selva to Corvara on skis';
/**
* The points the planner guides click, the first part of the Sella Ronda:
* Selva at the Dantercepies I valley station, the top of the Gardena run at
* Passo Gardena, and Corvara at the Borest and Boè gondolas. Each lies within
* a few metres of the piste data, because the router only takes a point
* within 150 m of a run or lift: the old Corvara point sat 360 m off the
* Sodlisia run and failed as not_on_piste, and the old Selva point snapped to
* the run down into the village, so the first leg went round by Ciampinoi
* instead of up the Dantercepies gondola. On the Places build of 2026-10-20
* the first leg rides Dantercepies I and II and skis the Sellaronda run to
* the pass, and the second skis Gardena, Frara and Sodlisia down to Colfosco
* and takes the Borest gondola over to Corvara. With every lift left out, the
* first leg fails as blocked_by_lift_avoid on Dantercepies I.
*/
export const SKI_POINTS: [number, number][] = [
[46.5556, 11.768],
[46.5512, 11.8077],
[46.5482, 11.871],
];
type Point = [number, number, number];
/** The trip's id by its title. */
export async function skiTripId(api: APIRequestContext): Promise<number> {
const res = await api.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
const trip = trips.find((t) => t.title === SKI_TRIP);
if (!trip) throw new Error(`no trip "${SKI_TRIP}": call ensureToursFixtures first`);
return trip.id;
}
/** The tours of the ski week, name and place id. */
async function skiTours(api: APIRequestContext, tripId: number): Promise<{ place_id: number; name: string }[]> {
const res = await api.get(`/api/trips/${tripId}/tours`);
if (!res.ok()) return [];
return ((await res.json()) as { tours?: { place_id: number; name: string }[] }).tours ?? [];
}
/** The line of a saved tour, from the trip's places or the tour itself. */
async function lineOf(api: APIRequestContext, tripId: number, placeId: number): Promise<Point[]> {
const parse = (raw: unknown): Point[] => {
const value = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
return Array.isArray(value)
? (value as number[][]).filter((p) => p.length >= 2).map((p) => [p[0], p[1], p[2] ?? NaN])
: [];
};
const places = await api.get(`/api/trips/${tripId}/places`);
if (places.ok()) {
const body = (await places.json()) as
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
const row = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === placeId);
const line = parse(row?.route_geometry);
if (line.length > 1) return line;
}
const tour = await api.get(`/api/trips/${tripId}/tours/${placeId}`);
if (tour.ok()) {
const body = (await tour.json()) as { route_geometry?: unknown; tour?: { route_geometry?: unknown } };
return parse(body.route_geometry ?? body.tour?.route_geometry);
}
return [];
}
const metres = (a: Point, b: Point): number => {
const r = 6_371_000;
const dLat = ((b[0] - a[0]) * Math.PI) / 180;
const dLng = ((b[1] - a[1]) * Math.PI) / 180;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return 2 * r * Math.asin(Math.sqrt(h));
};
/** A small seeded random, so the recording is the same on every run. */
function random(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* A GPS recording of `line`: a point every 15 m, a little scatter, heights
* where the line has none (the Vallunga climbs from about 1610 m to 1760 m),
* classic cross-country pace, slower uphill, and a stop at the turn.
*/
function recordedSkiGpx(line: Point[], startUtcMs: number): string {
const rnd = random(4219);
const known = line.every((p) => Number.isFinite(p[2]));
const total = line.slice(1).reduce((s, p, i) => s + metres(line[i], p), 0) || 1;
let run = 0;
const withHeights: Point[] = line.map((p, i) => {
if (i > 0) run += metres(line[i - 1], p);
if (known) return p;
// Out and back: up to the turn, then down the same way.
const f = run / total;
return [p[0], p[1], 1610 + 150 * (f <= 0.5 ? f * 2 : (1 - f) * 2)];
});
const pts: Point[] = [withHeights[0]];
for (let i = 1; i < withHeights.length; i++) {
const a = withHeights[i - 1];
const b = withHeights[i];
const n = Math.max(1, Math.round(metres(a, b) / 15));
for (let k = 1; k <= n; k++) {
const s = k / n;
pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, a[2] + (b[2] - a[2]) * s]);
}
}
let t = startUtcMs;
const turn = Math.floor(pts.length / 2);
const rows = pts.map((p, i) => {
if (i > 0) {
const d = metres(pts[i - 1], p);
const slope = d > 0 ? (p[2] - pts[i - 1][2]) / d : 0;
const kmh = slope > 0 ? Math.max(5, 11 - slope * 120) : Math.min(16, 11 - slope * 80);
t += (d / 1000 / kmh) * 3_600_000;
}
if (i === turn) t += 12 * 60_000;
const lat = p[0] + (rnd() - 0.5) * 0.00005;
const lng = p[1] + (rnd() - 0.5) * 0.00007;
const ele = p[2] + (rnd() - 0.5) * 3;
const time = new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '');
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${time}</time></trkpt>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${SKI_TRACK.name}</name><time>${new Date(startUtcMs).toISOString().replace('.000', '')}</time></metadata>
<trk><name>${SKI_TRACK.name}</name><type>cross_country_skiing</type><trkseg>
${rows.join('\n')}
</trkseg></trk>
</gpx>
`;
}
/** Where the Vallunga winter walk runs when no line can be read: the car park, the chapel and back. */
const VALLUNGA: Point[] = [
[46.5622, 11.7795, 1610],
[46.5652, 11.7858, 1640],
[46.5688, 11.7925, 1680],
[46.5712, 11.7984, 1720],
[46.5735, 11.804, 1760],
[46.5712, 11.7984, 1720],
[46.5688, 11.7925, 1680],
[46.5652, 11.7858, 1640],
[46.5622, 11.7795, 1610],
];
/**
* The path of the recorded ski track in `e2e/.tmp/`, written on every call
* from the winter walk's line, so it follows whatever line that tour has.
*/
export async function skiTrackFixture(api: APIRequestContext): Promise<string> {
const tripId = await skiTripId(api);
const walk = (await skiTours(api, tripId)).find((t) => t.name === TOUR_NAMES.winterWalk);
let line = walk ? await lineOf(api, tripId, walk.place_id) : [];
if (line.length < 2) {
console.warn('[ski fixtures] no line for the Vallunga winter walk; the track follows the built-in points');
line = VALLUNGA;
}
const dir = path.join(process.cwd(), 'e2e', '.tmp');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, SKI_TRACK.file);
// A year before the ski week's fourth day, 10:20 in Val Gardena (UTC+1).
const day = new Date(`${SKI_TRIP_DATES.start}T00:00:00Z`);
day.setUTCDate(day.getUTCDate() + 3 - 364);
const start = day.getTime() + (10 * 60 + 20 - 60) * 60_000;
writeFileSync(file, recordedSkiGpx(line, start));
return file;
}
/** Delete every tour of the ski week whose name is one of `names` (a guide's own, never the fixture's). */
export async function deleteSkiTours(page: Page, ...names: string[]): Promise<void> {
const tripId = await skiTripId(page.request);
const base = SKI_TRACK.file.replace(/\.gpx$/, '');
for (const tour of await skiTours(page.request, tripId)) {
if (!names.includes(tour.name) && !(names.includes(SKI_TRACK.name) && tour.name === base)) continue;
const res = await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
if (!res.ok()) console.warn(`[ski fixtures] could not delete "${tour.name}": ${res.status()}`);
}
}
/**
* Forget the editor's unsaved draft of the ski week. The planner keeps it on
* the account and opens it again on the next page load, so a draft one guide
* leaves behind would greet the next one in place of Plan new tour. Older
* builds kept it in this browser as `tour-draft-<tripId>`, which goes too.
*/
export async function forgetSkiDraft(page: Page): Promise<void> {
const tripId = await skiTripId(page.request);
const res = await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => null);
if (res && !res.ok() && res.status() !== 404)
console.warn(`[ski fixtures] could not forget the tour draft: ${res.status()}`);
await page
.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Storage blocked: nothing was kept there either.
}
}, tripId)
.catch(() => undefined);
}
-176
View File
@@ -1,176 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { seededTrip } from './fixtures';
import { TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
/**
* What the city walk pictures need beyond `ensureToursFixtures`: opening hours
* on one stop of the Bolzano walk, so the walk's day shows what a stay that
* runs past closing time looks like, and the Google Maps link the import
* guide pastes. Nothing here is run by importing it.
*/
/**
* The stop that gets opening hours, and the hours. The South Tyrol Museum of
* Archaeology is open every day in July from ten to six; the walk starts at
* 15:00 on day 1 and reaches it after the arcades and the market, so its
* hour and a half runs past closing time.
*/
export const WALK_HOURS = { stop: 'South Tyrol Museum of Archaeology', hours: 'Mo-Su 10:00-18:00' } as const;
type Json = Record<string, unknown>;
type Point = [number, number, number | null];
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
/**
* Give the museum stop of the Bolzano walk its opening hours, once. A walk's
* stops only carry hours where they were found on OpenStreetMap, and the
* fixture draws the walk by hand, so they are written here with a save of
* the tour as it stands: the same line, the same stops, one field more. A
* failure warns and leaves the walk as it was; the pictures then show the
* arrivals without a warning.
*/
export async function ensureWalkHours(api: APIRequestContext): Promise<void> {
try {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const tripId = trips.find((t) => t.title === TOUR_TRIP)?.id;
if (!tripId) throw new Error(`no trip "${TOUR_TRIP}"`);
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
const placeId = tours.find((t) => t.name === TOUR_NAMES.cityWalk)?.place_id;
if (!placeId) throw new Error(`no tour "${TOUR_NAMES.cityWalk}"`);
const detail = await body<{ tour: Json; waypoints: Json[] }>(
await api.get(`/api/trips/${tripId}/tours/${placeId}`),
'the walk'
);
const museum = detail.waypoints.find((w) => w.name === WALK_HOURS.stop);
if (!museum) throw new Error(`the walk has no stop "${WALK_HOURS.stop}"`);
if (museum.opening_hours === WALK_HOURS.hours) return;
const placesBody = await body<{ places?: Json[] } | Json[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
const place = (Array.isArray(placesBody) ? placesBody : (placesBody.places ?? [])).find((p) => p.id === placeId);
const raw = place?.route_geometry;
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as Point[];
if (line.length < 2 || line.some((p) => typeof p[2] !== 'number' || !Number.isFinite(p[2]))) {
throw new Error('the walk has no line with heights to save again');
}
const tour = detail.tour;
const minutes = typeof tour.duration === 'number' ? tour.duration : null;
const res = await api.put(`/api/trips/${tripId}/tours/${placeId}`, {
data: {
name: tour.name,
tour_type: 'walk',
route_geometry: line,
waypoints: detail.waypoints.map((w) => (w === museum ? { ...w, opening_hours: WALK_HOURS.hours } : w)),
duration_seconds: minutes === null ? null : minutes * 60,
break_additional_minutes: tour.break_additional_minutes ?? null,
planned_duration_minutes: tour.planned_duration_minutes ?? null,
description: tour.description ?? null,
website: tour.website ?? null,
expected_revision: tour.revision ?? null,
},
});
if (!res.ok()) throw new Error(`save the walk: ${res.status()} ${await res.text()}`);
} catch (err) {
console.warn(
`[walk-fixtures] the museum stop has no opening hours: ${err instanceof Error ? err.message : String(err)}`
);
}
}
// ── The Google Maps link ──────────────────────────────────────────────────────
/**
* A walking route through Ortisei, in Val Gardena where the trip stays, in
* the shape Google Maps shares one: the stops as path segments, then the
* viewport, then the data blob with one block per stop holding its position
* (`!2m2!1d<lng>!2d<lat>`) and `!3e2`, on foot. The server reads it without
* asking Google (`directions-url.helpers.ts`), so the picture needs no
* network for the preview. The place ids in the blocks are opaque to TREK.
*/
const ORTISEI_STOPS = [
{
name: 'Piazza S. Antonio, 39046 Ortisei BZ, Italy',
lat: 46.57478,
lng: 11.67228,
cid: '0x4778f4c0d1a2b3c5:0x2f6a1e0b9c4d7e13',
},
{
name: 'Museum Gherdëina, Strada Rezia 83, 39046 Ortisei BZ, Italy',
lat: 46.57589,
lng: 11.67428,
cid: '0x4778f4c2a9e1d0b7:0x6b3c2d1e0f9a8b75',
},
{
name: 'Chiesa parrocchiale di Sant’Ulrico, 39046 Ortisei BZ, Italy',
lat: 46.57673,
lng: 11.6708,
cid: '0x4778f4bd6c5e4f31:0x1d9e8f7a6b5c4d29',
},
{
name: 'Promenade Luis Trenker, 39046 Ortisei BZ, Italy',
lat: 46.5741,
lng: 11.6686,
cid: '0x4778f4b9e8d7c6a3:0x7c6b5a4f3e2d1c08',
},
] as const;
/** A path segment the way Google writes one: commas kept, spaces as plus signs. */
const segment = (name: string) => encodeURIComponent(name).replace(/%2C/g, ',').replace(/%20/g, '+');
function googleWalkLink(): string {
const blocks = ORTISEI_STOPS.map((s) => `!1m5!1m1!1s${s.cid}!2m2!1d${s.lng}!2d${s.lat}`).join('');
// The inner route message holds six tokens per stop and the mode; the outer one that and itself.
const inner = ORTISEI_STOPS.length * 6 + 1;
return (
`https://www.google.com/maps/dir/${ORTISEI_STOPS.map((s) => segment(s.name)).join('/')}` +
`/@46.5755,11.6715,17z/data=!4m${inner + 1}!4m${inner}${blocks}!3e2`
);
}
export const GOOGLE_WALK = {
link: googleWalkLink(),
/** The first and the last stop as the preview names them. */
first: ORTISEI_STOPS[0].name,
last: ORTISEI_STOPS[ORTISEI_STOPS.length - 1].name,
count: ORTISEI_STOPS.length,
} as const;
// ── Visits ────────────────────────────────────────────────────────────────────
/**
* Take every stop of the Higashiyama walk on Japan day 9 back to planned,
* so the check-off guide finds nothing marked and leaves nothing marked.
*/
export async function resetKyotoVisits(api: APIRequestContext): Promise<void> {
const japan = seededTrip();
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${japan.tripId}/tours`),
'Japan tours'
);
const walkId = tours.find((t) => t.name === TOUR_NAMES.kyotoWalk)?.place_id;
if (!walkId) return;
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
await api.get(`/api/trips/${japan.tripId}/days/${japan.dayIds[8]}/assignments`),
'Japan day 9'
);
const assignment = assignments.find((a) => a.place_id === walkId);
if (!assignment) return;
const { visits } = await body<{ visits: { assignment_id: number; station_key: string; status: string }[] }>(
await api.get(`/api/trips/${japan.tripId}/tour-visits`),
'visits'
);
for (const visit of visits.filter((v) => v.assignment_id === assignment.id)) {
await api.put(`/api/trips/${japan.tripId}/tour-visits/${assignment.id}/${visit.station_key}`, {
data: { status: 'planned' },
});
}
}
-186
View File
@@ -1,186 +0,0 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { clearNotices } from '../screenshots/shot';
import { dismissReleaseNotice, settle } from './guide';
import { TOUR_TRIP } from './tour-fixtures';
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
/**
* What the Plan-tab tour pictures need beyond `ensureToursFixtures`: the way
* into the Dolomites trip with the right column in Tours mode, a timed stop
* after the horse ride for the overrun line, and the writes the guides take
* back in their `cleanup`. Not a `.guide.ts`, so importing it registers no tests.
*/
type Api = APIRequestContext;
interface AssignmentRow {
id: number;
place_id: number;
assignment_time?: string | null;
place_time?: string | null;
}
/** The timed stop after the horse ride on day 2, made by the times guide and removed again. */
export const LUNCH = {
name: 'Lunch at Gostner Schwaige',
lat: 46.5546,
lng: 11.6347,
address: 'Compatsch, Seiser Alm',
time: '12:30',
};
async function body<T>(
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
what: string
): Promise<T> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
return (await res.json()) as T;
}
export async function tripIdOf(api: Api, title = TOUR_TRIP): Promise<number> {
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
const trip = trips.find((t) => t.title === title);
if (!trip) throw new Error(`no trip "${title}": ensureToursFixtures has not run`);
return trip.id;
}
/** A tour's place id by its name, or null when it is not in the trip. */
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
await api.get(`/api/trips/${tripId}/tours`),
'tours'
);
return tours.find((t) => t.name === name)?.place_id ?? null;
}
export async function assignmentsOf(api: Api, tripId: number, dayId: number): Promise<AssignmentRow[]> {
return (
await body<{ assignments: AssignmentRow[] }>(
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
'assignments'
)
).assignments;
}
/** Take a place off every day it is on. */
export async function offAllDays(api: Api, tripId: number, dayIds: number[], placeId: number): Promise<void> {
for (const dayId of dayIds) {
for (const a of await assignmentsOf(api, tripId, dayId)) {
if (a.place_id === placeId) await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
}
}
}
/** Put a tour back on a day with its start time, as the fixtures had it. */
export async function backOnDay(
api: Api,
tripId: number,
dayId: number,
placeId: number,
time: string | null
): Promise<void> {
let row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
if (!row) {
await body(
await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: placeId } }),
'put back on its day'
);
row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
}
if (row)
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
}
/** Give a tour's day entry its start again (and no end), as the fixtures had it. */
export async function resetTime(api: Api, tripId: number, dayId: number, placeId: number, time: string): Promise<void> {
const row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
if (row)
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
}
/** The lunch stop on `dayId`, at the end of the day and timed, made once. */
export async function ensureLunch(api: Api, tripId: number, dayId: number): Promise<void> {
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
await api.get(`/api/trips/${tripId}/places`),
'places'
);
const list = Array.isArray(places) ? places : (places.places ?? []);
let id = list.find((p) => p.name === LUNCH.name)?.id;
if (!id) {
const created = await body<{ place: { id: number } }>(
await api.post(`/api/trips/${tripId}/places`, {
data: { name: LUNCH.name, lat: LUNCH.lat, lng: LUNCH.lng, address: LUNCH.address },
}),
'create the lunch stop'
);
id = created.place.id;
}
await backOnDay(api, tripId, dayId, id, LUNCH.time);
}
export async function removeByName(api: Api, tripId: number, name: string): Promise<void> {
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
await api.get(`/api/trips/${tripId}/places`),
'places'
);
const list = Array.isArray(places) ? places : (places.places ?? []);
for (const place of list.filter((p) => p.name === name)) await api.delete(`/api/trips/${tripId}/places/${place.id}`);
}
/** Forget the tours hidden on the map; the switch is kept in this browser only. */
export async function clearHiddenTours(page: Page): Promise<void> {
await page.evaluate(() => {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith('trek:hidden-tours:')) window.localStorage.removeItem(key);
}
} catch {
// No storage, nothing hidden.
}
});
}
const rawKey = (page: Page) =>
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing|inspector)\.[A-Za-z0-9.]+$/);
/** The Places | Tours switch at the top of the right column. */
export const modeSwitch = (page: Page) => page.getByRole('tablist', { name: 'Add view' });
export const modeTab = (page: Page, name: 'Places' | 'Tours') =>
modeSwitch(page).getByRole('tab', { name, exact: true });
/**
* Open the Dolomites trip on the Plan tab the way `openTrip` opens the Japan
* one: notices cleared, waiting for the page and its texts, day 1 selected and
* its details panel closed again. With `tours`, the right column is switched
* to Tours and the list has loaded.
*/
export async function openToursPlan(page: Page, opts: { tours?: boolean } = {}): Promise<number> {
const tripId = await tripIdOf(page.request);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
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 tripReady(page);
await settle(page);
}
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
await selectDay(page, 1);
await closeDayDetails(page);
if (opts.tours) {
await modeTab(page, 'Tours').click();
await expect(modeTab(page, 'Tours')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('listbox', { name: 'Tours', exact: true }).getByRole('option').first()).toBeVisible({
timeout: 30_000,
});
await settle(page);
}
// The map tiles land after the page says it is idle.
await page.waitForTimeout(1200);
return tripId;
}
-257
View File
@@ -1,257 +0,0 @@
import { request as apiRequest, type APIRequestContext, type Page } from '@playwright/test';
import { E2E_BASE_URL } from '../../playwright.config';
/**
* The Vacay state the guides set up through the API: the seeded member mira
* fused into the admin's plan, a fusion request from her, a public-holiday
* calendar to edit. Every helper is idempotent, so a retried or a single
* guide finds what it needs, and each has its way back to the seed.
*/
const VACAY = '/api/addons/vacay';
/**
* The seeded member the fusion and sharing guides play with. Not jonas: the
* share guide shares with jonas, and two rows of the same name would hide
* which one is which.
*/
export const PARTNER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' };
interface PlanData {
plan: { id: number; holiday_calendars: { id: number; type?: string | null; color: string; label: string | null }[] };
users: { id: number; username: string; color: string }[];
pendingInvites: { user_id: number }[];
incomingInvites: { plan_id: number; username: string }[];
isFused: boolean;
}
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
}
/**
* 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.
*/
export async function asMember(member: typeof PARTNER = PARTNER): 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}` },
});
}
async function meId(api: APIRequestContext): Promise<number> {
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
}
/** The admin's active plan, as the page loads it. */
export async function adminPlan(page: Page): Promise<PlanData> {
const res = await page.request.get(`${VACAY}/plan`);
await ok(res, 'could not load the vacay plan');
return (await res.json()) as PlanData;
}
/** The admin's own id. */
export const adminId = (page: Page): Promise<number> => meId(page.request);
/**
* Mira fused into the admin's plan, the admin its owner. The invite guide may
* have left an invite pending already; otherwise the admin sends one.
*/
export async function ensureFused(page: Page): Promise<void> {
const data = await adminPlan(page);
if (data.isFused && data.users.some((u) => u.username === PARTNER.username)) return;
const member = await asMember();
try {
const partnerId = await meId(member);
if (!data.pendingInvites.some((i) => i.user_id === partnerId)) {
await ok(
await page.request.post(`${VACAY}/invite`, { data: { user_id: partnerId } }),
`could not invite ${PARTNER.username}`
);
}
await ok(
await member.post(`${VACAY}/invite/accept`, { data: { plan_id: data.plan.id } }),
`${PARTNER.username} could not accept`
);
} finally {
await member.dispose();
}
}
/** Back to separate plans. The admin owns the plan, so everyone's entries go home. */
export async function ensureSeparate(page: Page): Promise<void> {
if (!(await adminPlan(page)).isFused) return;
await ok(await page.request.post(`${VACAY}/dissolve`), 'could not dissolve the fusion');
}
/** Clear what the partner has logged in the admin's plan on these dates. */
export async function clearPartnerDays(page: Page, year: number, dates: string[]): Promise<void> {
const res = await page.request.get(`${VACAY}/entries/${year}`);
await ok(res, 'could not load the entries');
const { entries } = (await res.json()) as {
entries: { user_id: number; date: string; fraction: number; kind: string | null }[];
};
const member = await asMember();
try {
const partnerId = await meId(member);
for (const e of entries.filter((x) => x.user_id === partnerId && dates.includes(x.date))) {
// The same day with the same fraction and kind again is what clears it.
await ok(
await member.post(`${VACAY}/entries/toggle`, {
data: { date: e.date, fraction: e.fraction, kind: e.kind ?? 'vacation' },
}),
`could not clear ${e.date}`
);
}
} finally {
await member.dispose();
}
}
/**
* A fusion request from mira to the admin, which opens the Fusion Request
* dialog on the admin's next visit. Both have to be on their own plans first,
* or her invite would come from the plan the admin already owns.
*/
export async function requestFusionFromPartner(page: Page): Promise<void> {
await ensureSeparate(page);
const target = await adminId(page);
const member = await asMember();
try {
const res = await member.post(`${VACAY}/invite`, { data: { user_id: target } });
if (!res.ok() && !(await res.text()).includes('Invite already pending')) {
throw new Error(`${PARTNER.username} could not invite the admin: ${res.status()}`);
}
} finally {
await member.dispose();
}
}
/** Take back what requestFusionFromPartner did: the request and the notification it sent. */
export async function clearFusionRequest(page: Page): Promise<void> {
for (const inv of (await adminPlan(page)).incomingInvites) {
await ok(
await page.request.post(`${VACAY}/invite/decline`, { data: { plan_id: inv.plan_id } }),
'could not decline the request'
);
}
await deleteNotifications(page, 'notif.vacay_invite.title', PARTNER.username);
}
/** Remove the in-app notifications of one kind that a member sent the admin. */
export async function deleteNotifications(page: Page, titleKey: string, sender: string): Promise<void> {
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 === titleKey && x.sender_username === sender)) {
await page.request.delete(`/api/notifications/in-app/${n.id}`);
}
}
/** The member shares her calendar with the admin, as she would from her own Vacay. */
export async function shareWithAdmin(page: Page): Promise<void> {
const target = await adminId(page);
const member = await asMember();
try {
const res = await member.post(`${VACAY}/shares`, { data: { user_id: target } });
// 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 ${PARTNER.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. */
export async function unshareWithAdmin(page: Page): Promise<void> {
const shares = (await (await page.request.get(`${VACAY}/shares`)).json()) as {
incoming: { id: number; username: string }[];
};
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
await ok(await page.request.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
}
await deleteNotifications(page, 'notif.vacay_share.title', PARTNER.username);
}
/** The admin's own colour, to put back after the colour guide. */
export async function ownColor(page: Page): Promise<string> {
const me = await adminId(page);
const user = (await adminPlan(page)).users.find((u) => u.id === me);
if (!user) throw new Error('the admin is not on their own plan');
return user.color;
}
export async function setOwnColor(page: Page, color: string): Promise<void> {
await ok(await page.request.put(`${VACAY}/color`, { data: { color } }), 'could not set the colour');
}
/**
* At least one public-holiday calendar, for the guide that edits one. The
* public-holidays guide adds it in a full run; on its own the guide adds the
* same Berlin calendar here.
*/
export async function ensurePublicCalendar(page: Page): Promise<void> {
const { plan } = await adminPlan(page);
await ok(
await page.request.put(`${VACAY}/plan`, { data: { holidays_enabled: true } }),
'could not switch public holidays on'
);
if (plan.holiday_calendars.some((c) => (c.type ?? 'public_holiday') === 'public_holiday')) return;
await ok(
await page.request.post(`${VACAY}/plan/holiday-calendars`, {
data: { region: 'DE-BE', label: 'Berlin', type: 'public_holiday' },
}),
'could not add the Berlin calendar'
);
}
/** The colour of every holiday calendar, by id, to put back after the edit guide. */
export async function calendarColors(page: Page): Promise<Map<number, string>> {
return new Map((await adminPlan(page)).plan.holiday_calendars.map((c) => [c.id, c.color]));
}
export async function restoreCalendarColors(page: Page, colors: Map<number, string>): Promise<void> {
for (const cal of (await adminPlan(page)).plan.holiday_calendars) {
const was = colors.get(cal.id);
if (was && was !== cal.color) {
await ok(
await page.request.put(`${VACAY}/plan/holiday-calendars/${cal.id}`, { data: { color: was } }),
'could not restore the colour'
);
}
}
}
/** The fraction of the company holiday on a date, or null when there is none. */
export async function companyHolidayFraction(page: Page, year: number, date: string): Promise<number | null> {
const res = await page.request.get(`${VACAY}/entries/${year}`);
await ok(res, 'could not load the entries');
const { companyHolidays } = (await res.json()) as { companyHolidays: { date: string; fraction: number }[] };
return companyHolidays.find((c) => c.date === date)?.fraction ?? null;
}
/** Put a company holiday back to a size, or remove it (null). The same size again clears it. */
export async function setCompanyHoliday(
page: Page,
year: number,
date: string,
fraction: number | null
): Promise<void> {
const now = await companyHolidayFraction(page, year, date);
if (now === fraction) return;
// Clearing sends the size it has; converting sends the size it should get.
const send = fraction === null ? now : fraction;
await ok(
await page.request.post(`${VACAY}/entries/company-holiday`, { data: { date, fraction: send } }),
`could not reset ${date}`
);
}
+7 -146
View File
@@ -1,6 +1,6 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { PICTURE_DAY_ISO } from '../dates'
import { mkdirSync, rmSync } from 'node:fs'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import type { HelpGuide } from '../../src/help/types'
@@ -44,36 +44,9 @@ export const VIEWPORT = { width: 1920, height: 1080 }
*/
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
/** Where each page's pinned clock stands; it only ever moves on. */
const pinnedAt = new WeakMap<Page, number>()
const ticking = new WeakSet<Page>()
/**
* Move the pinned clock on, a few seconds by default, and never back. A
* Leaflet pan timed from `+new Date()` ends on the next frame, and a wait
* measured with `Date.now()` sees its time pass.
*/
export async function nudgeClock(page: Page, ms = 5_000): Promise<void> {
const at = (pinnedAt.get(page) ?? PICTURE_DAY.getTime()) + ms
pinnedAt.set(page, at)
await page.clock.setFixedTime(new Date(at))
}
/**
* Put the page on the day the pictures are taken on. Call before navigating.
* The clock then moves a second on each second: the public Valhalla is asked
* at most once a second, timed with `Date.now()`, and on a clock that stands
* still that second never passes and a tour's route waits for good.
*/
/** Put the page on the day the pictures are taken on. Call before navigating. */
async function pinClock(page: Page): Promise<void> {
if (!pinnedAt.has(page)) pinnedAt.set(page, PICTURE_DAY.getTime())
await page.clock.setFixedTime(new Date(pinnedAt.get(page) as number))
if (ticking.has(page)) return
ticking.add(page)
const tick = setInterval(() => {
nudgeClock(page, 1_000).catch(() => clearInterval(tick))
}, 1_000)
page.once('close', () => clearInterval(tick))
await page.clock.setFixedTime(PICTURE_DAY)
}
export interface StepAction {
@@ -94,17 +67,6 @@ export interface StepAction {
dropTo?: (page: Page) => Locator
/** What the step does. */
act?: (page: Page) => Promise<void>
/**
* A short loop of the step, for a gesture a still cannot show (a drag, a
* route being drawn). Do the gesture and call `frame()` wherever a frame
* belongs; the loop is cut from the same window as the still, with the
* step's number in its corner and a drawn pointer, since a headless
* screenshot has none. When set it replaces `act`: what the loop shows is
* what the step does. It is recorded only while the step is registered
* under `clips`; a browser drag shows no dragged row in a recording, so such
* steps keep the still with its arrow and run the gesture unrecorded.
*/
clip?: (page: Page, frame: () => Promise<void>) => Promise<void>
}
export interface GuideScript {
@@ -184,30 +146,19 @@ export async function captureGuide(page: Page, script: GuideScript): Promise<voi
else await target.hover()
await settle(page)
box = (await target.boundingBox()) ?? box
let shot: 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)
shot = frameFor(union(box, to))
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
} else {
shot = frameFor(box)
await ring(page, box, n, shot)
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
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)
if (step.clip) {
// Only a step the guide registers under `clips` is recorded; the others do the gesture unrecorded.
if (guide.media.clips?.includes(n)) {
await recordLoop(page, path.join(dir, `step-${n}-loop`), shot, n, step.clip, `guide "${guide.id}" step ${n}`)
} else {
await step.clip(page, async () => undefined)
}
continue
}
}
await step.act?.(page)
}
@@ -448,96 +399,6 @@ async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void
)
}
// ── Loops ────────────────────────────────────────────────────────────────────
/**
* The usual body of a drag loop: a short rest on the item, the press, a glide
* to the destination in even steps with a frame each, the release, a rest on
* the result. Libraries that wait for a few pixels of movement before a drag
* starts (dnd-kit, react-beautiful-dnd) get them from the first small steps.
*/
export async function dragLoop(page: Page, from: Locator, to: Locator, frame: () => Promise<void>, steps = 24): Promise<void> {
const a = await from.boundingBox()
const b = await to.boundingBox()
if (!a || !b) throw new Error('dragLoop: the item or the destination has no box')
const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 }
const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
await page.mouse.move(start.x - 30, start.y + 20)
for (let i = 1; i <= 4; i++) {
await page.mouse.move(start.x - 30 + (30 * i) / 4, start.y + 20 - (20 * i) / 4)
await frame()
}
await page.mouse.down()
for (const nudge of [4, 8, 12]) await page.mouse.move(start.x + nudge, start.y + nudge)
await frame()
for (let i = 1; i <= steps; i++) {
const k = i / steps
const ease = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
await page.mouse.move(start.x + (end.x - start.x) * ease, start.y + (end.y - start.y) * ease, { steps: 2 })
await frame()
}
await page.mouse.up()
await page.waitForTimeout(400)
for (let i = 0; i < 3; i++) await frame()
}
/**
* Record a step's loop as numbered PNG frames; promote.mjs turns the directory
* into `step-<n>.loop.webp`. The frames carry the step's number in the corner,
* like the still, and a drawn pointer that follows the mouse.
*/
async function recordLoop(
page: Page,
dir: string,
shot: Box,
n: number,
clip: NonNullable<StepAction['clip']>,
label: string,
): Promise<void> {
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
await page.evaluate(
({ shot, n }) => {
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
const layer = document.createElement('div')
layer.id = 'trek-help-loop'
Object.assign(layer.style, { position: 'fixed', inset: '0', pointerEvents: 'none', zIndex: '2147483647' })
const badge = document.createElement('div')
Object.assign(badge.style, {
position: 'absolute', left: `${shot.x + 10}px`, top: `${shot.y + 10}px`, width: '28px', height: '28px',
borderRadius: '999px', background: accent, color: accentText, textAlign: 'center',
font: '700 13px/28px Poppins, system-ui, sans-serif', boxShadow: '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)',
})
badge.textContent = String(n)
const pointer = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
pointer.setAttribute('width', '24')
pointer.setAttribute('height', '24')
pointer.setAttribute('viewBox', '0 0 24 24')
pointer.innerHTML = '<path d="M4 2 L4 19 L8.5 14.8 L11.6 21.5 L14.4 20.2 L11.3 13.6 L17.5 13.6 Z" fill="#111827" stroke="#ffffff" stroke-width="1.6" stroke-linejoin="round"/>'
Object.assign(pointer.style, { position: 'absolute', left: '-40px', top: '-40px', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))' })
layer.append(badge, pointer)
document.body.appendChild(layer)
document.addEventListener('mousemove', e => {
pointer.style.left = `${e.clientX - 4}px`
pointer.style.top = `${e.clientY - 2}px`
}, { capture: true })
document.addEventListener('pointermove', e => {
pointer.style.left = `${e.clientX - 4}px`
pointer.style.top = `${e.clientY - 2}px`
}, { capture: true })
},
{ shot, n },
)
let count = 0
await clip(page, async () => {
await page.screenshot({ path: path.join(dir, `${String(count).padStart(4, '0')}.png`), clip: shot })
count++
})
await page.evaluate(() => document.getElementById('trek-help-loop')?.remove())
if (count < 2) throw new Error(`${label}: a loop needs at least two frames, got ${count}`)
}
async function unring(page: Page): Promise<void> {
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
}
+4 -420
View File
@@ -2,8 +2,7 @@ import { test, expect, type APIResponse, type Locator, type Page } from '@playwr
import { readFileSync } from 'node:fs'
import { clearNotices } from '../screenshots/shot'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
import { connectJournalDawarich, releaseJournalDawarich, STAY_DAY, writtenEntryIds } from './fixtures-journey'
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
import type { HelpGuide } from '../../src/help/types'
@@ -345,109 +344,6 @@ async function unlinkTrip(page: Page, title: string): Promise<void> {
await answer(res, `unlink "${title}"`)
}
/** The fixtures leave every entry published; null is not a value the column takes, false is. */
async function clearDraft(page: Page, title: string): Promise<void> {
const entry = await journalEntry(page, title)
await answer(await page.request.patch(`/api/journeys/entries/${entry.id}`, { data: { is_draft: false } }), `publish "${title}" again`)
}
async function setJourney(page: Page, data: Record<string, boolean>): Promise<void> {
await answer(await page.request.patch(`/api/journeys/${journeyId}`, { data }), `update the journey with ${JSON.stringify(data)}`)
}
// ── The map and a recorded day ────────────────────────────────────────────────
/** The frosted switches at the top of the map: Plan, Recorded, Entries, Photos and the key. */
const layerBar = (page: Page) => page.getByRole('group', { name: 'Map layers' })
/**
* A hollow plan pin inside the map's frame. The map keeps the pins it drew
* outside the view in the DOM as well, and the plan pins carry no class of
* their own, only their 22 × 28 drawing, so the one in view is marked for the
* step and found by the mark.
*/
const PIN_MARK = 'data-help-plan-pin'
/** A pin of either map engine: Leaflet's, or the vector map's. */
const MARKERS = ':is(.leaflet-marker-icon, .maplibregl-marker, .mapboxgl-marker)'
const planPin = (page: Page) => page.locator(`[${PIN_MARK}]`)
/**
* The plan pins lie below the entry pins on purpose, and in the Japan journey
* most planned places carry an entry on the same spot, so a pin is only taken
* when its own centre is what a click there would hit. The layer is also drawn
* anew on every moveend, which drops a mark set while the first fit still
* moves the map, so the pin must be found at the same place twice in a row.
*/
async function markPlanPin(page: Page): Promise<void> {
// Zoomed out over the whole trip, the plan pins gather into discs and the one
// left alone sits under an entry pin; each zoom step pulls them apart.
for (let zoom = 0; zoom < 2; zoom++) {
if (await findPlanPin(page, 6_000).then(() => true, () => false)) return
// The map's own + in its corner; it carries no name, only the sign.
await mapPanel(page).getByRole('button', { name: '+', exact: true }).click()
await page.waitForTimeout(800)
}
await findPlanPin(page, 20_000)
}
/** Mark a plan pin that lies well inside the frame and that nothing covers, once the map holds still. */
async function findPlanPin(page: Page, timeout: number): Promise<void> {
let last = ''
await expect
.poll(
async () => {
const found = await mapPanel(page).evaluate((aside, { attr, markers }) => {
for (const old of Array.from(document.querySelectorAll(`[${attr}]`))) old.removeAttribute(attr)
const frame = aside.getBoundingClientRect()
const pins = Array.from(aside.querySelectorAll(markers)).filter(el => el.querySelector('svg[viewBox="0 0 22 28"]'))
for (const el of pins) {
const r = el.getBoundingClientRect()
// Clear of the layer bar at the top and the edges, so the card it opens has room.
if (!(r.left > frame.left + 80 && r.right < frame.right - 80 && r.top > frame.top + 140 && r.bottom < frame.bottom - 80)) continue
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
if (!hit || !el.contains(hit)) continue
el.setAttribute(attr, '')
return `${Math.round(r.left)}:${Math.round(r.top)}`
}
return ''
}, { attr: PIN_MARK, markers: MARKERS })
const steady = found !== '' && found === last
last = found
return steady
},
{ timeout, intervals: [400], message: 'no plan pin inside the journey map that an entry pin does not cover' },
)
.toBe(true)
}
/** One day of the timeline, by the date it carries, and its header row. */
const dayBlock = (page: Page, date = STAY_DAY) => page.locator(`[data-day="${date}"]`)
const dayHeader = (page: Page, date = STAY_DAY) => dayBlock(page, date).locator(':scope > div').first()
/** The folded row of the day's stays and the button that opens it. */
const stayRowToggle = (page: Page) => dayBlock(page).getByRole('button', { name: /stays? from Dawarich/ })
/** The dialog a recorded day opens in: the shared DialogShell, titled with the day. */
const dayDialog = (page: Page, button: RegExp | string) =>
page.getByRole('dialog').filter({ has: page.getByRole('button', { name: button }) }).last()
const recordingDialog = (page: Page) => dayDialog(page, 'Keep route')
const SUBMIT = /^Take over \d+ drafts?$/
const importDialog = (page: Page) => dayDialog(page, SUBMIT)
/** The entries a recorded-day guide found, so its cleanup deletes only what it wrote. */
let entriesBefore = new Set<number>()
/** Dawarich connected with the stays waiting, then the journal scrolled to the stay day. */
async function openRecordedDay(page: Page): Promise<void> {
await connectJournalDawarich(page.request, journeyId)
entriesBefore = await writtenEntryIds(page.request, journeyId)
await openJournal(page)
await expect(dayBlock(page)).toBeVisible({ timeout: 20_000 })
await expect(dayBlock(page).getByTestId('journey-day-recording')).toBeVisible({ timeout: 20_000 })
await dayHeader(page).evaluate(el => el.scrollIntoView({ block: 'start' }))
await settle(page)
}
const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request, journeyId, entriesBefore)
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
@@ -664,45 +560,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'entry-draft': {
guide: guide('entry-draft'),
start: async p => {
await clearDraft(p, ASAKUSA)
await openJournal(p)
},
cleanup: p => clearDraft(p, ASAKUSA),
steps: [
{
prepare: async p => {
await editEntry(p, ASAKUSA)
await editor(p).getByRole('button', { name: 'Draft', exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
// The row with the label and its hint, the switch at its end.
target: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).locator('xpath=..'),
hover: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).hover(),
act: async p => {
const toggle = editor(p).getByRole('button', { name: 'Draft', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
await beat(p, 400)
},
},
{
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
const badge = entryCard(p, ASAKUSA).getByText('Draft', { exact: true })
await expect(badge).toBeVisible({ timeout: 15_000 })
await entryCard(p, ASAKUSA).evaluate(el => el.scrollIntoView({ block: 'center' }))
await blurFocus(p)
await settle(p)
},
},
],
},
suggestions: {
guide: guide('suggestions'),
start: openJournal,
@@ -814,32 +671,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The day count between the two round buttons at the foot of the timeline.
prepare: async p => {
await p.getByPlaceholder('Search this journey').fill('')
await settle(p)
},
target: p => p.getByRole('button', { name: 'Jump to a day' }),
act: async p => {
await p.getByRole('button', { name: 'Jump to a day' }).click()
await expect(p.getByRole('menu')).toBeVisible()
await settle(p)
},
},
{
// The first day opened onto its entries, so the picture shows both levels.
prepare: async p => {
await p.getByRole('menu').getByRole('button', { name: 'Expand' }).first().click()
await settle(p)
},
target: p => p.getByRole('menu'),
hover: p => p.getByRole('menu').getByRole('menuitem').nth(1).hover(),
act: async p => {
await p.getByRole('menu').getByRole('menuitem').nth(1).click()
await expect(p.getByRole('menu')).toHaveCount(0)
await p.waitForTimeout(800) // the smooth scroll
await settle(p)
},
target: p => p.getByRole('button', { name: /^(Hide|Show) suggestions$/ }).first(),
},
],
},
@@ -855,268 +691,16 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
{
// Upload and, with Immich or Synology connected, a button per library, at the right of the toolbar.
target: p => p.getByRole('button', { name: 'Upload', exact: true }).first(),
},
{
prepare: async p => {
await p.getByRole('button', { name: 'Timeline', exact: true }).first().click()
await settle(p)
await p.waitForTimeout(600) // the map tiles
},
target: mapPanel,
},
],
},
'journey-map-layers': {
guide: guide('journey-map-layers'),
start: openJournal,
steps: [
{
target: layerBar,
// Plan off and on again: the switch is remembered in this browser, and the next guide finds it on.
act: async p => {
const plan = p.getByTestId('journey-map-layer-plan')
await plan.click()
await expect(plan).toHaveAttribute('aria-pressed', 'false')
await beat(p, 600)
await plan.click()
await expect(plan).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
prepare: async p => {
await p.getByTestId('journey-map-key-button').click()
await expect(p.getByTestId('journey-map-key')).toBeVisible()
await settle(p)
},
target: p => p.getByTestId('journey-map-key'),
hover: p => p.getByTestId('journey-map-key-button').hover(),
act: async p => {
await p.getByTestId('journey-map-key-button').click()
await expect(p.getByTestId('journey-map-key')).toHaveCount(0)
await settle(p)
},
},
{
prepare: markPlanPin,
target: planPin,
act: async p => {
await planPin(p).click()
await expect(mapPanel(p).getByText('Planned', { exact: true }).first()).toBeVisible({ timeout: 10_000 })
await settle(p)
},
},
],
},
// ── A recorded day (Dawarich) ───────────────────────────────────────────
'journey-keep-route': {
guide: guide('journey-keep-route'),
start: openRecordedDay,
cleanup: releaseRecordedDay,
steps: [
{
target: p => dayBlock(p).getByTestId('journey-day-recording'),
act: async p => {
await dayBlock(p).getByTestId('journey-day-recording').click()
// The dialog reads the day from Dawarich first; the button waits for the preview.
await expect(recordingDialog(p).getByRole('button', { name: 'Keep route' })).toBeEnabled({ timeout: 60_000 })
await settle(p)
await p.waitForTimeout(600) // the sketch's tiles
},
},
{
target: recordingDialog,
},
{
target: p => recordingDialog(p).getByRole('button', { name: 'Keep route' }),
act: async p => {
await recordingDialog(p).getByRole('button', { name: 'Keep route' }).click()
// Saved, the dialog closes; should it stay, its button reads Already kept and the X closes it.
const kept = p.getByRole('button', { name: 'Already kept' })
await expect.poll(async () => (await p.getByRole('button', { name: 'Keep route' }).count()) === 0 || (await kept.count()) > 0, { timeout: 30_000 }).toBe(true)
if (await anyDialog(p).count()) await closeDialog(p)
// A kept day shows its distance in the header instead of the Dawarich mark.
await expect(dayBlock(p).getByTestId('journey-day-recording')).toContainText(/\d/, { timeout: 15_000 })
await dayHeader(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await blurFocus(p)
await settle(p)
await p.waitForTimeout(600) // the map draws the route
},
},
],
},
'journey-day-stays': {
guide: guide('journey-day-stays'),
start: async p => {
await openRecordedDay(p)
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
cleanup: releaseRecordedDay,
steps: [
{
target: stayRowToggle,
act: async p => {
await stayRowToggle(p).click()
await expect(stayRowToggle(p)).toHaveAttribute('aria-expanded', 'true')
await expect(dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first()).toBeVisible()
await settle(p)
},
},
{
target: p => dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first(),
act: async p => {
const before = (await writtenEntryIds(p.request, journeyId)).size
await dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first().click()
await expect.poll(async () => (await writtenEntryIds(p.request, journeyId)).size, { timeout: 15_000 }).toBeGreaterThan(before)
await settle(p)
},
},
{
target: p => dayBlock(p).getByRole('button', { name: 'Not a place I visited' }).first(),
act: async p => {
const rows = dayBlock(p).getByRole('button', { name: 'Not a place I visited' })
const before = await rows.count()
await rows.first().click()
await expect.poll(() => rows.count(), { timeout: 15_000 }).toBeLessThan(before)
await blurFocus(p)
await settle(p)
},
},
],
},
// Nothing is written: Take over turns the stays into drafts for good, and a
// stay taken over is never offered again, so a second run would find none.
'journey-take-over-day': {
guide: guide('journey-take-over-day'),
start: async p => {
await openRecordedDay(p)
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
await settle(p)
},
cleanup: releaseRecordedDay,
steps: [
{
target: p => dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }),
act: async p => {
await dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }).click()
await expect(importDialog(p).locator('[data-stay-id]').first()).toBeVisible({ timeout: 60_000 })
await settle(p)
await p.waitForTimeout(600) // the day's map
},
},
{
prepare: async p => {
await importDialog(p).locator('[data-stay-id]').first().scrollIntoViewIfNeeded()
await markGroup(p, importDialog(p).locator('[data-stay-id]'))
},
target: groupMark,
hover: p => importDialog(p).locator('[data-stay-id] input').first().hover(),
act: async p => {
await unmarkGroup(p)
await typeInto(p, importDialog(p).locator('[data-stay-id] input').first(), 'Morning on the bridge')
await beat(p, 400)
},
},
{
target: p => importDialog(p).getByRole('group', { name: 'Stays' }),
act: async p => {
await importDialog(p).getByRole('group', { name: 'Stays' }).getByRole('button', { name: 'Day report' }).click()
await expect(importDialog(p).getByRole('button', { name: /^Take over 1 draft$/ })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await importDialog(p).getByRole('button', { name: SUBMIT }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => importDialog(p).getByRole('button', { name: SUBMIT }),
act: async p => {
await importDialog(p).getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(importDialog(p)).toHaveCount(0)
await settle(p)
},
},
],
},
'journey-auto-prepare': {
guide: guide('journey-auto-prepare'),
start: async p => {
await connectJournalDawarich(p.request, journeyId)
entriesBefore = await writtenEntryIds(p.request, journeyId)
await openJournal(p)
},
cleanup: releaseRecordedDay,
steps: [
{ target: settingsButton, act: openSettings },
{
prepare: async p => {
await dialog(p).getByText('Recorded days', { exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
// The label sits in a row of its own, so the section with its switch is two levels up.
target: p => dialog(p).getByText('Recorded days', { exact: true }).locator('xpath=../..'),
hover: p => dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' }).hover(),
act: async p => {
const toggle = dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
await settle(p)
},
},
{
// Switching on fetches at once; the line says when, with Fetch now beside it.
prepare: async p => {
await expect(dialog(p).getByTestId('journey-prep-status')).toBeVisible({ timeout: 30_000 })
// Fetch now shows only while a linked trip has days left to prepare; the fetch it starts first has to end.
await expect(dialog(p).getByRole('button', { name: 'Fetching…' })).toHaveCount(0, { timeout: 60_000 })
await settle(p)
},
target: p => dialog(p).getByTestId('journey-prep-status'),
act: closeDialog,
},
],
},
'photo-location': {
guide: guide('photo-location'),
start: async p => {
await setJourney(p, { photo_location: false })
await openJournal(p)
},
cleanup: p => setJourney(p, { photo_location: false }),
steps: [
{ target: settingsButton, act: openSettings },
{
prepare: async p => {
await dialog(p).getByRole('button', { name: 'Place entries from their photos' }).scrollIntoViewIfNeeded()
await settle(p)
},
// The row with the label, its hint and the switch.
target: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).locator('xpath=..'),
hover: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).hover(),
act: async p => {
const toggle = dialog(p).getByRole('button', { name: 'Place entries from their photos' })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
await beat(p, 400)
await closeDialog(p)
},
},
],
},
'entry-fields': {
guide: guide('entry-fields'),
start: openJournal,
@@ -1134,6 +718,8 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
// The spare trip gets stations on its days first: a trip without places adds
// nothing to the journal, and the result would show no change at all.
'journey-status': {
guide: guide('journey-status'),
start: openJournal,
@@ -1158,8 +744,6 @@ const SCRIPTS: Record<string, GuideScript> = {
}
},
},
// The spare trip gets stations on its days first: a trip without places adds
// nothing to the journal, and the result would show no change at all.
'link-trip': {
guide: guide('link-trip'),
start: async p => {
-245
View File
@@ -1,245 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { notificationsContext, notificationsGuides } from '../../src/help/contexts/notifications';
import type { HelpGuide } from '../../src/help/types';
import { clearNotices } from '../screenshots/shot';
import {
BOOKING_TITLE,
ensureNotificationFixtures,
markUnread,
removeNotificationFixtures,
SHARE_TITLE,
unreadIds,
} from './fixtures-notifications';
import { captureGuide, captureHero, dismissReleaseNotice, settle, VIEWPORT, type GuideScript } from './guide';
/**
* Actions for the notification guides, keyed by the ids in
* `src/help/contexts/notifications.ts`. The admin's inbox holds the seed's
* "New Message" notifications from the Japan trip's chat plus the two
* `fixtures-notifications.ts` adds: jonas saved a booking, mira shared her
* Vacay calendar. A guide that reads or deletes one of those gets it back
* unread in its cleanup, and the file ends by taking both away, so the bell
* every later picture shows counts what the seed left.
*
* Delete all is ringed but never clicked: it would also empty the seed's
* notifications, and the bell of every later screen would lose its number.
*/
const guide = (id: string): HelpGuide => {
const g = notificationsGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/**
* The bell in the navbar. Found by its title: with unread notifications its
* accessible name is the number on its badge, not "Notifications".
*/
const bell = (page: Page): Locator => page.getByTitle('Notifications', { exact: true }).first();
/** The panel the bell opens, found through its footer button. */
const panel = (page: Page): Locator => page.getByRole('button', { name: 'Show all notifications' }).locator('xpath=..');
/** The column of the /notifications page: header, filter pills and the list. */
const pageColumn = (page: Page): Locator => page.locator('.max-w-2xl').first();
/** One notification (InAppNotificationItem's root) by its title, in the panel or the page. */
const item = (scope: Locator, title: string): Locator =>
scope.locator('div.relative.px-4.py-3').filter({ hasText: title }).first();
/** The user menu button at the top right: avatar, name and chevron. */
const userMenu = (page: Page): Locator =>
page
.getByRole('button')
.filter({ has: page.locator('span.max-w-40') })
.first();
/** The Settings sidebar, found by its first tab. */
const settingsSidebar = (page: Page): Locator =>
page
.locator('nav')
.filter({ has: page.getByRole('button', { name: 'General' }) })
.first();
async function openDashboard(page: Page): Promise<void> {
await page.goto('/dashboard');
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(bell(page)).toBeVisible();
await settle(page);
}
async function openNotifications(page: Page): Promise<void> {
await page.goto('/notifications');
await clearNotices(page);
await dismissReleaseNotice(page);
await expect(item(pageColumn(page), SHARE_TITLE)).toBeVisible();
await settle(page);
}
async function openBell(page: Page): Promise<void> {
await bell(page).click();
await expect(item(panel(page), SHARE_TITLE)).toBeVisible();
await settle(page);
}
/** The unread ones the mark-read guide found, to give back in its cleanup. */
let unreadBefore: number[] = [];
const SCRIPTS: Record<string, GuideScript> = {
'read-notifications': {
guide: guide('read-notifications'),
start: openDashboard,
steps: [
{ target: bell, act: openBell },
{
target: panel,
// Over the newest one, so its unread tint is not covered by the pointer.
hover: async (p) => {
await item(panel(p), SHARE_TITLE).hover();
},
},
{
target: (p) => item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }),
act: async (p) => {
await item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }).click();
await expect(p).toHaveURL(/\/vacay/);
await settle(p);
},
},
],
// Following the share marked it read; the next guide wants it unread.
cleanup: async (p) => {
await ensureNotificationFixtures(p.request);
},
},
'filter-notifications': {
guide: guide('filter-notifications'),
start: openDashboard,
steps: [
{ target: bell, act: openBell },
{
target: (p) => p.getByRole('button', { name: 'Show all notifications' }),
act: async (p) => {
await p.getByRole('button', { name: 'Show all notifications' }).click();
await expect(p).toHaveURL(/\/notifications/);
await expect(item(pageColumn(p), SHARE_TITLE)).toBeVisible();
await settle(p);
},
},
{
target: (p) => pageColumn(p).getByRole('button', { name: 'Unread', exact: true }),
act: async (p) => {
await pageColumn(p).getByRole('button', { name: 'Unread', exact: true }).click();
await settle(p);
},
},
],
},
'mark-notifications-read': {
guide: guide('mark-notifications-read'),
start: async (p) => {
unreadBefore = await unreadIds(p.request);
await openNotifications(p);
},
steps: [
{
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read'),
act: async (p) => {
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read').click();
await expect(item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read')).toHaveCount(0);
await settle(p);
},
},
{
target: (p) => pageColumn(p).getByRole('button', { name: 'Mark all read' }),
act: async (p) => {
await pageColumn(p).getByRole('button', { name: 'Mark all read' }).click();
await expect(pageColumn(p).getByRole('button', { name: 'Mark all read' })).toHaveCount(0);
await settle(p);
},
},
],
cleanup: async (p) => {
await markUnread(p.request, unreadBefore);
await ensureNotificationFixtures(p.request);
},
},
'delete-notifications': {
guide: guide('delete-notifications'),
start: openNotifications,
steps: [
{
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }),
act: async (p) => {
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }).click();
await expect(pageColumn(p).locator('div.relative.px-4.py-3').filter({ hasText: BOOKING_TITLE })).toHaveCount(
0
);
await settle(p);
},
},
// Ringed only, see the file's comment.
{ target: (p) => pageColumn(p).getByRole('button', { name: 'Delete all' }) },
],
cleanup: async (p) => {
await ensureNotificationFixtures(p.request);
},
},
'notification-settings': {
guide: guide('notification-settings'),
start: openNotifications,
steps: [
{
target: userMenu,
act: async (p) => {
await userMenu(p).click();
await expect(p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' })).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }),
act: async (p) => {
await p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }).click();
await expect(settingsSidebar(p)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }),
act: async (p) => {
await settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }).click();
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureNotificationFixtures(request);
});
test.afterAll(async ({ request }) => {
await removeNotificationFixtures(request);
});
test('every registered notifications guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(notificationsContext.guides.slice().sort());
});
test('hero: notifications', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, notificationsContext.id, openNotifications);
});
for (const id of notificationsContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-29
View File
@@ -43,30 +43,6 @@ async function picture(src, dest, width) {
files++
}
// A step's loop: its frames, at the width of a step picture, as one animated
// WebP that loops forever. The first frame rests a little and the last one
// long enough to read the result before it starts again.
const FRAME_MS = 80
const FIRST_MS = 600
const LAST_MS = 1600
async function animation(srcDir, dest) {
const frames = readdirSync(srcDir).filter(f => f.endsWith('.png')).sort()
if (frames.length < 2) return
for (const f of frames) before += statSync(path.join(srcDir, f)).size
if (dry) {
console.log(` ${path.relative(SRC, srcDir)} → ${path.relative(DEST, dest)} (${frames.length} frames)`)
return
}
const buffers = await Promise.all(
frames.map(f => sharp(path.join(srcDir, f)).resize({ width: STEP_WIDTH, withoutEnlargement: true }).png().toBuffer()),
)
const delay = frames.map((_, i) => (i === 0 ? FIRST_MS : i === frames.length - 1 ? LAST_MS : FRAME_MS))
await sharp(buffers, { join: { animated: true } }).webp({ quality: 78, effort: 5, loop: 0, delay }).toFile(dest)
after += statSync(dest).size
files++
}
mkdirSync(DEST, { recursive: true })
for (const dir of readdirSync(SRC)) {
@@ -75,11 +51,6 @@ for (const dir of readdirSync(SRC)) {
const to = path.join(DEST, dir)
mkdirSync(to, { recursive: true })
for (const file of readdirSync(from)) {
const loop = /^step-(\d+)-loop$/.exec(file)
if (loop) {
await animation(path.join(from, file), path.join(to, `step-${loop[1]}.loop.webp`))
continue
}
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)
+77 -296
View File
@@ -2,30 +2,15 @@ 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 { ensureAirtrailFlights, ensureDawarichRecording, disconnectAirtrail, disconnectDawarich, toggleAddon } from './external'
import {
mediaEnv, wandererUrl, wandererToken, allowWanderer, ensureWandererConnection, disconnectWanderer,
ensureActivePlugin, removeSideloadedPlugin, parkOfflineChanges, clearParkedChanges,
} from './fixtures-settings'
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 card or a row, and the few that change something (theme, an API
* key, two-factor authentication) leave the account in a state the run can live
* 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.
*
* The cards come from `components/Settings/settingsKit.tsx`: a <section> with an
* <h2> in its head band, rows inside (`SettingRow`, a div with `px-3.5`), boxes
* of rows (`SettingRows`, a div with `divide-y`). A stacked row puts its wide
* control in a sibling below the label, so a row is found from its label by its
* own div, never by the label's parent.
*
* Connect AirTrail, Dawarich and the two wanderer guides need those services
* (media.env, see README and fixtures-settings.ts) and fail at their start
* without them; every other guide runs on the seed.
*/
const TAB_OF: Record<string, string> = {
@@ -34,10 +19,8 @@ const TAB_OF: Record<string, string> = {
'settings-map': 'Map',
'settings-notifications': 'Notifications',
'settings-integrations': 'Integrations',
'settings-plugins': 'Plugins',
'settings-offline': 'Offline',
'settings-account': 'Account',
'settings-about': 'About',
}
const guide = (id: string): HelpGuide => {
@@ -48,31 +31,14 @@ const guide = (id: string): HelpGuide => {
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 | RegExp) =>
page.locator('h2').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
/** The label of a field or a row inside a card. */
const label = (page: Page, sectionTitle: string | RegExp, text: string) =>
section(page, sectionTitle).getByText(text, { exact: true }).first()
/** A field of a section by its label: the label's own column, as before. */
const field = (page: Page, sectionTitle: string | RegExp, text: string) => label(page, sectionTitle, text).locator('xpath=..')
/** A whole row (label, control and whatever the row holds below them) by its label. */
const row = (page: Page, sectionTitle: string | RegExp, text: string) =>
label(page, sectionTitle, text).locator('xpath=ancestor::div[contains(@class,"px-3.5")][1]')
/** The box of rows a row sits in. */
const rows = (page: Page, sectionTitle: string | RegExp, text: string) =>
label(page, sectionTitle, text).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')
/** The line of buttons a card ends with, found by its Save. */
const buttonRow = (page: Page, sectionTitle: string | RegExp) =>
section(page, sectionTitle).getByRole('button', { name: 'Save', exact: true }).locator('xpath=..')
/** The two fields side by side (address and key) of a connection card, by the address field's id. */
const fieldPair = (page: Page, inputId: string) => page.locator(`#${inputId}`).locator('xpath=../..')
const dialog = (page: Page) => page.getByRole('dialog').last()
/** A sub-section headed by an h2 or h3 inside a card. */
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]')
const badge = (page: Page, sectionTitle: string, text: string) => section(page, sectionTitle).getByText(text, { exact: true }).first()
/** The background check's switch, not the info dot beside its label, which carries the same name. */
const watchSwitch = (page: Page) =>
section(page, /^wanderer$/).locator('button[aria-pressed][aria-label="Check my trails for changes"]')
async function openSettings(page: Page, tab: string): Promise<void> {
await page.goto('/settings')
@@ -102,20 +68,24 @@ function totp(secretBase32: string): string {
const only = (target: (p: Page) => Locator) => ({ target })
/**
* Save, wait for the card to say Connected, then Test connection and wait for
* its answer: the order the connect guides tell the reader.
*/
async function saveAndTest(page: Page, card: string, connected: string, answer: (p: Page) => Locator): Promise<void> {
await section(page, card).getByRole('button', { name: 'Save', exact: true }).click()
await expect(badge(page, card, connected)).toBeVisible({ timeout: 30_000 })
await section(page, card).getByRole('button', { name: 'Test connection' }).click()
await expect(answer(page)).toBeVisible({ timeout: 30_000 })
await settle(page)
}
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'),
@@ -129,25 +99,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
},
'language-region': {
guide: guide('language-region'),
start: opener('settings-display'),
steps: [
// The chips sit below the label in a stacked row, so the row, not the label's column.
only(p => row(p, 'Language & region', 'Language')),
only(p => row(p, 'Language & region', 'Place names')),
only(p => section(p, 'Language & region')),
],
},
'travel-map-prefs': {
guide: guide('travel-map-prefs'),
start: opener('settings-display'),
steps: [
only(p => row(p, 'Travel & map', 'Open places in')),
only(p => row(p, 'Travel & map', 'Always show booking routes')),
only(p => row(p, 'Travel & map', 'Explore places on the map')),
],
},
// ── Appearance ───────────────────────────────────────────────────────────
'theme-scheme': {
@@ -155,18 +106,20 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('settings-appearance'),
steps: [
{
target: p => section(p, 'Theme').getByRole('group', { name: 'Color Mode' }),
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 => row(p, 'Theme', 'Color scheme'),
// Custom brings up the accent row the next step is about.
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
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) },
},
{
target: p => row(p, 'Theme', 'Custom accent'),
// One of the preset dots (named by its hex), so the result shows a picked accent.
act: async p => { await section(p, 'Theme').locator('button[aria-label^="#"]').nth(2).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 => {
@@ -178,16 +131,15 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('readability'),
start: opener('settings-appearance'),
steps: [
only(p => rows(p, 'Readability', 'Transparency')),
only(p => row(p, 'Readability', 'Text size')),
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 => label(p, 'Dashboard widgets', 'Desktop').locator('xpath=ancestor::section[1]')),
only(p => label(p, 'Dashboard widgets', 'Mobile').locator('xpath=ancestor::section[1]')),
only(p => section(p, 'Dashboard widgets')),
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
],
},
@@ -197,15 +149,10 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('map-provider'),
start: opener('settings-map'),
steps: [
only(p => row(p, /^Map$/, 'Map Provider')),
only(p => section(p, /^Map$/).getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
{
// The Mapbox rows only exist with Mapbox GL picked; nothing is saved, the reload in cleanup drops it.
prepare: async p => { await section(p, /^Map$/).getByRole('button', { name: /Mapbox GL/ }).click(); await settle(p) },
target: p => rows(p, /^Map$/, '3D Buildings & Terrain'),
},
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=..')),
],
cleanup: async p => { await p.reload(); await settle(p) },
},
// ── Notifications ────────────────────────────────────────────────────────
@@ -213,18 +160,9 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('notification-channels'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Webhook URL')),
only(p => section(p, 'Ntfy')),
// The per-event matrix is the first card, headed Notifications.
only(p => section(p, /^Notifications$/)),
],
},
'web-push-device': {
guide: guide('web-push-device'),
start: opener('settings-notifications'),
steps: [
only(p => section(p, 'Push notifications on this device')),
only(p => section(p, /^Notifications$/)),
only(p => section(p, 'Webhook URL')),
only(p => page_rows(p)),
],
},
@@ -234,143 +172,29 @@ const SCRIPTS: Record<string, GuideScript> = {
start: opener('settings-integrations'),
steps: [
only(p => section(p, 'Immich')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]')),
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=..')),
],
},
'connect-airtrail': {
guide: guide('connect-airtrail'),
// The account holds the flights first, so Test connection has something to count.
start: async p => {
await ensureAirtrailFlights()
await disconnectAirtrail(p.request)
await toggleAddon(p.request, 'airtrail', true)
await openSettings(p, 'Integrations')
await expect(section(p, 'AirTrail')).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'airtrail-url'),
act: async p => {
await typeInto(p, p.locator('#airtrail-url'), mediaEnv('HELP_MEDIA_AIRTRAIL_URL'))
await typeInto(p, p.locator('#airtrail-key'), mediaEnv('HELP_MEDIA_AIRTRAIL_API_KEY'))
await settle(p)
},
},
only(p => rows(p, 'AirTrail', 'Allow self-signed certificates')),
{
target: p => buttonRow(p, 'AirTrail'),
act: p => saveAndTest(p, 'AirTrail', 'Connected', q => q.getByText(/^Connected\. \d+ flights? found$/).first()),
},
],
cleanup: p => disconnectAirtrail(p.request),
},
'connect-dawarich': {
guide: guide('connect-dawarich'),
// The recording first, so Test connection finds the stays of the last 30 days.
start: async p => {
await ensureDawarichRecording()
await disconnectDawarich(p.request)
await toggleAddon(p.request, 'dawarich', true)
await openSettings(p, 'Integrations')
await expect(section(p, 'Dawarich')).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'dawarich-url'),
act: async p => {
await typeInto(p, p.locator('#dawarich-url'), mediaEnv('HELP_MEDIA_DAWARICH_URL'))
await typeInto(p, p.locator('#dawarich-key'), mediaEnv('HELP_MEDIA_DAWARICH_API_KEY'))
await settle(p)
},
},
// Both switches stay off: an automatic check would seed suggestions into every later picture.
only(p => rows(p, 'Dawarich', 'Check for new stays automatically')),
{
target: p => buttonRow(p, 'Dawarich'),
act: p => saveAndTest(p, 'Dawarich', 'Connected', q => section(q, 'Dawarich').getByText(/^Connected\. \d+ stays? found in the last 30 days\.$/).first()),
},
],
cleanup: p => disconnectDawarich(p.request),
},
'tour-connect-wanderer': {
guide: guide('tour-connect-wanderer'),
start: async p => {
await allowWanderer(p.request)
await openSettings(p, 'Integrations')
await expect(section(p, /^wanderer$/)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
target: p => fieldPair(p, 'wanderer-url'),
act: async p => {
await typeInto(p, p.locator('#wanderer-url'), wandererUrl())
await typeInto(p, p.locator('#wanderer-token'), wandererToken())
await settle(p)
},
},
only(p => row(p, /^wanderer$/, 'Allow a self-signed certificate')),
{
target: p => buttonRow(p, /^wanderer$/),
act: async p => {
const card = section(p, /^wanderer$/)
await card.getByRole('button', { name: 'Save', exact: true }).click()
await expect(card.getByText('Connected', { exact: true }).first()).toBeVisible({ timeout: 30_000 })
await card.getByRole('button', { name: 'Test connection' }).click()
await expect(card.getByRole('status').getByText(/^Connected\./)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: p => disconnectWanderer(p.request),
},
'tour-wanderer-watch': {
guide: guide('tour-wanderer-watch'),
start: async p => {
await ensureWandererConnection(p.request)
await openSettings(p, 'Integrations')
await expect(section(p, /^wanderer$/)).toBeVisible({ timeout: 20_000 })
},
steps: [
{
// Found by its switch: the label also holds the info dot, whose hidden text would spoil a text match.
target: p => watchSwitch(p).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]'),
act: async p => {
await watchSwitch(p).click()
await expect(p.getByTestId('wanderer-watch-status')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(p => p.getByTestId('wanderer-watch-status')),
],
cleanup: p => disconnectWanderer(p.request),
},
'api-keys': {
guide: guide('api-keys'),
start: opener('settings-integrations'),
steps: [
{
prepare: async p => {
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
await expect(dialog(p).getByText('Create API key')).toBeVisible()
await typeInto(p, dialog(p).getByPlaceholder('e.g. Home dashboard'), 'Home Assistant')
// A key for a dashboard only needs the trips and their days.
for (const area of ['Bookings', 'Accommodation', 'Who is coming', 'Wishlist', 'Totals']) {
await dialog(p).getByRole('checkbox', { name: area }).click()
}
await settle(p)
},
target: p => dialog(p),
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
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 => dialog(p),
target: p => modal(p).locator('> [role="presentation"], > div').first(),
act: async p => {
await dialog(p).getByRole('button', { name: 'Done' }).click()
await expect(p.getByRole('dialog')).toHaveCount(0)
await modal(p).getByRole('button', { name: 'Done' }).click()
await expect(modal(p)).toHaveCount(0)
await settle(p)
},
},
@@ -380,36 +204,15 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('mcp-oauth'),
start: opener('settings-integrations'),
steps: [
only(p => field(p, 'MCP Configuration', 'MCP Endpoint')),
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 section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).click()
await expect(dialog(p).getByText('Register OAuth Client')).toBeVisible()
await dialog(p).getByRole('button', { name: 'Claude Desktop', exact: true }).click()
await settle(p)
},
target: p => dialog(p),
// Registered for real, so the next step has a client row with its two buttons.
act: async p => {
await dialog(p).getByRole('button', { name: 'Register Client', exact: true }).click()
await expect(dialog(p).getByText('Client Registered')).toBeVisible({ timeout: 15_000 })
await dialog(p).getByRole('button', { name: 'Done', exact: true }).click()
await expect(p.getByRole('dialog')).toHaveCount(0)
await settle(p)
},
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=../..'),
},
only(p => section(p, 'MCP Configuration').getByRole('button', { name: 'Rotate Secret' }).first().locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
],
},
// ── Plugins ──────────────────────────────────────────────────────────────
'plugin-settings': {
guide: guide('plugin-settings'),
start: opener('settings-plugins'),
steps: [
only(p => section(p, 'Plugin settings')),
only(p => section(p, 'Plugin activity')),
],
},
@@ -420,25 +223,17 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => section(p, 'What to store offline')),
only(p => p.getByRole('button', { name: 'Download for offline use' })),
only(p => row(p, 'Offline mode', 'Force offline mode')),
only(p => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
],
},
'offline-conflicts': {
guide: guide('offline-conflicts'),
// The conflicts card only exists while a change waits; two are parked in this browser's queue first.
start: async p => {
await openSettings(p, 'General')
await parkOfflineChanges(p)
await sidebar(p).getByRole('button', { name: 'Offline', exact: true }).click()
await expect(section(p, 'Sync conflicts')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
start: opener('settings-offline'),
steps: [
only(p => section(p, 'Sync conflicts').getByRole('button', { name: 'Keep mine' }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
only(p => row(p, 'Sync conflicts', 'When a conflict happens')),
only(p => section(p, 'Offline cache').getByRole('button', { name: 'Try again' }).locator('xpath=../..')),
// 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' })),
],
cleanup: async p => { await clearParkedChanges(p); await p.reload(); await settle(p) },
},
// ── Account ──────────────────────────────────────────────────────────────
@@ -446,9 +241,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('profile'),
start: opener('settings-account'),
steps: [
// The identity strip: the picture with its camera, the name and the role.
only(p => section(p, 'Account').locator('div[class*="rounded-[14px]"]').first()),
only(p => fieldPair(p, 'account-username')),
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
],
},
@@ -456,17 +249,9 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('password'),
start: opener('settings-account'),
steps: [
{
prepare: async p => {
// A few characters, so the checklist shows rules met and rules still open.
await typeInto(p, p.locator('#account-new-password'), 'Kyoto2026')
await settle(p)
},
target: p => section(p, 'Change Password'),
},
only(p => section(p, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
only(p => section(p, 'Change Password').getByRole('button', { name: 'Update password' })),
],
cleanup: async p => { await p.reload(); await settle(p) },
},
mfa: {
guide: guide('mfa'),
@@ -492,8 +277,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The whole panel: the codes and the Copy codes, Download TXT and Print / PDF buttons under them.
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes', { exact: true }).first().locator('xpath=../..'),
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
},
],
},
@@ -501,7 +285,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('passkeys'),
start: opener('settings-account'),
steps: [
// A passkey needs a device prompt the run cannot answer, so this guide has no pictures.
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
{},
{},
],
@@ -515,28 +299,25 @@ const SCRIPTS: Record<string, GuideScript> = {
},
}
/** 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]
/** Whether this file sideloaded a plugin for the Plugins tab and has to take it away again. */
let sideloadedPlugin = false
// The photo-provider sections exist only for providers the admin switched on, the ntfy,
// webhook and push cards only for channels the admin turned on, and the Plugins tab only
// while a plugin is active.
// 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,push' } })
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()}`)
sideloadedPlugin = await ensureActivePlugin(request)
})
test.afterAll(async ({ request }) => {
if (sideloadedPlugin) await removeSideloadedPlugin(request)
})
test('every registered settings guide has a script, and only those', async () => {
-312
View File
@@ -1,312 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { at, day } from '../dates';
import { setTripCover, type CoverPhoto } from '../screenshots/photos';
import { EXTRA_TRIPS, SPARE_TRIP, ensureExtraTrips } from './fixtures';
/**
* The trips around the seeded one, made to look lived in.
*
* "Autumn in Japan" is the trip every guide works on, but the dashboard, the
* journey and the collections pictures show the others too, and a grid of
* grey cards with no plan in them reads as an empty demo. So the other trips
* get a cover photo, a few real places on their days and the bookings a
* traveller makes months ahead: the flight and the hotel for Lisbon, the
* ferry and the car for Norway. Those are also what the dashboard's Upcoming
* reservations widget lists, which stayed empty while the only bookings were
* on a trip that ends today.
*
* Runs once after the seed. Everything is looked up by title or name first,
* so a second run changes nothing.
*/
interface ShowcasePlace {
name: string;
lat: number;
lng: number;
address: string;
description: string;
/** Index of the trip day, or none for an idea not planned yet. */
day?: number;
start?: string;
end?: string;
category: string;
}
interface ShowcaseBooking {
title: string;
type: string;
status: 'confirmed' | 'pending';
/** Days from the picture day and a local time, through `at`. */
starts: [number, string];
ends?: [number, string];
confirmation_number?: string;
location?: string;
metadata?: Record<string, string>;
endpoints?: Array<{
role: 'from' | 'to';
name: string;
code?: string;
lat: number;
lng: number;
timezone: string;
date: number;
time: string;
}>;
}
interface ShowcaseTrip {
title: string;
cover: CoverPhoto;
places: ShowcasePlace[];
bookings: ShowcaseBooking[];
}
const LISBON_START = 25;
const NORWAY_START = 257;
const SHOWCASE: ShowcaseTrip[] = [
{
title: EXTRA_TRIPS[0].title,
cover: 'lisbon',
places: [
{
name: 'Torre de Belém',
lat: 38.6916,
lng: -9.216,
address: 'Av. Brasília, Lisbon',
description: 'Sixteenth-century tower on the Tagus, the start of the voyages of discovery.',
day: 1,
start: '10:00',
end: '11:00',
category: 'Attraction',
},
{
name: 'Pastéis de Belém',
lat: 38.6975,
lng: -9.2032,
address: 'R. de Belém 84, Lisbon',
description: 'The original custard tarts since 1837, eaten warm with cinnamon.',
day: 1,
start: '11:15',
end: '11:45',
category: 'Bar/Cafe',
},
{
name: 'Miradouro da Senhora do Monte',
lat: 38.7193,
lng: -9.1327,
address: 'Largo Monte, Lisbon',
description: 'The highest viewpoint over the old town, best at sunset.',
day: 0,
start: '19:00',
end: '20:00',
category: 'Attraction',
},
{
name: 'Palácio da Pena',
lat: 38.7876,
lng: -9.3906,
address: 'Estrada da Pena, Sintra',
description: 'The painted palace on the hill above Sintra, a day trip by train.',
day: 2,
start: '10:30',
end: '13:00',
category: 'Attraction',
},
],
bookings: [
{
title: 'TP579 FRA → LIS',
type: 'flight',
status: 'confirmed',
starts: [LISBON_START, '09:40'],
ends: [LISBON_START, '11:35'],
confirmation_number: 'K4R7TW',
location: 'Frankfurt Airport',
metadata: {
airline: 'TAP Air Portugal',
flight_number: 'TP579',
departure_airport: 'FRA',
arrival_airport: 'LIS',
},
endpoints: [
{
role: 'from',
name: 'Frankfurt Airport',
code: 'FRA',
lat: 50.0379,
lng: 8.5622,
timezone: 'Europe/Berlin',
date: LISBON_START,
time: '09:40',
},
{
role: 'to',
name: 'Lisbon Humberto Delgado',
code: 'LIS',
lat: 38.7742,
lng: -9.1342,
timezone: 'Europe/Lisbon',
date: LISBON_START,
time: '11:35',
},
],
},
{
title: 'Memmo Alfama Hotel',
type: 'hotel',
status: 'confirmed',
starts: [LISBON_START, '15:00'],
ends: [LISBON_START + 2, '11:00'],
confirmation_number: 'MA-88213',
location: 'Travessa das Merceeiras 27, Lisbon',
},
{
title: 'Train to Sintra',
type: 'train',
status: 'pending',
starts: [LISBON_START + 2, '09:11'],
ends: [LISBON_START + 2, '09:52'],
location: 'Rossio Station',
},
],
},
{
title: EXTRA_TRIPS[1].title,
cover: 'norway',
places: [
{
name: 'Bryggen',
lat: 60.3976,
lng: 5.3247,
address: 'Bryggen, Bergen',
description: 'The Hanseatic wharf with its leaning wooden warehouses.',
day: 0,
start: '16:00',
end: '17:30',
category: 'Attraction',
},
{
name: 'Trollstigen',
lat: 62.4549,
lng: 7.6699,
address: 'Trollstigen, Rauma',
description: 'Eleven hairpins up the mountain road, with a viewing platform at the top.',
day: 4,
start: '11:00',
end: '12:00',
category: 'Nature',
},
{
name: 'Reine',
lat: 67.9324,
lng: 13.0886,
address: 'Reine, Moskenes',
description: 'Red fishing cabins under granite peaks in the Lofoten.',
category: 'Nature',
},
],
bookings: [
{
title: 'Rental car, Bergen airport',
type: 'car',
status: 'confirmed',
starts: [NORWAY_START, '13:00'],
ends: [NORWAY_START + 14, '10:00'],
confirmation_number: 'HZ-5521907',
location: 'Bergen Airport Flesland',
},
{
title: 'Ferry Bodø → Moskenes',
type: 'ferry',
status: 'pending',
starts: [NORWAY_START + 10, '15:00'],
ends: [NORWAY_START + 10, '18:30'],
location: 'Bodø ferry terminal',
},
],
},
{ title: SPARE_TRIP.title, cover: 'alps', places: [], bookings: [] },
];
type Trip = { id: number; title: string; cover_image?: string | null };
async function list<T>(api: APIRequestContext, url: string, key: string): Promise<T[]> {
const res = await api.get(url);
if (!res.ok()) throw new Error(`GET ${url}: ${res.status()} ${await res.text()}`);
const body = (await res.json()) as Record<string, T[]> | T[];
return Array.isArray(body) ? body : (body[key] ?? []);
}
async function post(api: APIRequestContext, url: string, data: unknown): Promise<Record<string, unknown>> {
const res = await api.post(url, { data });
if (!res.ok()) throw new Error(`POST ${url}: ${res.status()} ${await res.text()}`);
return (await res.json()) as Record<string, unknown>;
}
async function fillTrip(
api: APIRequestContext,
trip: Trip,
spec: ShowcaseTrip,
categories: { id: number; name: string }[]
): Promise<void> {
if (!trip.cover_image) await setTripCover(api, trip.id, spec.cover);
const days = await list<{ id: number }>(api, `/api/trips/${trip.id}/days`, 'days');
const places = await list<{ name: string }>(api, `/api/trips/${trip.id}/places`, 'places');
for (const place of spec.places) {
if (places.some((p) => p.name === place.name)) continue;
const { day: dayIndex, start, end, category, ...payload } = place;
const categoryId = categories.find((c) => c.name === category)?.id;
const created = (await post(api, `/api/trips/${trip.id}/places`, {
...payload,
...(categoryId ? { category_id: categoryId } : {}),
})) as { place: { id: number } };
const dayId = dayIndex === undefined ? undefined : days[dayIndex]?.id;
if (!dayId) continue;
const assigned = (await post(api, `/api/trips/${trip.id}/days/${dayId}/assignments`, {
place_id: created.place.id,
})) as { assignment: { id: number } };
if (start) {
const res = await api.put(`/api/trips/${trip.id}/assignments/${assigned.assignment.id}/time`, {
data: { place_time: start, end_time: end ?? null },
});
if (!res.ok()) throw new Error(`time for ${place.name}: ${res.status()} ${await res.text()}`);
}
}
const bookings = await list<{ title: string }>(api, `/api/trips/${trip.id}/reservations`, 'reservations');
for (const booking of spec.bookings) {
if (bookings.some((b) => b.title === booking.title)) continue;
const { starts, ends, endpoints, ...rest } = booking;
await post(api, `/api/trips/${trip.id}/reservations`, {
...rest,
reservation_time: at(starts[0], starts[1]),
...(ends ? { reservation_end_time: at(ends[0], ends[1]) } : {}),
...(endpoints
? {
endpoints: endpoints.map(({ date, time, ...e }, sequence) => ({
...e,
sequence,
local_date: day(date),
local_time: time,
})),
}
: {}),
});
}
}
export async function ensureShowcase(api: APIRequestContext): Promise<void> {
await ensureExtraTrips(api);
let trips = await list<Trip>(api, '/api/trips', 'trips');
if (!trips.some((t) => t.title === SPARE_TRIP.title)) {
await post(api, '/api/trips', SPARE_TRIP);
trips = await list<Trip>(api, '/api/trips', 'trips');
}
const categories = await list<{ id: number; name: string }>(api, '/api/categories', 'categories');
for (const spec of SHOWCASE) {
const trip = trips.find((t) => t.title === spec.title);
if (trip) await fillTrip(api, trip, spec, categories);
}
}
File diff suppressed because it is too large Load Diff
+6 -142
View File
@@ -1,6 +1,6 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { day } from '../dates'
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'
@@ -54,29 +54,6 @@ const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwit
// ── Timeline and detail ───────────────────────────────────────────────────────
/** The sliders after the view icons: Group by and Sort by, or the timeline's lanes. */
const viewOptionsButton = (page: Page) => toolbar(page).getByRole('button', { name: 'View options', exact: true })
/** The view options panel is a menu like the filter's; only one is open at a time. */
const viewMenu = (page: Page) => page.getByRole('menu')
/** An item of the panel. Type stands under Group by and Sort by alike, so the first is Group by's. */
const viewItem = (page: Page, name: string) => viewMenu(page).getByRole('button', { name, exact: true }).first()
const exportButton = (page: Page) => toolbar(page).getByRole('button', { name: /^Export (as spreadsheet|the \d+ of \d+ shown rows) \(CSV\)$/ })
/** A section's heading in Cards: a button with the label and its count, folding the section. */
const sectionHead = (page: Page, label: string) =>
page.locator('button[aria-expanded]').filter({ hasText: new RegExp(String.raw`^${label}\s*\d+$`) })
async function openViewOptions(page: Page): Promise<void> {
if (!(await viewMenu(page).isVisible().catch(() => false))) await viewOptionsButton(page).click()
await expect(viewMenu(page)).toBeVisible()
await beat(page, 300)
}
/** The bar's buttons toggle their panel; a second click puts it away. */
async function closeViewOptions(page: Page): Promise<void> {
if (await viewMenu(page).isVisible().catch(() => false)) await viewOptionsButton(page).click()
await expect(viewMenu(page)).toHaveCount(0)
}
/** 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. */
@@ -151,14 +128,12 @@ async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: stri
/**
* Set a date without touching the calendar: every date field has a keyboard
* button beside it that swaps in a text box, which is one fill instead of a
* month of grid cells that move with the trip's dates. The box's placeholder
* follows the locale (MM/DD/YYYY in en-US), but it takes an ISO date in every
* locale, so the value is `day(offset)` and the box is found by its name.
* 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).getByRole('textbox', { name: 'Enter date manually' })
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
await box.fill(value)
await box.press('Enter')
await expect(box).toHaveCount(0)
@@ -273,53 +248,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await clearPanelState(p)
},
},
'group-sort-bookings': {
guide: guide('group-sort-bookings'),
start: openBookings,
steps: [
{
target: viewOptionsButton,
act: async p => {
await openViewOptions(p)
await expect(viewItem(p, 'No grouping')).toBeVisible()
await settle(p)
},
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Type'),
act: async p => {
await viewItem(p, 'Type').click()
await expect(sectionHead(p, 'Tour')).toBeVisible()
await settle(p)
},
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Title'),
act: async p => {
await viewItem(p, 'Title').click()
// The direction row under the four reads the order now in force.
await expect(viewItem(p, 'A to Z')).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
await closeViewOptions(p)
await settle(p)
},
target: p => sectionHead(p, 'Tour'),
act: async p => {
await sectionHead(p, 'Tour').click()
await expect(sectionHead(p, 'Tour')).toHaveAttribute('aria-expanded', 'false')
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'create-booking': {
guide: guide('create-booking'),
start: openBookings,
@@ -349,9 +277,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await setDate(p, 0, day(-2))
await setDate(p, 0, dotted(-2))
await timeBox(p).nth(0).fill('16:30')
await setDate(p, 1, day(-2))
await setDate(p, 1, dotted(-2))
await timeBox(p).nth(1).fill('20:00')
await settle(p)
},
@@ -475,35 +403,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => restore(p, 'teamLab Planets timed entry'),
},
'booking-on-map': {
guide: guide('booking-on-map'),
start: openBookings,
steps: [
{
target: p => bookingCard(p, VIEWED.title),
act: async p => {
// The title, not the middle of the card, which may be the address link.
await bookingCard(p, VIEWED.title).getByText(VIEWED.title, { exact: true }).click()
await expect(detail(p, VIEWED.title)).toBeVisible()
await settle(p)
},
},
{
target: p => detail(p, VIEWED.title).getByRole('button', { name: 'On map', exact: true }),
act: async p => {
await detail(p, VIEWED.title).getByRole('button', { name: 'On map', exact: true }).click()
await expect(detail(p, VIEWED.title)).toHaveCount(0)
// The tab goes and the plan opens on the tour's day with its place selected.
await expect(p.getByRole('heading', { name: 'Bookings', exact: true, level: 2 })).toHaveCount(0)
await expect(p.getByText('Fushimi Inari Taisha').first()).toBeVisible({ timeout: 20_000 })
// The map flies to the place and the tiles land after it.
await p.waitForTimeout(1200)
await settle(p)
},
},
],
cleanup: clearPanelState,
},
'booking-travelers': {
guide: guide('booking-travelers'),
start: openBookings,
@@ -732,41 +631,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: clearPanelState,
},
'export-bookings-csv': {
guide: guide('export-bookings-csv'),
start: openBookings,
steps: [
{
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(resultsChip(p)).toBeVisible()
await settle(p)
},
target: p => searchBox(p).locator('xpath=..'),
act: settle,
},
{
prepare: openViewOptions,
target: p => viewItem(p, 'Title'),
act: async p => {
await viewItem(p, 'Title').click()
await expect(viewItem(p, 'A to Z')).toBeVisible()
await closeViewOptions(p)
await settle(p)
},
},
{
target: exportButton,
act: async p => {
const download = p.waitForEvent('download')
await exportButton(p).click()
expect((await download).suggestedFilename()).toMatch(/-bookings\.csv$/)
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
+1 -189
View File
@@ -4,7 +4,6 @@ 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 { MANAGE_NOTE, MANAGE_NOTE_EDIT, ensureManageNote, removeNotes, restoreNoteCategories } from './fixtures-trip-collab'
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
import type { HelpGuide } from '../../src/help/types'
@@ -16,10 +15,7 @@ import type { HelpGuide } from '../../src/help/types'
* 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. The note `manage-notes` works
* on is its own (`fixtures-trip-collab.ts`), written at its start and deleted by
* its last step; `note-categories` renames and recolours seeded categories and
* puts them back in `cleanup`.
* why `trip-chat` is the last guide of the screen.
*/
const guide = (id: string): HelpGuide => {
@@ -109,31 +105,6 @@ const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (pa
* 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...') })
/** The full view of a note (Expand): a dialog without a text box, holding the note's title. */
const noteView = (page: Page, title: string) =>
page.getByRole('dialog').filter({ hasText: title }).filter({ hasNot: page.getByPlaceholder('Write something...') })
const categoryDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('New category...') })
/**
* One row of Manage Categories, from the button that carries its name. Tooltip
* clones its child instead of wrapping it, so the button's parent is the row
* with the dot, the name, the six swatches and the bin.
*/
const categoryRow = (page: Page, name: string) =>
categoryDialog(page).getByRole('button', { name, exact: true }).locator('xpath=..')
/** The box a category's name turns into while it is renamed: the dialog's one field without a placeholder. */
const renameBox = (page: Page) => categoryDialog(page).locator('input:not([placeholder])')
/** The card's menu is a ContextMenu portal; its items are plain buttons. */
const noteMenu = (page: Page) => page.getByRole('menu')
const openNoteMenu = async (page: Page, title: string): Promise<void> => {
await noteCard(page, title).getByRole('button', { name: 'More options', exact: true }).click()
await expect(noteMenu(page)).toBeVisible()
await beat(page, 300)
}
/** The Delete note? question: ConfirmDialog has no dialog role, so its panel is found from its heading. */
const confirmPanel = (page: Page, title: string) =>
page.getByRole('heading', { name: title, exact: true }).locator('xpath=ancestor::div[@role="presentation"][1]')
/** The filter row under the Notes head band: All and one pill per category. */
const categoryPills = (page: Page) => notesPanel(page).getByRole('button', { name: 'All', exact: true }).locator('xpath=..')
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. */
@@ -160,13 +131,6 @@ const start = async (page: Page): Promise<void> => {
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
/** A category added in Manage Categories and used by no note is kept in this browser only. */
const NEW_CATEGORY = 'Food'
/** The renamed category and the recoloured one; `cleanup` puts both back. */
const RENAME = { from: 'Ideas', to: 'Bad weather' }
const RECOLOUR = { category: 'Accommodation', swatch: 'Amber' }
const FILTER = 'Transport'
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>
@@ -257,158 +221,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: page => deleteNote(page, NOTE.title),
},
'manage-notes': {
guide: guide('manage-notes'),
start: async page => {
await ensureManageNote(page.request)
await start(page)
await expect(noteCard(page, MANAGE_NOTE.title)).toBeVisible({ timeout: 15_000 })
},
steps: [
{
target: page => noteCard(page, MANAGE_NOTE.title).getByRole('button', { name: 'More options', exact: true }),
act: async page => {
await noteCard(page, MANAGE_NOTE.title).getByRole('button', { name: 'More options', exact: true }).click()
await expect(noteMenu(page)).toBeVisible()
},
},
{
// The picture is the note opened in full: its text, its Website and Edit at its foot.
prepare: async page => {
await noteMenu(page).getByRole('button', { name: 'Expand', exact: true }).click()
await expect(noteView(page, MANAGE_NOTE.title)).toBeVisible()
await settle(page)
},
target: page => noteView(page, MANAGE_NOTE.title),
act: async page => {
await noteView(page, MANAGE_NOTE.title).getByRole('button', { name: 'Close', exact: true }).click()
await expect(noteView(page, MANAGE_NOTE.title)).toHaveCount(0)
},
},
{
// The whole menu, like the other menus of the library: Pin is in it, between Expand and Edit.
prepare: page => openNoteMenu(page, MANAGE_NOTE.title),
target: noteMenu,
hover: async page => { await noteMenu(page).getByRole('button', { name: 'Pin', exact: true }).hover() },
act: async page => {
await noteMenu(page).getByRole('button', { name: 'Pin', exact: true }).click()
await expect(notesPanel(page).getByRole('article').first()).toHaveAttribute('aria-label', MANAGE_NOTE.title, { timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await openNoteMenu(page, MANAGE_NOTE.title)
await noteMenu(page).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(noteDialog(page)).toBeVisible()
await typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), `${MANAGE_NOTE.content}\n${MANAGE_NOTE_EDIT}`)
await beat(page, 300)
},
target: noteDialog,
hover: async page => { await noteDialog(page).getByRole('button', { name: 'Save', exact: true }).hover() },
act: async page => {
await noteDialog(page).getByRole('button', { name: 'Save', exact: true }).click()
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
{
prepare: async page => {
await openNoteMenu(page, MANAGE_NOTE.title)
await noteMenu(page).getByRole('button', { name: 'Delete', exact: true }).click()
await expect(confirmPanel(page, 'Delete note?')).toBeVisible()
await beat(page, 300)
},
target: page => confirmPanel(page, 'Delete note?'),
hover: async page => { await confirmPanel(page, 'Delete note?').getByRole('button', { name: 'Delete', exact: true }).hover() },
act: async page => {
await confirmPanel(page, 'Delete note?').getByRole('button', { name: 'Delete', exact: true }).click()
await expect(noteCard(page, MANAGE_NOTE.title)).toHaveCount(0, { timeout: 15_000 })
await settle(page)
},
},
],
// The last step already deleted it; this is for a run that stopped short.
cleanup: page => removeNotes(page.request, MANAGE_NOTE.title),
},
'note-categories': {
guide: guide('note-categories'),
start,
steps: [
{
target: page => notesPanel(page).getByRole('button', { name: 'Manage Categories', exact: true }),
act: async page => {
await notesPanel(page).getByRole('button', { name: 'Manage Categories', exact: true }).click()
await expect(categoryDialog(page)).toBeVisible()
await settle(page)
},
},
{
prepare: page => typeInto(page, categoryDialog(page).getByPlaceholder('New category...'), NEW_CATEGORY),
// The box and the plus beside it.
target: page => categoryDialog(page).getByPlaceholder('New category...').locator('xpath=..'),
act: async page => {
await categoryDialog(page).getByRole('button', { name: 'New category...', exact: true }).click()
await expect(categoryRow(page, NEW_CATEGORY)).toBeVisible()
await settle(page)
},
},
{
// The name turns into a box on a click; the picture shows the new name typed into it.
prepare: async page => {
await categoryDialog(page).getByRole('button', { name: RENAME.from, exact: true }).click()
await expect(renameBox(page)).toBeFocused()
await typeInto(page, renameBox(page), RENAME.to)
await beat(page, 300)
},
target: page => renameBox(page).locator('xpath=..'),
// Hovering the row would be harmless, but a click elsewhere would blur the box and save early.
hover: async page => { await renameBox(page).hover() },
act: async page => {
await renameBox(page).press('Enter')
await expect(categoryRow(page, RENAME.to)).toBeVisible()
await settle(page)
},
},
{
// Another category than the renamed one: a rename and a new colour on the
// same category in one Save keep only the name (see the bug list).
prepare: async page => {
await categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true }).click()
await expect(categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true })).toHaveAttribute('aria-pressed', 'true')
await beat(page, 300)
},
target: page => categoryRow(page, RECOLOUR.category),
hover: async page => { await categoryRow(page, RECOLOUR.category).getByRole('button', { name: RECOLOUR.swatch, exact: true }).hover() },
},
{
target: page => categoryDialog(page).getByRole('button', { name: 'Save', exact: true }),
act: async page => {
await categoryDialog(page).getByRole('button', { name: 'Save', exact: true }).click()
await expect(categoryDialog(page)).toHaveCount(0, { timeout: 15_000 })
await expect(categoryPills(page).getByRole('button', { name: RENAME.to, exact: true })).toBeVisible({ timeout: 15_000 })
await settle(page)
},
},
{
target: categoryPills,
hover: async page => { await categoryPills(page).getByRole('button', { name: FILTER, exact: true }).hover() },
act: async page => {
await categoryPills(page).getByRole('button', { name: FILTER, exact: true }).click()
await expect(categoryPills(page).getByRole('button', { name: FILTER, exact: true })).toHaveAttribute('aria-pressed', 'true')
await expect(notesPanel(page).getByRole('article')).toHaveCount(1)
await settle(page)
},
},
],
cleanup: async page => {
await restoreNoteCategories(page.request)
// The added category has no note, so it was only ever kept in this browser.
await page.evaluate(id => {
try { localStorage.removeItem(`collab-cats-${id}`) } catch { /* nothing was kept */ }
}, seededTrip().tripId)
},
},
'shared-links': {
guide: guide('shared-links'),
start,
+10 -77
View File
@@ -3,7 +3,6 @@ import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type Guide
import { day, long, short } from '../dates'
import { seededTrip, ensureCostsFixtures } from './fixtures'
import { openTrip, modal } from './trip-shared'
import { RAMEN, RECEIPT_FILE, receiptFixture, removeReceiptFiles } from './fixtures-trip-costs'
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
import type { HelpGuide } from '../../src/help/types'
@@ -84,14 +83,15 @@ const categoryList = (page: Page) => page.getByRole('group', { name: 'Category',
/** 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' })
/** Every Settle button of the card; each is named "Settle: <from> → <to>, <amount>". */
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: / })
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
/**
* The Settle button of jonas's transfer. 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.
* 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 settleJonas = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: jonas → / })
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…')
@@ -342,7 +342,7 @@ const SCRIPTS: Record<string, GuideScript> = {
.locator('xpath=ancestor::div[2]')
.getByRole('checkbox', { name: /jonas/ })
.click()
await expect(amounts(p).first()).toHaveValue(/^2100(\.00)?$/)
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]'),
@@ -422,72 +422,6 @@ const SCRIPTS: Record<string, GuideScript> = {
cleanup: p => deleteExpenses(p, UPGRADE),
},
'expense-receipts': {
guide: guide('expense-receipts'),
start: async p => {
await createExpense(p, RAMEN)
await openCosts(p)
},
steps: [
{
target: p => rowActions(p, RAMEN.name).getByRole('button', { name: 'Edit' }),
act: async p => {
await rowActions(p, RAMEN.name).getByRole('button', { name: 'Edit' }).click()
await expect(block(p, 'Receipts & Invoices')).toBeVisible()
await settle(p)
},
},
{
// The picker is the system's own window, which no screenshot shows: the
// file goes straight into the hidden input behind Attach, and the
// picture is of the section with the file listed under it.
prepare: async p => {
const section = block(p, 'Receipts & Invoices')
await section.locator('input[type="file"]').setInputFiles(await receiptFixture())
await expect(section.getByText(RECEIPT_FILE)).toBeVisible()
await settle(p)
},
target: p => block(p, 'Receipts & Invoices'),
},
{
target: p => modal(p).getByRole('button', { name: 'Save', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
await dropFocus(p)
await expect(expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' }),
act: async p => {
await expRow(p, RAMEN.name).getByRole('button', { name: 'View receipt' }).click()
const img = p.getByRole('img', { name: RECEIPT_FILE })
await expect(img).toBeVisible({ timeout: 20_000 })
await expect.poll(() => img.evaluate(el => (el as HTMLImageElement).naturalWidth), { timeout: 20_000 }).toBeGreaterThan(0)
await settle(p)
},
},
{
// The preview's white panel: the file's name, Open in new tab and
// Download in its head, the receipt under them.
target: p => p.getByRole('button', { name: 'Open in new tab' }).locator('xpath=ancestor::div[3]'),
act: async p => {
await p.keyboard.press('Escape')
await expect(p.getByRole('img', { name: RECEIPT_FILE })).toHaveCount(0)
await dropFocus(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await deleteExpenses(p, RAMEN.name)
await removeReceiptFiles(p.request)
},
},
'filter-costs': {
guide: guide('filter-costs'),
start: openCosts,
@@ -568,9 +502,9 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => card(p, 'Settle up')),
{
target: settleJonas,
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
act: async p => {
await settleJonas(p).click()
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
await settle(p)
@@ -590,7 +524,6 @@ const SCRIPTS: Record<string, GuideScript> = {
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 typeInto(p, modal(p).getByPlaceholder('Paid in cash, by bank transfer…'), 'Cash, at the ryokan')
await settle(p)
},
target: p => p.getByRole('dialog', { name: 'Add payment' }),
+5 -64
View File
@@ -22,10 +22,7 @@ const guide = (id: string): HelpGuide => {
/** The name the rename guide gives day 5, and takes away again. */
const DAY_NAME = 'Arrival in Kyoto'
/**
* The panel itself. PageSidebar's drawer is `fixed z-50` too, but it is
* `lg:hidden` and never on screen at the pictures' width.
*/
/** 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} `) })
@@ -61,13 +58,6 @@ const bookings = (page: Page) => block(page, 'Reservations')
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 })
/**
* The stay's Reservation box: a button named by the booking's title and its
* status (the code in it is aria-hidden). The pencil and the X beside the
* name are named Edit accommodation and Remove, so the title picks the box.
*/
const stayBooking = (page: Page, name: string) =>
stayCard(page, name).getByRole('button', { name: new RegExp(`^${name}`) })
/**
* Select a day by its title rather than by the middle of its header: a day with
@@ -126,17 +116,6 @@ async function restoreTokyoStay(page: Page): Promise<void> {
})
}
/** The Hotel booking a stay made for itself; the accommodation route created it confirmed. */
async function setStayBookingStatus(page: Page, name: string, status: 'pending' | 'confirmed'): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: { id: number; type: string; title: string }[] }
const booking = (body.reservations ?? []).find(r => r.type === 'hotel' && r.title === name)
if (!booking) throw new Error(`the Hotel booking of "${name}" is missing`)
const put = await page.request.put(`/api/trips/${tripId}/reservations/${booking.id}`, { data: { status } })
if (!put.ok()) throw new Error(`could not set "${name}" to ${status}: ${put.status()} ${await put.text()}`)
}
/**
* 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.
@@ -302,10 +281,10 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
only(p => stayCard(p, TOKYO_HOTEL)),
{
// The pencil and the X are RoundActions named by their tooltips.
target: p => stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Edit accommodation', exact: true }),
// 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).getByRole('button', { name: 'Edit accommodation', exact: true }).click()
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
await settle(p)
},
@@ -332,49 +311,11 @@ const SCRIPTS: Record<string, GuideScript> = {
// 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).getByRole('button', { name: 'Remove', exact: true }),
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'),
},
],
cleanup: restoreTokyoStay,
},
'confirm-stay-booking': {
guide: guide('confirm-stay-booking'),
start: async p => {
// The night was booked confirmed; the guide starts from a pending one.
await setStayBookingStatus(p, TOKYO_HOTEL, 'pending')
await openDay(p, 1)
await expect(stayBooking(p, TOKYO_HOTEL)).toContainText('Pending', { timeout: 15_000 })
},
steps: [
only(p => stayBooking(p, TOKYO_HOTEL)),
{
prepare: async p => {
await stayBooking(p, TOKYO_HOTEL).click()
await expect(bookingDetail(p, TOKYO_HOTEL)).toBeVisible()
await settle(p)
},
target: p => bookingDetail(p, TOKYO_HOTEL),
},
{
target: p => bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Confirmed', exact: true }),
act: async p => {
await bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Confirmed', exact: true }).click()
await expect(bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Set to Pending', exact: true })).toBeVisible()
await settle(p)
},
},
{
target: p => bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Close', exact: true }),
act: async p => {
await bookingDetail(p, TOKYO_HOTEL).getByRole('button', { name: 'Close', exact: true }).click()
await expect(bookingDetail(p, TOKYO_HOTEL)).toHaveCount(0)
await expect(stayBooking(p, TOKYO_HOTEL)).toContainText('Confirmed')
await settle(p)
},
},
],
cleanup: p => setStayBookingStatus(p, TOKYO_HOTEL, 'confirmed'),
},
'day-bookings': {
guide: guide('day-bookings'),
start: p => openDay(p, 5),
+100 -270
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, VIEWPORT, type GuideScript } from './guide'
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'
@@ -21,9 +21,8 @@ const guide = (id: string): HelpGuide => {
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.' }
/** The day-fixtures train of day 5, the booking the move guide drags to day 4. */
const TRAIN = 'Nozomi 21 Tokyo → Kyoto'
/** 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
@@ -34,10 +33,7 @@ const TRAIN = 'Nozomi 21 Tokyo → Kyoto'
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 })
/** A day card's head band (`.dp-day-header`, a div with the button role). */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The whole card of a day: the head band and the rows under it. */
const dayCard = (page: Page, n: number) => dayHeader(page, n).locator('xpath=..')
/**
* 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
@@ -48,16 +44,13 @@ const stop = (page: Page, name: string) =>
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first()
/** A booking row of a day: its title, its time and its phase (Departure / Arrival) make its name. */
/** 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 })
/** A transport row by its title, anywhere in the plan or inside one card. */
const transportRow = (scope: Page | Locator, title: string) =>
scope.locator('[data-dp="transport-row"]').filter({ hasText: title }).first()
/** 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 round + at the end of a places row (named "+ Day"): it puts the place on the open day. */
/** 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' })
@@ -67,8 +60,14 @@ const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button
* 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 modes, Optimize and the two hand-offs. */
const routeBar = (page: Page) => page.locator('[data-dp="route-tools"]').first()
/** 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
@@ -82,31 +81,6 @@ const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBo
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' })
/** 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()
/** The menu a right-click on a day's head opens. */
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
// ── Seed helpers ──────────────────────────────────────────────────────────────
type Assignment = { id: number; place_id: number; route_excluded?: boolean }
async function assignmentsOf(page: Page, dayIndex: number): Promise<Assignment[]> {
const { tripId, dayIds } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
const body = (await res.json()) as { assignments?: Assignment[] }
return body.assignments ?? []
}
async function placeIdOf(page: Page, name: string): Promise<number> {
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 ?? [])
const place = places.find(x => x.name === name)
if (!place) throw new Error(`the seeded place "${name}" is missing`)
return place.id
}
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
@@ -124,58 +98,10 @@ async function deleteByName(page: Page, ...names: string[]): Promise<void> {
}
}
/** teamLab Planets' slot on day 1 as the fixtures give it. */
const TEAMLAB_SLOT = { place_time: '13:00', end_time: '15:00' }
/** Give teamLab Planets on day 1 a time, or none. */
async function setTeamLabTime(page: Page, slot: { place_time: string; end_time: string } | null): Promise<void> {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(page, 'teamLab Planets')
const stop = (await assignmentsOf(page, 0)).find(a => a.place_id === teamLab)
if (!stop) return
const res = await page.request.put(`/api/trips/${tripId}/assignments/${stop.id}/time`, {
data: slot ?? { place_time: null, end_time: null },
})
if (!res.ok()) throw new Error(`could not set the time of teamLab Planets: ${res.status()} ${await res.text()}`)
}
/** Day 1's stops as the seed orders them, read before the reorder guide moves them. */
let day1Order: number[] = []
/** Put teamLab Planets back on day 1 and day 1 back in its seeded order. */
async function restoreDay1(page: Page): Promise<void> {
const { tripId, dayIds } = seededTrip()
const teamLab = await placeIdOf(page, 'teamLab Planets')
if (!(await assignmentsOf(page, 0)).some(a => a.place_id === teamLab)) {
for (let i = 1; i < dayIds.length; i++) {
const moved = (await assignmentsOf(page, i)).find(a => a.place_id === teamLab)
if (!moved) continue
await page.request.put(`/api/trips/${tripId}/assignments/${moved.id}/move`, { data: { new_day_id: dayIds[0], order_index: 0 } })
break
}
}
const day1 = await assignmentsOf(page, 0)
const rank = (a: Assignment) => { const i = day1Order.indexOf(a.place_id); return i === -1 ? day1Order.length : i }
const orderedIds = [...day1].sort((a, b) => rank(a) - rank(b)).map(a => a.id)
await page.request.put(`/api/trips/${tripId}/days/${dayIds[0]}/assignments/reorder`, { data: { orderedIds } })
}
/** The train's days as the fixtures left them, read before the move guide drags it. */
let trainDays: { id: number; day_id: number | null; end_day_id: number | null } | null = null
async function readTrain(page: Page): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: { id: number; title: string; day_id: number | null; end_day_id: number | null }[] }
const train = (body.reservations ?? []).find(r => r.title === TRAIN)
if (!train) throw new Error(`the day fixtures' train "${TRAIN}" is missing`)
trainDays = { id: train.id, day_id: train.day_id, end_day_id: train.end_day_id }
}
/**
* Open the trip on the routed day 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.
* 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)
@@ -191,36 +117,52 @@ const closeModal = async (page: Page): Promise<void> => {
}
}
/** Right-click a stop and wait for its menu to offer `entry`. */
async function stopMenu(page: Page, name: string, entry: string): Promise<void> {
await stop(page, name).click({ button: 'right' })
await expect(menu(page).getByRole('button', { name: entry })).toBeVisible()
await beat(page, 300)
}
/** Whether stop `a` sits above stop `b` on screen. */
async function above(page: Page, a: string, b: string): Promise<boolean> {
const boxA = await stop(page, a).boundingBox()
const boxB = await stop(page, b).boundingBox()
return !!boxA && !!boxB && boxA.y < boxB.y
}
/** Hold the loop's last frame on the settled result once the drop has landed. */
async function holdResult(page: Page, frame: () => Promise<void>): Promise<void> {
await settle(page)
await frame()
await frame()
}
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)),
// The hover the capture does brings the row's grip, arrows, ticket and dots out.
only(p => stop(p, 'Arashiyama Bamboo Grove')),
only(p => stop(p, 'Nishiki Market')),
only(connector),
@@ -231,23 +173,23 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('place-onto-day'),
start: async p => {
await createSpare(p)
// Day 1 stays open: the round + of a place row and To day exist only while
// 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),
clip: async (p, frame) => {
await dragLoop(p, placeRow(p, SPARE.name), dayHeader(p, 2), frame)
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 holdResult(p, frame)
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.
// 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),
},
@@ -258,47 +200,26 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'reorder-stops': {
guide: guide('reorder-stops'),
start: async p => {
day1Order = (await assignmentsOf(p, 0)).map(a => a.place_id)
// A timed stop may not go above an earlier timed one without the Remove time?
// question, so teamLab Planets runs untimed here and gets its slot back after.
await setTeamLabTime(p, null)
await openTrip(p)
},
start: p => openTrip(p),
steps: [
{
// teamLab Planets is untimed, so it may go above the timed Senso-ji Temple
// without the Remove time? question; the loop shows it landing there.
target: p => stop(p, 'teamLab Planets').locator('.dp-grip'),
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
dropTo: p => stop(p, 'Senso-ji Temple'),
clip: async (p, frame) => {
await dragLoop(p, stop(p, 'teamLab Planets').locator('.dp-grip'), stop(p, 'Senso-ji Temple'), frame)
await expect.poll(() => above(p, 'teamLab Planets', 'Senso-ji Temple'), { timeout: 15_000 }).toBe(true)
await holdResult(p, frame)
},
},
{
// The pair of arrows, not one of them: the stop is first now, so Move up is
// greyed out, and the text names both.
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
target: p => stop(p, 'teamLab Planets').locator('.reorder-buttons'),
hover: 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),
clip: async (p, frame) => {
await dragLoop(p, stop(p, 'teamLab Planets'), dayHeader(p, 2), frame)
await expect(dayCard(p, 2).getByText('teamLab Planets').first()).toBeVisible({ timeout: 15_000 })
await holdResult(p, frame)
},
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, /^LH716 FRA → HND .*departure/i)
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 } })
@@ -313,17 +234,17 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: async p => {
await restoreDay1(p)
await setTeamLabTime(p, TEAMLAB_SLOT)
},
},
'set-stop-times': {
guide: guide('set-stop-times'),
start: p => openTrip(p),
steps: [
{
prepare: p => stopMenu(p, 'teamLab Planets', 'Edit'),
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()
@@ -354,10 +275,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(p, 'teamLab Planets')
for (const a of await assignmentsOf(p, 0)) {
if (a.place_id === teamLab) {
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 } })
}
}
@@ -367,7 +289,7 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('remove-from-day'),
start: async p => {
await createSpare(p)
// The + puts the place on the OPEN day, so day 1 stays selected.
// 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()
@@ -376,7 +298,11 @@ const SCRIPTS: Record<string, GuideScript> = {
},
steps: [
{
prepare: p => stopMenu(p, SPARE.name, 'Remove from day'),
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()
@@ -388,41 +314,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, SPARE.name),
},
'leave-stop-off-route': {
guide: guide('leave-stop-off-route'),
start: p => openTrip(p),
steps: [
{
prepare: p => stopMenu(p, 'teamLab Planets', 'Leave out of route'),
target: p => menu(p).getByRole('button', { name: 'Leave out of route' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Leave out of route' }).click()
await expect(stop(p, 'teamLab Planets').getByText('Off route', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
// Hovered by the capture, so its tooltip (the route skips it) stands beside it.
only(p => stop(p, 'teamLab Planets').getByText('Off route', { exact: true })),
{
prepare: p => stopMenu(p, 'teamLab Planets', 'Add back to route'),
target: p => menu(p).getByRole('button', { name: 'Add back to route' }),
act: async p => {
await menu(p).getByRole('button', { name: 'Add back to route' }).click()
await expect(stop(p, 'teamLab Planets').getByText('Off route', { exact: true })).toHaveCount(0, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
const teamLab = await placeIdOf(p, 'teamLab Planets')
for (const a of await assignmentsOf(p, 0)) {
if (a.place_id === teamLab && a.route_excluded) {
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/route`, { data: { excluded: false } })
}
}
},
},
'lock-stop': {
guide: guide('lock-stop'),
start: p => openTrip(p),
@@ -449,7 +340,7 @@ const SCRIPTS: Record<string, GuideScript> = {
start: p => openTrip(p),
steps: [
{
// The + of the day's head band opens the menu; Add Note is its last entry.
// 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()
@@ -481,11 +372,17 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
// Hovered by the capture: the grip, the arrows and the three dots come out.
only(noteRow),
{
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 => {
await closeMenu(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 }[]
@@ -507,7 +404,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
// Tooltip clones its child, so the Driving button's parent is the segmented control.
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
{
prepare: async p => {
@@ -518,10 +414,10 @@ const SCRIPTS: Record<string, GuideScript> = {
target: menu,
act: closeMenu,
},
only(p => routeBar(p).getByRole('button', { name: 'Optimize' })),
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 => routeBar(p).getByRole('button', { name: 'Open in Google Maps' })),
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
],
},
'manage-days': {
@@ -532,11 +428,11 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: async p => {
await p.getByRole('button', { name: 'Reorder days' }).click()
await expect(reorderPopup(p)).toBeVisible()
await expect(p.getByText('Reorder days').first()).toBeVisible()
await settle(p)
},
// The whole popup: the rows with their grips, arrows and bins, and the
// two ways of adding a day in its footer.
// The whole popup: the rows with their arrows and bins, and the two ways
// of adding a day in its footer.
target: reorderPopup,
},
{
@@ -575,7 +471,7 @@ const SCRIPTS: Record<string, GuideScript> = {
hover: p => renameDay(p).hover(),
act: closeDayDetails,
},
only(p => p.getByRole('button', { name: /^(Expand all days|Collapse all days)$/ })),
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
],
},
'bookings-in-plan': {
@@ -585,9 +481,8 @@ const SCRIPTS: Record<string, GuideScript> = {
},
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. The
// time pill sits between the title and the phase.
only(p => bookingRow(p, /^LH716 FRA → HND .*departure/i)),
// 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'),
@@ -601,45 +496,13 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
act: closeDayDetails,
},
only(p => p.getByRole('button', { name: /^(Show|Hide) all booking routes$/ })),
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' }),
},
],
},
'move-booking-day': {
guide: guide('move-booking-day'),
start: async p => {
await readTrain(p)
await openTrip(p)
},
steps: [
{
// The grip shows on hover only; the capture hovers the row through it.
prepare: async p => { await transportRow(p, TRAIN).hover() },
target: p => transportRow(p, TRAIN).locator('.dp-grip'),
hover: async p => { await transportRow(p, TRAIN).hover() },
},
{
target: p => transportRow(p, TRAIN),
dropTo: p => dayHeader(p, 4),
clip: async (p, frame) => {
await dragLoop(p, transportRow(p, TRAIN), dayHeader(p, 4), frame)
await expect(transportRow(dayCard(p, 4), TRAIN)).toBeVisible({ timeout: 15_000 })
await holdResult(p, frame)
},
},
only(p => transportRow(dayCard(p, 4), TRAIN)),
],
cleanup: async p => {
if (!trainDays) return
const { tripId } = seededTrip()
await p.request.put(`/api/trips/${tripId}/reservations/${trainDays.id}`, {
data: { day_id: trainDays.day_id, end_day_id: trainDays.end_day_id },
})
},
},
'export-plan': {
guide: guide('export-plan'),
start: p => openTrip(p),
@@ -658,39 +521,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: closeModal,
},
'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)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
+6 -81
View File
@@ -3,7 +3,6 @@ import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type 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 { ensureNextcloudBinding } from './fixtures-trip-files'
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
import type { HelpGuide } from '../../src/help/types'
@@ -119,17 +118,6 @@ const bindingCard = (page: Page) => dialog(page).locator('article').first()
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' })
/** One lane of the flow bar, a toggle named by its caption. */
const lane = (page: Page, name: string) => bindingCard(page).getByRole('button', { name, exact: true })
/** The fold under the flow bar; only the owner or an administrator gets it. */
const settingsFold = (page: Page) => bindingCard(page).getByRole('button', { name: 'Settings', exact: true })
/** What the fold opens: the rows of settings on their tinted panel (`space-y-3`, DocSyncBinding.tsx). */
const settingsPanel = (page: Page) => bindingCard(page).locator('div.space-y-3').first()
/** One settings row, the label with its hint on the left and its control on the right. */
const settingsRow = (page: Page, label: string) =>
settingsPanel(page).locator('div.flex.items-center.justify-between').filter({ hasText: label }).first()
/** A CustomSelect's menu is a portal `group`, named after the option it shows. */
const selectMenu = (page: Page, current: string) => page.getByRole('group', { name: current })
interface ApiFile {
id: number
@@ -186,15 +174,6 @@ async function untrashFile(page: Page, name: string): Promise<void> {
const only = (target: (p: Page) => Locator) => ({ target })
/** Unbind the trip, take the pulled documents out and put the folder back to its two. */
async function resetStore(page: Page): Promise<void> {
const { tripId } = seededTrip()
await resetDocSync(page.request, tripId)
await removeFiles(page, NEXTCLOUD_DOCUMENTS)
// The run pushed the trip's own documents into the folder; back to its two.
await ensureNextcloudFolder(drawPdf)
}
const SCRIPTS: Record<string, GuideScript> = {
'files-upload': {
guide: guide('files-upload'),
@@ -591,67 +570,13 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: resetStore,
},
'files-sync-settings': {
guide: guide('files-sync-settings'),
// Bound through the API with the dialog's own defaults, so the guide opens
// on the card; how the binding is made is files-sync's story.
start: async p => {
cleanup: async p => {
const { tripId } = seededTrip()
await ensureNextcloudBinding(p.request, tripId)
await openFiles(p)
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
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)
},
steps: [
{
target: syncButton,
act: async p => {
await syncButton(p).click()
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
// The only binding is selected by itself.
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
await expect(lane(p, 'Out to the store')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
{
target: p => lane(p, 'Out to the store'),
act: async p => {
await lane(p, 'Out to the store').click()
await expect(lane(p, 'Out to the store')).toHaveAttribute('aria-pressed', 'false', { timeout: 20_000 })
await expect(bindingCard(p).getByText(/^Only coming in\./)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: settingsFold,
act: async p => {
await settingsFold(p).click()
await expect(settingsFold(p)).toHaveAttribute('aria-expanded', 'true')
await expect(settingsRow(p, 'When a document is deleted')).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
// The menu open in the picture, so both choices the text names show.
prepare: async p => {
await settingsRow(p, 'When a document is deleted').getByRole('button').first().click()
await expect(selectMenu(p, 'Keep both copies').getByRole('button', { name: 'Move to recycle bin' })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
target: p => settingsRow(p, 'When a document is deleted'),
act: async p => {
await selectMenu(p, 'Keep both copies').getByRole('button', { name: 'Move to recycle bin' }).click()
await expect(settingsRow(p, 'When a document is deleted')).toContainText('Move to recycle bin', { timeout: 20_000 })
await dropFocus(p)
await settle(p)
},
},
// The two rows under it; the step only reads them, nothing is changed.
only(settingsPanel),
],
cleanup: resetStore,
},
}
@@ -663,7 +588,7 @@ 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 every guide show the same bar.
// the whole file, so the hero and all seven guides show the same bar.
await toggleAddon(request, 'nextcloud', true)
})
+88 -227
View File
@@ -1,24 +1,18 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, dragLoop, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
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 { restorePacking, snapshotPacking, untickPacking, type PackingSnapshotRow } from './fixtures-trip-lists'
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 extra tasks in place, so Apply
* template exists and the to-do sidebar has more than one filled filter.
*
* The seed's packing list is long and mostly ticked, so the packing guides do
* not undo their changes one by one: the list is snapshotted once before the
* first guide (`fixtures-trip-lists.ts`) and put back from that snapshot when
* each packing guide starts and again when it ends. What lives outside the
* items (a template, a bag, a list's assignees, a task) is removed by the
* guide that made it.
* 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 => {
@@ -27,10 +21,10 @@ const guide = (id: string): HelpGuide => {
return g
}
/** What the guides make and unmake. None of these names is in the seed. */
const NEW_LIST = 'Snacks'
const FIRST_ITEM = 'Rice crackers'
const RENAMED_ITEM = 'Rice crackers for the train'
/** 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'
@@ -40,60 +34,36 @@ 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'
/** An open, unassigned task of the seed; the edit guide puts a name on it. */
const EDITED_TASK = 'Send postcards home'
/** The only open task with a priority (the fixtures' P3), so Priority puts it on top. */
const PRIORITY_TASK = 'Confirm ryokan check-in time'
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 list it brings with it. */
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit' }
/** Names the seed does not have, so the imported rows are the only ones that match. */
const IMPORT_LINES = 'Toiletries, Earplugs\nHealth, Motion sickness tablets, 20\nDay bag, Folding fan, 40'
const IMPORTED_LAST = 'Folding fan'
/** Still open in the seed, so a click ticks it rather than unticking it. */
const OPEN_ITEM = 'Slip-on shoes for temples'
/** A list with one open item, so Check All visibly finishes it. */
const CHECK_ALL_LIST = 'Electronics'
const CHECK_ALL_OPEN = 'eSIM activation code'
/** Fully packed in the seed, so the Open filter hides its list. */
const PACKED_ITEM = 'Passport'
/** What the ordering guide moves: the third list onto the first, then the last item of that first list to its top. */
const MOVED_LIST = 'Electronics'
const FIRST_LIST = 'Documents'
const MOVED_ITEM = 'Credit card and some yen'
const TOP_ITEM = 'Passport'
/** Packed in the seed; the counting guide unticks it and counts five pairs in. */
const PIECES_ITEM = 'Merino socks'
const PIECES = 5
/** 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 head band. */
/** 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 head band of a card. The ⋯ has to be taken from here: every item row
* 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-ellipsis, svg.lucide-more-horizontal)')
/** The grip in a list's head band, before its colour dot. */
const catGrip = (page: Page, name: string): Locator =>
catHeader(page, name).getByRole('button', { name: 'Drag to reorder the category' })
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()
/** A row's own grip is a bare draggable div, the first thing in the row. */
const rowGrip = (page: Page, name: string): Locator => item(page, name).locator('div[draggable="true"]')
/** The two-column grid of list cards; its children are the cards, in order. */
const listGrid = (page: Page): Locator =>
page.locator('div.grid').filter({ has: page.locator('.packing-item-row') }).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. */
@@ -101,18 +71,6 @@ const rowMenu = (page: Page, name: string): Locator => item(page, name).locator(
/** 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 progress card's count and percentage. The total is a span of its own
* that reads "/28"; the category badges hold "n/m" in one span, so they never match.
*/
const progressCount = (page: Page): Locator =>
page.getByText(/^\/\d+$/).first().locator('xpath=ancestor::div[2]')
/** The quantity badge is the first field among a row's inline actions while it has no counter. */
const quantityField = (page: Page, name: string): Locator =>
item(page, name).locator('.packing-row-inline-actions input').first()
/** The packed counter of a multi-piece item, named "3 of 5 packed". */
const piecesCounter = (page: Page, name: string): Locator =>
item(page, name).getByRole('group', { name: / of \d+ packed$/ })
/** 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. */
@@ -125,7 +83,7 @@ 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, so they are found by their caption rather than inside the row.
* 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()
@@ -135,6 +93,7 @@ const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Da
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')
@@ -148,12 +107,6 @@ 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}`) })
/**
* A CustomSelect's options are portalled to <body> as a group named after what
* the field shows. Scoped to it, an option never collides with the sidebar's
* list row of the same name, which reads exactly the same once its count is zero.
*/
const selectList = (page: Page, showing: string): Locator => page.getByRole('group', { name: showing, exact: true }).last()
const only = (target: (p: Page) => Locator) => ({ target })
@@ -179,25 +132,10 @@ async function openTodos(page: Page): Promise<void> {
// ── Putting the seed back ────────────────────────────────────────────────────
/** The packing list as the seed and the fixtures left it, taken in beforeAll. */
let seedPacking: PackingSnapshotRow[] | null = null
function seed(): PackingSnapshotRow[] {
if (!seedPacking) throw new Error('the packing list was not snapshotted before the guides')
return seedPacking
}
const resetPacking = (page: Page): Promise<void> => restorePacking(page.request, seed())
/** Every packing guide starts from the seed's list, even after one that failed half way. */
async function startLists(page: Page): Promise<void> {
await resetPacking(page)
await openLists(page)
}
interface PackingRow {
id: number
name: string
category: string | null
}
async function packingItems(page: Page): Promise<PackingRow[]> {
@@ -207,6 +145,31 @@ async function packingItems(page: Page): Promise<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 }[] }
@@ -215,16 +178,6 @@ async function deleteTemplate(page: Page, name: string): Promise<void> {
}
}
async function deleteBag(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.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 === name)) {
// bag_id is ON DELETE SET NULL, so the item is freed with it.
await page.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
}
}
async function deleteTodo(page: Page, name: string): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/todo`)
@@ -247,7 +200,7 @@ async function unassignTodo(page: Page, name: string): Promise<void> {
const SCRIPTS: Record<string, GuideScript> = {
'packing-categories': {
guide: guide('packing-categories'),
start: startLists,
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Add list' }),
@@ -324,78 +277,36 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: async p => {
const { tripId } = seededTrip()
await resetPacking(p)
await deleteItems(p, row => row.category === NEW_LIST)
await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } })
},
},
'arrange-packing-list': {
guide: guide('arrange-packing-list'),
start: startLists,
steps: [
{
// The still rings the grip and the card it goes onto; the loop shows the drag.
target: p => catGrip(p, MOVED_LIST),
dropTo: p => catHeader(p, FIRST_LIST),
clip: async (p, frame) => {
await dragLoop(p, catGrip(p, MOVED_LIST), catHeader(p, FIRST_LIST), frame)
await expect(listGrid(p).locator('> div').first()).toContainText(MOVED_LIST, { timeout: 20_000 })
await settle(p)
for (let i = 0; i < 3; i++) await frame()
},
},
{
prepare: async p => { await item(p, MOVED_ITEM).hover() },
target: p => rowGrip(p, MOVED_ITEM),
dropTo: p => item(p, TOP_ITEM),
clip: async (p, frame) => {
await dragLoop(p, rowGrip(p, MOVED_ITEM), item(p, TOP_ITEM), frame)
await expect(cat(p, FIRST_LIST).locator('.packing-item-row').first()).toContainText(MOVED_ITEM, { timeout: 20_000 })
await settle(p)
for (let i = 0; i < 3; i++) await frame()
},
},
{
target: p => p.getByRole('button', { name: 'A to Z' }),
act: async p => {
await p.getByRole('button', { name: 'A to Z' }).click()
await expect(p.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
],
cleanup: async p => {
// A to Z is remembered in this browser; the next guide opens a fresh one,
// but the switch goes back off here all the same.
await p.evaluate(() => { try { localStorage.removeItem('trek:packing-sort') } catch { /* nothing stored */ } })
await resetPacking(p)
},
},
'check-off-packing': {
guide: guide('check-off-packing'),
start: startLists,
start: openLists,
steps: [
{
// The grip is a div, so the tick box is the row's first button.
target: p => item(p, OPEN_ITEM).getByRole('button').first(),
target: p => item(p, 'Passport').getByRole('button').first(),
act: async p => {
await item(p, OPEN_ITEM).getByRole('button').first().click()
await expect(item(p, OPEN_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
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(progressCount),
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
{
prepare: async p => {
await catMenu(p, CHECK_ALL_LIST).click()
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(item(p, CHECK_ALL_OPEN).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
@@ -403,74 +314,28 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByRole('button', { name: 'Open', exact: true }),
act: async p => {
await p.getByRole('button', { name: 'Open', exact: true }).click()
await expect(item(p, PACKED_ITEM)).toHaveCount(0, { timeout: 20_000 })
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 cannot be pictured. The step shows the
// button and answers the browser with Cancel: with the Open filter on,
// the screen looks the same either way, and the seed's ticked items
// (most of the list) stay where they are for the guides after this one.
p.once('dialog', d => { void d.dismiss() })
// 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 })).toBeVisible()
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
await settle(p)
},
},
],
cleanup: resetPacking,
},
'count-packed-pieces': {
guide: guide('count-packed-pieces'),
start: async p => {
await resetPacking(p)
await untickPacking(p.request, PIECES_ITEM)
await openLists(p)
},
steps: [
{
prepare: async p => { await item(p, PIECES_ITEM).hover() },
target: p => quantityField(p, PIECES_ITEM),
act: async p => {
const field = quantityField(p, PIECES_ITEM)
await typeInto(p, field, String(PIECES))
await field.press('Enter')
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`0 of ${PIECES} packed`, { timeout: 20_000 })
await settle(p)
},
},
{
target: p => piecesCounter(p, PIECES_ITEM),
act: async p => {
for (let n = 1; n <= 3; n++) {
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
}
await settle(p)
},
},
{
// Partly packed: the counter in the accent, the tick box outlined in it.
target: p => item(p, PIECES_ITEM),
act: async p => {
for (let n = 4; n <= PIECES; n++) {
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
}
await expect(item(p, PIECES_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
await settle(p)
},
},
only(p => item(p, PIECES_ITEM)),
],
cleanup: resetPacking,
cleanup: restoreDocuments,
},
'apply-packing-template': {
guide: guide('apply-packing-template'),
start: startLists,
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Apply template' }),
@@ -502,13 +367,13 @@ const SCRIPTS: Record<string, GuideScript> = {
},
],
cleanup: async p => {
await resetPacking(p)
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
await deleteTemplate(p, SAVED_TEMPLATE)
},
},
'import-packing-list': {
guide: guide('import-packing-list'),
start: startLists,
start: openLists,
steps: [
{
// An icon at the right end of the bar, named Import by its label and tooltip.
@@ -542,17 +407,16 @@ const SCRIPTS: Record<string, GuideScript> = {
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, IMPORTED_LAST)).toBeVisible({ timeout: 30_000 })
await item(p, IMPORTED_LAST).scrollIntoViewIfNeeded()
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
],
cleanup: resetPacking,
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
},
'export-packing-list': {
guide: guide('export-packing-list'),
start: startLists,
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: 'Export', exact: true }),
@@ -585,7 +449,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'share-packing-item': {
guide: guide('share-packing-item'),
start: startLists,
start: openLists,
steps: [
{
target: p => p.getByRole('button', { name: /^My list/ }),
@@ -654,23 +518,18 @@ const SCRIPTS: Record<string, GuideScript> = {
if (row) {
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
}
await resetPacking(p)
},
},
'packing-bags': {
guide: guide('packing-bags'),
start: async p => {
// A run that died half way leaves its bag behind, and the Bags card with it.
await deleteBag(p, BAG)
await startLists(p)
},
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('—'),
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
act: async p => {
const field = item(p, BAG_ITEM).getByPlaceholder('—')
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
await typeInto(p, field, '620')
await expect(field).toHaveValue('620', { timeout: 20_000 })
await settle(p)
@@ -698,8 +557,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
},
// The jacket is ticked in the seed, so Packed under Total weight already
// carries its 620 g.
only(bagSidebar),
{
target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }),
@@ -724,15 +581,20 @@ const SCRIPTS: Record<string, GuideScript> = {
// 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 })
// A bag with someone on it fills Per person at the foot of the card.
await expect(p.getByText('Per person', { exact: true })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
await deleteBag(p, BAG)
await resetPacking(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': {
@@ -763,7 +625,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => {
await modal(p).getByRole('button', { name: 'No list' }).click()
// The select's list is portalled to <body>, outside the dialog.
await selectList(p, 'No list').getByRole('button', { name: TASK_LIST, exact: true }).click()
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)
},
@@ -795,7 +657,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => {
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
await settle(p)
},
@@ -840,7 +702,7 @@ const SCRIPTS: Record<string, GuideScript> = {
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(PRIORITY_TASK, { timeout: 20_000 })
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 })
await settle(p)
},
},
@@ -855,7 +717,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: async p => {
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
await p.getByRole('button', { name: ASSIGNEE }).click()
await settle(p)
},
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
@@ -877,7 +739,6 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureListsFixtures(request)
seedPacking = await snapshotPacking(request)
})
test('every registered lists guide has a script, and only those', async () => {
@@ -886,7 +747,7 @@ test('every registered lists guide has a script, and only those', async () => {
test('hero: trip-lists', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, tripListsContext.id, startLists)
await captureHero(page, tripListsContext.id, openLists)
})
for (const id of tripListsContext.guides) {
+1 -178
View File
@@ -3,8 +3,6 @@ import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript
import { seededTrip, ensureMapFixtures } from './fixtures'
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
import { ensureDawarichConnection, disconnectDawarich } from './external'
import { ensureToursFixtures } from './tour-fixtures'
import { ensureTourOnDay, removeTourFromDay, tourNames, type TourOnDay } from './fixtures-trip-map'
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
import type { HelpGuide } from '../../src/help/types'
@@ -12,8 +10,7 @@ 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, and with the two Kyoto
* tours of the Tours fixtures, one of them on a day; what a guide changes it
* 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.
*/
@@ -94,65 +91,6 @@ const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
*/
const bookingDetail = (page: Page) => dialog(page)
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
/**
* The part of a tour's line that takes the click: TrackLines lays an invisible
* 14 px line over every track, and nothing else on the map is drawn at that
* width with no opacity, so this is the set of the trip's tours on the map.
*/
const tourLines = (page: Page) => page.locator('#trek-map path[stroke-width="14"][stroke-opacity="0"]')
/**
* The tour's card over the foot of the map. DetailShell carries no test id, so
* it is found by its head band: a `header` holding the Close and a tour's name.
*/
const tourCard = (page: Page) =>
page.locator('header')
.filter({ has: page.getByRole('button', { name: 'Close', exact: true }) })
.filter({ hasText: new RegExp(tourNames().map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')) })
.locator('xpath=..')
/** The right column's switch between the places list and the tours list. */
const toursTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Tours' })
const placesTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Places' })
const tourRow = (page: Page, name: string) =>
page.getByRole('listbox', { name: 'Tours' }).getByRole('option').filter({ hasText: name }).first()
const hideOnMap = (page: Page, name: string) => tourRow(page, name).getByRole('button', { name: 'Hide on the map' })
/**
* A point on a tour's line the pointer can take hold of: in the open part of
* the map, and with the line's own hit path on top there, so a click lands on
* the tour rather than on a pin or a column. Walks each line at twenty points
* and maps them out of the SVG's space into the page's. Zooms out a notch and
* tries again where the frame holds none, at most three times.
*/
async function tourLinePoint(page: Page): Promise<{ index: number; x: number; y: number }> {
for (let attempt = 0; attempt < 4; attempt++) {
const box = await map(page).boundingBox()
if (!box) throw new Error('the map has no box')
const bounds = { left: box.x + 430, right: box.x + box.width - 430, top: box.y + 140, bottom: box.y + box.height - 220 }
const hit = await tourLines(page).evaluateAll((paths, b) => {
for (let i = 0; i < paths.length; i++) {
const path = paths[i] as SVGPathElement
const ctm = path.getScreenCTM()
const length = path.getTotalLength()
if (!ctm || !length) continue
for (let k = 1; k < 20; k++) {
const p = path.getPointAtLength((length * k) / 20)
const x = ctm.a * p.x + ctm.c * p.y + ctm.e
const y = ctm.b * p.x + ctm.d * p.y + ctm.f
if (x < b.left || x > b.right || y < b.top || y > b.bottom) continue
if (document.elementFromPoint(x, y) === path) return { index: i, x, y }
}
}
return null
}, bounds)
if (hit) return hit
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.wheel(0, 100)
await page.waitForTimeout(600)
await settle(page)
}
throw new Error('no tour line in the open part of the map')
}
/** A row of the places column. */
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
/**
@@ -365,10 +303,6 @@ async function deleteByName(page: Page, ...names: string[]): Promise<void> {
let bubble = 0
let pin = 0
let found = ''
/** The tour on a day (from the fixtures), and the one whose line the tour guide clicked. */
let tourOnDay: TourOnDay | null = null
let line = { index: 0, x: 0, y: 0 }
let clickedTour = ''
const NEW_PLACE = 'Lunch stop'
@@ -774,80 +708,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
},
},
'tour-map-lines': {
guide: guide('tour-map-lines'),
// On the day that holds a tour: the map frames the day, its route runs to
// the tour and on from it, and both Kyoto tours lie inside the frame.
start: async p => {
if (!tourOnDay) throw new Error('no tour on a day: ensureTourOnDay did not run')
await openTripOnDay(p, tourOnDay.day)
await foldDayDetails(p)
await expect.poll(() => tourLines(p).count(), { timeout: 30_000 }).toBeGreaterThan(0)
await settle(p)
},
steps: [
{
prepare: async p => {
line = await tourLinePoint(p)
},
target: p => tourLines(p).nth(line.index),
// The line's box is mostly empty map; the pointer goes onto the line itself.
hover: p => p.mouse.move(line.x, line.y),
},
{
// The click is the preparation, the card it opens is the picture.
prepare: async p => {
await p.mouse.click(line.x, line.y)
await expect(tourCard(p)).toBeVisible({ timeout: 15_000 })
const text = await tourCard(p).innerText()
clickedTour = tourNames().find(n => text.includes(n)) ?? tourOnDay?.name ?? ''
await settle(p)
},
target: tourCard,
act: async p => {
await tourCard(p).getByRole('button', { name: 'Close', exact: true }).click()
await expect(tourCard(p)).toHaveCount(0)
await settle(p)
},
},
{
// The whole map: the day's route running to the tour and on from it,
// with the pointer parked on an empty spot so no card is in the way.
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)
},
},
{
prepare: async p => {
await toursTab(p).click()
await expect(tourRow(p, clickedTour)).toBeVisible({ timeout: 20_000 })
// The eye only comes out while the pointer is on the row.
await tourRow(p, clickedTour).hover()
await expect(hideOnMap(p, clickedTour)).toBeVisible()
await settle(p)
},
target: p => hideOnMap(p, clickedTour),
act: async p => {
const before = await tourLines(p).count()
await hideOnMap(p, clickedTour).click()
await expect(tourRow(p, clickedTour).getByRole('button', { name: 'Show on the map' })).toBeVisible()
await expect.poll(() => tourLines(p).count(), { timeout: 10_000 }).toBeLessThan(before)
await settle(p)
},
},
],
// Hidden tours are kept in this browser, per user and trip, under
// `trek:hidden-tours:`; the Tours list is page state and goes with the page.
cleanup: async p => {
await p.evaluate(() => {
for (const key of Object.keys(localStorage)) if (key.startsWith('trek:hidden-tours:')) localStorage.removeItem(key)
})
if (await placesTab(p).isVisible().catch(() => false)) await placesTab(p).click()
},
},
'map-compass': {
guide: guide('map-compass'),
@@ -888,38 +748,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
target: compassPill,
// The loop: the map turning from north to the still's 35°, the arrow
// leaning with it, and a pointer dragging sideways as a right-button
// turn would. The camera is moved by the map's own API, a notch a
// frame, for the reason the still's preparation gives.
clip: async (p, frame) => {
const box = await glCanvas(p).boundingBox()
if (!box) throw new Error('the GL map has no box')
const x = box.x + EMPTY.x
const y = box.y + EMPTY.y
const turnTo = async (bearing: number) => {
await p.evaluate(b => {
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: b, pitch: 0, duration: 0 })
}, bearing)
await p.waitForFunction(() => {
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
return !!m && !m.isMoving()
})
// Two frames, so the canvas has painted the new bearing before the shot.
await p.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))
}
await turnTo(0)
await p.mouse.move(x, y)
for (let i = 0; i < 3; i++) await frame()
for (let i = 1; i <= 14; i++) {
await p.mouse.move(x + (120 * i) / 14, y)
await turnTo((35 * i) / 14)
await frame()
}
for (let i = 0; i < 4; i++) await frame()
},
},
{
target: compassPill,
@@ -947,9 +775,6 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureMapFixtures(request)
// The Kyoto city walk and the Fushimi Inari hike, and one of them on a day.
await ensureToursFixtures(request)
tourOnDay = await ensureTourOnDay(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)
@@ -965,8 +790,6 @@ test.afterAll(async ({ request }) => {
// run after this one would find a GL canvas instead.
await disconnectDawarich(request)
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
// Only what ensureTourOnDay added itself; a tour the Tours fixtures put on a day stays.
await removeTourFromDay(request, tourOnDay)
})
test('every registered map guide has a script, and only those', async () => {
+5 -128
View File
@@ -1,8 +1,7 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
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 { ensurePlacePhoto, removePlacePhoto, PHOTO_FILE, PHOTO_PLACE } from './fixtures-trip-place'
import { long } from '../dates'
import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace'
import type { HelpGuide } from '../../src/help/types'
@@ -36,13 +35,6 @@ 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 place whose hours and contact details the guide types in: the TREK index
* gives it no hours (its OpenStreetMap tag is a shape the parser rejects, see
* OSM_PLACES), so it is the market a traveller corrects by hand.
*/
const HOURS_PLACE = 'Nishiki Market'
const HOURS_CONTACT = { phone: '+81 75 211 3882', email: 'info@kyoto-nishiki.or.jp' }
/** The card itself: the scroll body's parent holds head, body and footer. */
const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..')
@@ -63,7 +55,7 @@ const participants = (page: Page) => section(page, 'Participants')
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?)$/ })
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. */
@@ -85,20 +77,10 @@ const weekday = (offset: number) => long(offset).split(',')[0]
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 round picture in the head; its name says whether a picture of your own is set. */
const avatar = (page: Page) => card(page).getByRole('button', { name: /^(Upload image|Change image)$/ })
/** The popover the picture opens when the place has photos among its files. */
const imageChooser = (page: Page) => page.getByRole('dialog', { name: 'Choose a picture' })
/** The place form's hours editor, found by its remove button (only there while editing). */
const hoursEditor = (page: Page) =>
modal(page).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::section[1]')
/**
* The card's own X, the one in its head; the day panel over the map has another.
* By name: the red X on a custom picture is in the head as well.
*/
/** 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).getByRole('button', { name: 'Close', exact: true }).click()
await head(page).locator('button:has(svg.lucide-x)').click()
await expect(card(page)).toHaveCount(0)
}
@@ -309,7 +291,7 @@ const SCRIPTS: Record<string, GuideScript> = {
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
},
{
target: avatar,
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 })
@@ -321,111 +303,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }),
},
'place-image-from-file': {
guide: guide('place-image-from-file'),
// Attached before the trip opens, so the files are in the store the card reads.
start: async p => {
await ensurePlacePhoto(p.request)
await openTrip(p, { day: null })
},
steps: [
{
prepare: async p => {
await openPlace(p, PHOTO_PLACE)
await expect(filesToggle(p)).toHaveText(/\d+ files?$/)
},
target: p => section(p, 'Files'),
},
{
target: avatar,
act: async p => {
await avatar(p).click()
await expect(imageChooser(p)).toBeVisible()
// The thumbnails load as blobs one request later.
await expect(imageChooser(p).locator('img').first()).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => imageChooser(p).getByRole('button', { name: PHOTO_FILE, exact: true }),
act: async p => {
await imageChooser(p).getByRole('button', { name: PHOTO_FILE, exact: true }).click()
await expect(imageChooser(p)).toHaveCount(0)
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
only(head),
],
cleanup: p => removePlacePhoto(p.request),
},
'place-own-hours': {
guide: guide('place-own-hours'),
start: async p => {
await openTrip(p, { day: null })
await openPlace(p, HOURS_PLACE)
},
steps: [
{
target: p => footer(p).getByRole('button', { name: 'Edit', exact: true }),
act: async p => {
await footer(p).getByRole('button', { name: 'Edit', exact: true }).click()
await expect(modal(p).getByLabel('Phone', { exact: true })).toBeVisible()
await modal(p).getByLabel('Phone', { exact: true }).scrollIntoViewIfNeeded()
await settle(p)
},
},
{
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
act: async p => {
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HOURS_CONTACT.phone)
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HOURS_CONTACT.email)
},
},
{
// The week only exists once Add opening hours is clicked, and the picture
// is of the week, with Monday filled in the way the text says.
prepare: async p => {
// Hours the details column looked up open the week by themselves; only
// without them is there an Add opening hours to click.
const add = modal(p).getByRole('button', { name: 'Add opening hours' })
if (await add.isVisible().catch(() => false)) await add.click()
await expect(hoursEditor(p)).toBeVisible()
await modal(p).getByRole('textbox', { name: 'Monday opens' }).fill('10:00')
await modal(p).getByRole('textbox', { name: 'Monday closes' }).fill('18:00')
await p.keyboard.press('Tab')
await hoursEditor(p).scrollIntoViewIfNeeded()
await settle(p)
},
target: hoursEditor,
},
{
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[contains(@class, "gap-1")][1]'),
act: async p => {
await modal(p).getByRole('button', { name: "Use Monday's hours for every day" }).click()
await expect(modal(p).getByRole('textbox', { name: 'Sunday opens' })).toHaveValue(/10/)
// A market day off, so the week in the result is not seven equal lines.
await modal(p).getByRole('switch', { name: 'Wednesday: Closed' }).click()
await expect(modal(p).getByRole('switch', { name: 'Wednesday: Closed' })).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
{
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(hoursRow(p)).toBeVisible({ timeout: 20_000 })
await expect(card(p).getByRole('link', { name: HOURS_CONTACT.email })).toBeVisible()
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await patchPlace(p, HOURS_PLACE, { phone: '', email: '', opening_hours: '' })
},
},
'place-day-assign': {
guide: guide('place-day-assign'),
start: p => openTrip(p, { day: null }),
+25 -164
View File
@@ -1,20 +1,15 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, gpxFixture } from './fixtures'
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 { disconnectDawarich } from './external'
import { ensureDawarichStays, undoDawarichStays, setTours } from './fixtures-trip-places'
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 the Tours add-on on, as on every install: the column has the Places and
* Tours switch, no Import file and no Tracks filter. The file import is
* pictured with Tours off, and the Dawarich stays with a connection, each for
* its own guide only; what a guide creates or switches it takes back again in
* `cleanup`, so the next one starts the same.
* 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 => {
@@ -43,27 +38,15 @@ const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\
*/
const head = (page: Page) =>
page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]')
/** The list import's entry: List Import, Google List while Google is the only list provider. */
const LIST_IMPORT = /^(Google List|List Import)$/
/** The import button's menu: the list import, and Import file where Tours is off. */
/** 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: LIST_IMPORT }) }).last()
/** The Show dropdown (All, Unplanned, Planned; Tracks only with Tours off) and the list it opens. */
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 icon filters of the row: each a button whose list sits right after it
* (the Tooltip clones the button rather than wrapping it) and opens across the
* whole row.
*/
const listOf = (trigger: Locator) => trigger.locator('xpath=following-sibling::div[1]')
/** 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) => listOf(categoryTrigger(page))
const ratingTrigger = (page: Page) => head(page).getByRole('button', { name: 'Filter by rating', exact: true })
const ratingList = (page: Page) => listOf(ratingTrigger(page))
/** Only there once the trip spans two countries or regions; the seed's Tokyo and Kyoto do. */
const localityTrigger = (page: Page) => head(page).getByRole('button', { name: 'Filter by country or region', exact: true })
const localityList = (page: Page) => listOf(localityTrigger(page))
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=..')
@@ -79,34 +62,10 @@ const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[cont
async function createSpare(page: Page): Promise<void> {
const { tripId } = seededTrip()
// A guide that failed before its cleanup left one behind; two rows of one name would confuse every step.
await deleteByName(page, SPARE.name)
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()}`)
}
/**
* The country and region filter shows once the places lie in two regions or more,
* and the regions come from the Atlas, which looks them up in the background the
* first time its regions are asked for. Ask, then wait until two have come back.
*/
async function resolveRegions(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.request.get('/api/addons/atlas/regions')
await expect
.poll(
async () => {
const body = (await (await page.request.get(`/api/trips/${tripId}/places`)).json()) as
| { places?: { region_name?: string | null }[] }
| { region_name?: string | null }[]
const places = Array.isArray(body) ? body : (body.places ?? [])
return new Set(places.map(x => x.region_name).filter(Boolean)).size
},
{ timeout: 120_000, intervals: [2_000] },
)
.toBeGreaterThan(1)
}
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
const { tripId } = seededTrip()
const res = await page.request.get(`/api/trips/${tripId}/places`)
@@ -127,13 +86,7 @@ const closeModal = async (page: Page): Promise<void> => {
const only = (target: (p: Page) => Locator) => ({ target })
const sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
const sortList = (page: Page) => listOf(sortTrigger(page))
/** The day headers of the plan; their accessible name starts with the number. */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
/** The Dawarich panel on top of the list: its header, and the actions of its rows. */
const dawarichHeader = (page: Page) => page.getByRole('button', { name: /From Dawarich/ })
const stayAction = (page: Page, label: 'Add as a place' | 'Not a place I visited') =>
page.getByRole('button', { name: label, exact: true }).first()
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' }
@@ -281,9 +234,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
steps: [
{
target: p => dayHeader(p, 1),
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
act: async p => {
await dayHeader(p, 1).click()
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
await settle(p)
},
@@ -317,14 +270,11 @@ const SCRIPTS: Record<string, GuideScript> = {
await settle(p)
},
target: p => row(p, SPARE.name),
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
// The gesture is the point of the step, so it also comes as a loop: the
// row leaves the column and lands on the Day 2 card.
clip: async (p, frame) => {
await dragLoop(p, row(p, SPARE.name), dayHeader(p, 2), frame)
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)
for (let i = 0; i < 4; i++) await frame()
},
},
],
@@ -334,7 +284,6 @@ const SCRIPTS: Record<string, GuideScript> = {
guide: guide('filter-places'),
start: async p => {
await createSpare(p)
await resolveRegions(p)
await openTrip(p)
},
steps: [
@@ -376,33 +325,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The list of floors, open: 5+ down to 1+ is what the step reads out.
prepare: async p => {
await ratingTrigger(p).click()
await expect(ratingList(p)).toBeVisible()
await beat(p, 300)
},
target: ratingList,
// The lists close on a second click of their trigger.
act: async p => {
await ratingTrigger(p).click()
await expect(ratingList(p)).toHaveCount(0)
await settle(p)
},
},
{
// Countries with their regions under them, each with its count.
prepare: async p => {
await localityTrigger(p).click()
await expect(localityList(p)).toBeVisible()
await beat(p, 300)
},
target: localityList,
act: async p => {
await localityTrigger(p).click()
await expect(localityList(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),
@@ -496,11 +421,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'import-places-file': {
guide: guide('import-places-file'),
// Import file is only in the menu of an install with the Tours add-on off.
start: async p => {
await setTours(p.request, false)
await openTrip(p)
},
start: p => openTrip(p),
steps: [
{
prepare: async p => {
@@ -534,10 +455,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
cleanup: async p => {
await deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa')
await setTours(p.request, true)
},
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
},
'import-places-list': {
guide: guide('import-places-list'),
@@ -549,10 +467,10 @@ const SCRIPTS: Record<string, GuideScript> = {
await expect(importMenu(p)).toBeVisible()
await beat(p, 300)
},
// With Tours on, the list import is the menu's only entry.
target: p => importMenu(p).getByRole('button', { name: LIST_IMPORT }),
// 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: LIST_IMPORT }).click()
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
await expect(listImportDialog(p)).toBeVisible()
await settle(p)
},
@@ -574,69 +492,14 @@ const SCRIPTS: Record<string, GuideScript> = {
}
},
},
'dawarich-stays': {
guide: guide('dawarich-stays'),
// Connected for this guide only: while it is, the panel heads the column in
// every picture, and the other guides of this screen do not show it.
start: async p => {
await ensureDawarichStays(p.request, seededTrip().tripId)
await openTrip(p)
await expect(dawarichHeader(p)).toBeVisible({ timeout: 30_000 })
},
steps: [
{
target: dawarichHeader,
act: async p => {
await dawarichHeader(p).click()
await expect(stayAction(p, 'Add as a place')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The first stay's button; the frame shows its row and the day above it.
target: p => stayAction(p, 'Add as a place'),
act: async p => {
await stayAction(p, 'Add as a place').click()
await expect(modal(p).locator('#dawarich-accept-name')).toBeVisible()
await settle(p)
},
},
{
target: dialog,
act: async p => {
const name = await modal(p).locator('#dawarich-accept-name').inputValue()
await modal(p).getByRole('button', { name: 'Add place', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
await expect(row(p, name)).toBeVisible({ timeout: 20_000 })
await settle(p)
},
},
{
target: p => stayAction(p, 'Not a place I visited'),
act: async p => {
await stayAction(p, 'Not a place I visited').click()
await expect(p.getByRole('button', { name: /^Show \d+ already dealt with$/ })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
await undoDawarichStays(p.request, seededTrip().tripId)
await disconnectDawarich(p.request)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test.afterAll(async ({ request }) => {
// Instance-wide, and a guide that fails never reaches its cleanup: Tours back
// on, as on every install, and Dawarich off, so its panel on top of the places
// list does not ride into the pictures of the files after this one.
await setTours(request, true)
await disconnectDawarich(request)
test.beforeAll(async ({ request }) => {
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
})
test('every registered places guide has a script, and only those', async () => {
@@ -650,8 +513,6 @@ test('hero: trip-places', async ({ page }) => {
for (const id of tripPlacesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
// Dawarich: a recording to upload and a check of the trip's dates first.
if (id === 'dawarich-stays') test.slow()
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
+15 -470
View File
@@ -1,11 +1,7 @@
import { test, expect, type Locator, type Page } from '@playwright/test'
import { captureGuide, captureHero, settle, dragLoop, nudgeClock, VIEWPORT, type GuideScript } from './guide'
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
import { openTrip, modal, dialog } from './trip-shared'
import {
FERRY, FUEL_STOP, GOOGLE_ROUTE, HAZARD_TITLE, assignmentsOn, ensureFerry, ensureFuelStop, hideHazards, overnightFlight, placeIdOf,
removeFerry, removeFuelStop, removeImportedStops, removeNightAt, restoreFlight, stubGooglePreview, stubHazards,
} from './fixtures-trip-roadtrip'
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
import type { HelpGuide } from '../../src/help/types'
@@ -13,9 +9,7 @@ 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`). A few
* guides bring their own extra (a fuel stop, a ferry, the answers of Google's
* preview and of the warning feeds), from `fixtures-trip-roadtrip.ts`. What a
* 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.
@@ -30,15 +24,11 @@ const guide = (id: string): HelpGuide => {
// ── Where things are ──────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
/**
* A day's card by its head band, which is the fold control and carries the day's
* name as its label ("Day 2"): the rail leaves out days with nothing on them. Not
* by the heading inside it, which sits inside that role=button band.
*/
/** 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.locator(`header[aria-label="Day ${day}"]`) })
.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
@@ -59,27 +49,8 @@ const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^D
/** 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/ })
/** Which of day 4's drive bands is its longest leg; worked out by `aimAtLongLeg`. */
let altLeg = 0
/** 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$/ }).nth(altLeg)
/**
* Pick day 4's longest leg by the distance its band reads. Not simply the first
* band: which leg comes first depends on what else the day holds (a timed stop,
* the hotel the day starts from), and a short hop inside Hakone has one sensible
* road, so the bar would only say that.
*/
async function aimAtLongLeg(page: Page): Promise<void> {
const names = await dayCard(page, 4)
.getByRole('button', { name: /Other ways$/ })
.evaluateAll(els => els.map(el => `${el.getAttribute('aria-label') ?? ''} ${el.textContent ?? ''}`))
const km = names.map(name => {
const m = /([\d.,]+)\s*km/.exec(name)
return m ? Number(m[1].replace(/,/g, '')) : 0
})
altLeg = km.reduce((best, value, i) => (value > km[best] ? i : best), 0)
}
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. */
@@ -87,31 +58,6 @@ const dryBand = (page: Page) => page.getByText('Tank runs out here').first().loc
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()
/** The Search button's card: Looking for, the day, Within, Search and Manual. */
const searchCard = (page: Page) => corridor(page).getByRole('button', { name: 'Search', exact: true }).locator('xpath=../..')
/** A hotel hit: its add button is the bed, Add as an overnight stay. */
const nightButton = (page: Page) =>
corridor(page).getByRole('button', { name: 'Add as an overnight stay', exact: true }).first()
const nightRow = (page: Page) =>
corridor(page)
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'Add as an overnight stay', exact: true }) })
.first()
/**
* The battery badge of a refuelling stop. It has no label of its own: its name
* is its figure, "80 %", or the "+" it shows while nothing says how full.
*/
const fillBadge = (page: Page, name: string) => stopItem(page, name).getByRole('button', { name: /^(\d+ %|\+)$/ })
const fillPopover = (page: Page) => page.getByRole('dialog', { name: 'Fills to' })
/** A booked ride is a card button in the chain, named by its title first. */
const rideCard = (page: Page) => rail(page).getByRole('button', { name: /^Lake Ashi cruise/ }).first()
/** The check-in pill of a ride card: the timetable's chips are pills of the same shape. */
const checkInPill = (page: Page) => rideCard(page).locator('span.rounded-full').filter({ hasText: /Check-in|Boarding/ }).last()
/** CustomSelect drops its options into a fixed portal on the body. */
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
/** A warning area drawn by the hazard layer, in the warning colour. */
const hazardArea = (page: Page) => page.locator('.leaflet-overlay-pane path[stroke="var(--warning)"]').first()
const hazardPopup = (page: Page) => page.locator('.leaflet-popup.trek-hazard-popup')
// ── Getting there ─────────────────────────────────────────────────────────────
@@ -154,14 +100,14 @@ async function frameOnStop(page: Page, name: string): Promise<void> {
/**
* 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 (guide.ts), so
* `+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 nudgeClock(page)
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
await page.waitForTimeout(1200)
await settle(page)
}
@@ -180,13 +126,6 @@ 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.
*
* The drawn casing is not what takes the click: it is non-interactive, and an
* invisible band over the same line (`RouteHitBand`, a transparent stroke with
* the class `cursor-copy`) catches it instead. So the point is read off the
* casing, which is the line the reader sees and the one the ring sits on, and
* the hit test asks for the band of that same leg. Both are drawn from the same
* positions, so their `d` is the same string.
*/
async function routePoints(page: Page): Promise<RoutePoint[]> {
return page.evaluate(() => {
@@ -198,16 +137,13 @@ async function routePoints(page: Page): Promise<RoutePoint[]> {
const length = path.getTotalLength()
const ctm = path.getScreenCTM()
if (!ctm || length < 24) return
const d = path.getAttribute('d')
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
const hit = document.elementFromPoint(x, y)
const ownBand = hit instanceof SVGPathElement && hit.classList.contains('cursor-copy') && hit.getAttribute('d') === d
if (hit === path || ownBand) found.push({ leg, x, y })
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
}
})
return found
@@ -222,16 +158,11 @@ let viaDayId = 0
/** Names of what a guide created, read back by its cleanup. */
let addedStop = ''
let refuelStop = ''
let nightStop = ''
/** 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) {
const legs = await page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').count()
const bands = await page.locator('.leaflet-overlay-pane svg path.cursor-copy').count()
throw new Error(`no drawn leg is reachable on the map (${legs} drawn legs, ${bands} click bands)`)
}
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]
@@ -247,86 +178,6 @@ const hoverAimedLine = async (page: Page): Promise<void> => {
await page.mouse.move(aimedAt.x, aimedAt.y)
}
/** Where on the warning area a click lands on the area itself, clear of the panels and the route. */
let hazardAt = { x: 0, y: 0 }
async function aimAtHazard(page: Page): Promise<void> {
const aim = await page.evaluate(() => {
const paths = document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane path[stroke="var(--warning)"]')
const path = paths[0]
if (!path) return { point: null, why: 'no warning area is drawn' }
// The stub answers with one storm. More than one area means the live feed
// got through, and its first area is anywhere but over the drive.
if (paths.length > 1) return { point: null, why: `${paths.length} warning areas are drawn, so the feed was not stubbed` }
const box = path.getBoundingClientRect()
const found: { x: number; y: number }[] = []
for (let y = box.top + 8; y < box.bottom - 8; y += 12) {
for (let x = box.left + 8; x < box.right - 8; x += 12) {
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
if (document.elementFromPoint(x, y) === path) found.push({ x: Math.round(x), y: Math.round(y) })
}
}
// Said in the error when nothing is found, so the log tells a map framed
// elsewhere from an area something else is lying on.
const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)
const why = `area at ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)}, ` +
`on top at its centre: ${top ? `${top.tagName.toLowerCase()}.${String(top.getAttribute('class') ?? '')}` : 'nothing'}`
return { point: found[Math.floor(found.length / 2)] ?? null, why }
})
if (!aim.point) throw new Error(`no part of the warning area is reachable on the map (${aim.why})`)
hazardAt = aim.point
}
/** Hold the loop's last frame a moment, on the state the gesture left. */
async function holdResult(page: Page, frame: () => Promise<void>): Promise<void> {
await settle(page)
await frame()
await frame()
}
// ── Day 4 as the fixtures left it, for the guide that moves its stops ─────────
let day4Order: number[] = []
async function placeOrder(page: Page, dayIndex: number): Promise<number[]> {
return (await assignmentsOn(page.request, dayIndex)).map(a => a.place_id)
}
/** Whether `first` comes before `second` among the day's stops. */
async function before(page: Page, dayIndex: number, first: string, second: string): Promise<boolean> {
const order = await placeOrder(page, dayIndex)
const a = order.indexOf((await placeIdOf(page.request, first)) ?? -1)
const b = order.indexOf((await placeIdOf(page.request, second)) ?? -1)
return a >= 0 && b >= 0 && a < b
}
/**
* Every stop the guide pushed to another day back on day 4, and day 4 back in
* the order it had. Whichever stop went: the guide moves one, and a run that
* broke halfway must not leave it on the other day for the next guides.
*/
async function restoreDay4(page: Page): Promise<void> {
const { tripId, dayIds } = seededTrip()
const onDay4 = await placeOrder(page, 3)
for (const placeId of day4Order.filter(id => !onDay4.includes(id))) {
for (let i = 0; i < dayIds.length; i++) {
if (i === 3) continue
const moved = (await assignmentsOn(page.request, i)).find(a => a.place_id === placeId)
if (!moved) continue
await page.request.put(`/api/trips/${tripId}/assignments/${moved.id}/move`, {
data: { new_day_id: dayIds[3], order_index: null },
})
break
}
}
const day4 = await assignmentsOn(page.request, 3)
const rank = (placeId: number) => {
const i = day4Order.indexOf(placeId)
return i === -1 ? day4Order.length : i
}
const orderedIds = [...day4].sort((a, b) => rank(a.place_id) - rank(b.place_id)).map(a => a.id)
await page.request.put(`/api/trips/${tripId}/days/${dayIds[3]}/assignments/reorder`, { data: { orderedIds } })
}
// ── What the guides write, and how it is taken back ───────────────────────────
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
@@ -470,13 +321,9 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// Hakone Shrine, not a seeded stop: every seeded visit has an end time
// from the day plan, and a stop with one opens the read-only "until"
// version of the dialog, which has no slider and no Save. The drive's
// own fixture places carry no times, so the badge is the plus of Add a stay.
target: p => stopItem(p, 'Hakone Shrine').getByRole('button', { name: /^(Time at this stop|Add a stay)/ }),
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
act: async p => {
await stopItem(p, 'Hakone Shrine').getByRole('button', { name: /^(Time at this stop|Add a stay)/ }).click()
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)
},
@@ -488,66 +335,16 @@ const SCRIPTS: Record<string, GuideScript> = {
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, 'Hakone Shrine')).toContainText('1 h', { timeout: 20_000 })
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: async p => {
// Zero, not null: the update folds a null into "leave it alone".
await restorePlace(p, 'Hakone Shrine', { duration_minutes: 0 })
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
},
},
'roadtrip-reorder': {
guide: guide('roadtrip-reorder'),
start: async p => {
day4Order = await placeOrder(p, 3)
await openRoadtrip(p)
},
steps: [
{
// Hakone Shrine onto Shinjuku Gyoen: the shrine moves up beside
// Owakudani, and the day stops driving to Tokyo and back.
target: p => stopItem(p, 'Hakone Shrine'),
dropTo: p => stopItem(p, 'Shinjuku Gyoen'),
clip: async (p, frame) => {
await dragLoop(p, stopItem(p, 'Hakone Shrine'), stopItem(p, 'Shinjuku Gyoen'), frame)
await expect.poll(() => before(p, 3, 'Hakone Shrine', 'Shinjuku Gyoen'), { timeout: 20_000 }).toBe(true)
await holdResult(p, frame)
},
},
{
// Onto the Open-Air Museum on day 3, the Hakone day the card just above:
// the shrine on Lake Ashi joins the lake and the museum. A day close by,
// and not Tokyo's day 2: the picture centres the stop it picks up in the
// rail, whatever a prepare scrolled to before, and with day 3 and the
// head of day 4 between them day 2 then lies above the rail's window, so
// the pointer let go off the rail and nothing moved.
target: p => stopItem(p, 'Hakone Shrine'),
dropTo: p => stopItem(p, 'Hakone Open-Air Museum'),
clip: async (p, frame) => {
await dragLoop(p, stopItem(p, 'Hakone Shrine'), stopItem(p, 'Hakone Open-Air Museum'), frame)
const shrine = await placeIdOf(p.request, 'Hakone Shrine')
await expect.poll(async () => (await placeOrder(p, 2)).includes(shrine ?? -1), { timeout: 20_000 }).toBe(true)
await expect(dayCard(p, 3)).toContainText('Hakone Shrine', { timeout: 30_000 })
await holdResult(p, frame)
},
},
{
// Day 4 is Owakudani and Shinjuku Gyoen now; Alt and up puts the garden
// first, and the day drives out of Tokyo to the valley.
target: p => stopRow(p, 'Shinjuku Gyoen'),
act: async p => {
await stopRow(p, 'Shinjuku Gyoen').focus()
await p.keyboard.press('Alt+ArrowUp')
await expect.poll(() => before(p, 3, 'Shinjuku Gyoen', 'Owakudani'), { timeout: 20_000 }).toBe(true)
await settle(p)
},
},
],
cleanup: restoreDay4,
},
'roadtrip-corridor': {
guide: guide('roadtrip-corridor'),
start: async p => {
@@ -622,74 +419,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, addedStop),
},
'roadtrip-overnight': {
guide: guide('roadtrip-overnight'),
start: async p => {
nightStop = ''
await openRoadtrip(p)
},
steps: [
{
target: searchCard,
act: async p => {
// Accommodation on its own: the panel opens on Fuel for a petrol car,
// so Accommodation is ticked first and Fuel taken off after it (the
// trigger is only named "Looking for: ..." while something is ticked).
await kindPicker(p).click()
await expect(openList(p)).toBeVisible()
await openList(p).getByRole('option', { name: 'Accommodation' }).click()
const fuel = openList(p).getByRole('option', { name: 'Fuel' })
if ((await fuel.getAttribute('aria-selected')) === 'true') await fuel.click()
await kindPicker(p).click()
await expect(openList(p)).toHaveCount(0)
await expect(kindPicker(p)).toHaveAccessibleName('Looking for: Accommodation')
// Day 4 ends in Hakone, where the inns are.
await dayPicker(p).click()
await openList(p).getByRole('option', { name: /\bDay 4$/ }).click()
await expect(dayPicker(p)).toContainText('Day 4')
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
await expect(nightButton(p)).toBeVisible({ timeout: 180_000 })
await settle(p)
},
},
{
prepare: async p => {
nightStop = (await nightRow(p).innerText()).split('\n')[0] ?? ''
await nightRow(p).hover()
await settle(p)
},
target: nightButton,
act: async p => {
await nightButton(p).click()
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
{
// Left on Overnight, which is what a hotel opens on.
target: p => modal(p).getByRole('group', { name: 'Kind of stop' }),
act: async p => {
await expect(modal(p).getByRole('button', { name: 'Overnight', exact: true })).toHaveAttribute('aria-pressed', 'true')
},
},
only(p => dialog(p).locator('aside').filter({ hasText: 'Find accommodation' })),
{
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, { timeout: 30_000 })
await expect
.poll(async () => (await listPlaces(p)).some(x => x.name === nightStop), { timeout: 30_000 })
.toBe(true)
await expect(rail(p).getByText(nightStop).first()).toBeVisible({ timeout: 30_000 })
await rail(p).getByText(nightStop).first().scrollIntoViewIfNeeded()
await settle(p)
},
},
],
// The stay goes first, with the booking it brought, then the place.
cleanup: p => removeNightAt(p.request, nightStop),
},
'roadtrip-via': {
guide: guide('roadtrip-via'),
start: openRoadtrip,
@@ -765,23 +494,13 @@ const SCRIPTS: Record<string, GuideScript> = {
start: openRoadtrip,
steps: [
{
// Day 4's long drive between Tokyo and Hakone, not a city leg: for three
// kilometres across Shibuya the router knows one sensible way, and the
// bar says only that.
prepare: aimAtLongLeg,
// 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 })
// Both are answers of the public routers, not of this script; said
// outright, so a run on a day they will not help reads as that.
if (await p.getByText('The router is not answering right now.').isVisible()) {
throw new Error('the alternatives router did not answer (OSRM demo or public Valhalla)')
}
if (await p.getByText('Only one sensible way to drive this one.').isVisible()) {
throw new Error(`the router offered one way only for day 4's leg ${altLeg + 1}`)
}
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
await settle(p)
},
@@ -962,74 +681,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByName(p, refuelStop),
},
'roadtrip-stop-fill': {
guide: guide('roadtrip-stop-fill'),
start: async p => {
await ensureFuelStop(p.request)
await openRoadtrip(p)
},
steps: [
{
// Faint "80 %": the stop follows the trip's Fill up to.
target: p => fillBadge(p, FUEL_STOP.name),
act: async p => {
await fillBadge(p, FUEL_STOP.name).click()
await expect(fillPopover(p)).toBeVisible()
await settle(p)
},
},
{
target: fillPopover,
act: async p => {
await fillPopover(p).getByRole('button', { name: '100', exact: true }).click()
await expect(fillPopover(p)).toHaveCount(0)
await expect(stopItem(p, FUEL_STOP.name)).toContainText('100 %', { timeout: 20_000 })
await settle(p)
},
},
{
prepare: async p => {
await fillBadge(p, FUEL_STOP.name).click()
await expect(fillPopover(p)).toBeVisible()
await settle(p)
},
target: p => fillPopover(p).getByRole('button', { name: /^Use my default/ }),
act: async p => {
await fillPopover(p).getByRole('button', { name: /^Use my default/ }).click()
await expect(fillPopover(p)).toHaveCount(0)
await expect(stopItem(p, FUEL_STOP.name)).toContainText('80 %', { timeout: 20_000 })
await settle(p)
},
},
],
cleanup: p => removeFuelStop(p.request),
},
'roadtrip-bookings': {
guide: guide('roadtrip-bookings'),
start: async p => {
await ensureFerry(p.request)
await openRoadtrip(p)
},
steps: [
only(p => dayCard(p, 3)),
only(rideCard),
only(checkInPill),
{
target: rideCard,
act: async p => {
await rideCard(p).click()
await expect(modal(p)).toContainText(FERRY.title, { timeout: 15_000 })
await settle(p)
await closeModal(p)
await settle(p)
},
},
],
cleanup: async p => {
await closeModal(p)
await removeFerry(p.request)
},
},
'roadtrip-track': {
guide: guide('roadtrip-track'),
start: openRoadtrip,
@@ -1075,110 +726,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await clearVias(p)
},
},
'roadtrip-google-import': {
guide: guide('roadtrip-google-import'),
start: async p => {
await stubGooglePreview(p)
await openRoadtrip(p)
},
steps: [
{
target: p => corridor(p).getByRole('button', { name: 'Import Google Maps route', exact: true }),
act: async p => {
await corridor(p).getByRole('button', { name: 'Import Google Maps route', exact: true }).click()
await p.getByRole('menu').getByRole('button', { name: 'Import Google Maps route' }).click()
await expect(modal(p).getByRole('heading', { name: 'Import Google Maps route' })).toBeVisible()
await settle(p)
},
},
{
target: p => modal(p).getByRole('textbox', { name: 'Google Maps URL' }),
act: async p => {
await modal(p).getByRole('textbox', { name: 'Google Maps URL' }).fill(GOOGLE_ROUTE.url)
await modal(p).getByRole('button', { name: 'Preview', exact: true }).click()
await expect(modal(p).getByRole('list').first()).toContainText('Lake Kawaguchi', { timeout: 30_000 })
await settle(p)
},
},
only(p => modal(p).getByRole('list').first()),
{
target: dialog,
act: async p => {
// Day 3, the Hakone day: the route goes on round Mount Fuji from there.
await modal(p).getByRole('button', { name: /^Day \d+/, expanded: false }).first().click()
await expect(selectMenu(p)).toBeVisible()
await selectMenu(p).getByRole('button', { name: 'Day 3', exact: true }).click()
await expect(modal(p).getByRole('button', { name: /^Day 3/ }).first()).toBeVisible()
await modal(p).getByRole('button', { name: 'Import', exact: true }).click()
await expect(modal(p)).toHaveCount(0, { timeout: 60_000 })
const day3 = async () => {
const ids = await placeOrder(p, 2)
return Promise.all(['Gotemba Premium Outlets', 'Lake Kawaguchi'].map(async n => ids.includes((await placeIdOf(p.request, n)) ?? -1)))
}
await expect.poll(async () => (await day3()).every(Boolean), { timeout: 30_000 }).toBe(true)
await expect(dayCard(p, 3)).toContainText('Lake Kawaguchi', { timeout: 60_000 })
await dayCard(p, 3).scrollIntoViewIfNeeded()
await settle(p)
},
},
],
cleanup: async p => {
await p.unrouteAll({ behavior: 'ignoreErrors' })
await removeImportedStops(p.request)
},
},
'roadtrip-hazards': {
guide: guide('roadtrip-hazards'),
start: async p => {
// The stub sits on the browser context: the service worker fetches the
// feed itself, and a page route would let the live warnings through.
await stubHazards(p)
await openRoadtrip(p)
},
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).getByText('Current warnings', { exact: true }).locator('xpath=ancestor::section[1]'),
act: async p => {
const toggle = modal(p).getByRole('button', { name: 'Show hazard areas', exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
// The footer's Close, not the cross in the head band.
await modal(p).getByRole('button', { name: 'Close', exact: true }).last().click()
await expect(modal(p)).toHaveCount(0)
await expect(hazardArea(p)).toBeAttached({ timeout: 30_000 })
// The map stands fitted to the whole trip, where the storm over Hakone
// is a small patch near the right column, crossed by the drive and its
// pins, and wherever the fit lands decides whether it is reachable at
// all. Hakone Shrine lies inside it, so framing on
// that stop (the way the via guide brings its leg into view) puts the
// area in the middle of the map at the same zoom, outline and all.
await frameOnStop(p, 'Hakone Shrine')
await aimAtHazard(p)
},
},
{
target: hazardArea,
hover: async p => { await p.mouse.move(hazardAt.x, hazardAt.y) },
act: async p => {
await p.mouse.click(hazardAt.x, hazardAt.y)
await expect(hazardPopup(p)).toContainText(HAZARD_TITLE, { timeout: 15_000 })
await settle(p)
},
},
],
cleanup: async p => {
await p.context().unrouteAll({ behavior: 'ignoreErrors' })
await hideHazards(p.request)
},
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
@@ -1187,7 +734,6 @@ test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ request }) => {
await ensureRoadtripFixtures(request)
await overnightFlight(request)
})
test.afterAll(async ({ request }) => {
@@ -1195,7 +741,6 @@ test.afterAll(async ({ request }) => {
// 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 } })
await restoreFlight(request)
})
test('every registered road trip guide has a script, and only those', async () => {
+3 -11
View File
@@ -27,22 +27,14 @@ export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.tre
export const portalDialog = (page: Page, contains: Locator): Locator =>
page.locator('div[role="presentation"]').filter({ has: contains }).last()
/**
* The trip page has loaded: its header carries the Trip settings pill, which
* every tab shows. (Share moved into that pill's dialog in v5, so it is no
* longer a button of its own up there.)
*/
export async function tripReady(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
}
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
const { tripId } = seededTrip()
const { tab, day = 1 } = opts
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
await clearNotices(page)
await dismissReleaseNotice(page)
await tripReady(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) {
@@ -70,7 +62,7 @@ async function untilTranslated(page: Page): Promise<void> {
await page.reload()
await clearNotices(page)
await dismissReleaseNotice(page)
await tripReady(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)
-260
View File
@@ -1,260 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourBikeContext, tripTourBikeGuides } from '../../src/help/contexts/tripTourBike';
import type { HelpGuide } from '../../src/help/types';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
/**
* Actions for bike tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourBike.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`): the road bike loop over the four passes and the
* MTB loop over Monte Pana, both saved tours that are only opened here and
* left as they were. The one guide that saves a tour of its own deletes it
* again in `cleanup`.
*
* Every route in the editor is planned in the browser against the public
* Valhalla, and the Surface and ways card asks the same server how the route
* runs, so these pictures need the network; the MTB trail grades come from
* the server's trail-grade endpoint (TREK Places, else Overpass).
*/
const guide = (id: string): HelpGuide => {
const g = tripTourBikeGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/** The tour the profile guide plans and saves, and deletes again afterwards. */
const NEW_TOUR = 'Selva to Bolzano by bike';
// ── Where things are ──────────────────────────────────────────────────────────
/** The left rail: the neutral head with Plan new tour, or the editor. Both are labelled "Plan a tour". */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
/** The right rail, "Tours of this trip". */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name });
const profileField = (page: Page) => editorRail(page).getByRole('group', { name: 'Profile' });
const waysCard = (page: Page) => page.getByRole('region', { name: 'Surface and ways' });
const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
/** A section of the ways card by its eyebrow ("Surface", "Kind of way", "Trail grade (mtb:scale)"). */
const waysSection = (page: Page, label: string) =>
waysCard(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[1]');
/** The first row whose test id starts with one of the prefixes, in the order given. */
async function firstRow(page: Page, prefixes: string[]): Promise<Locator> {
for (const prefix of prefixes) {
const rows = waysCard(page).locator(`[data-testid^="route-ways-row-${prefix}"]`);
if ((await rows.count()) > 0) return rows.first();
}
throw new Error(`no row in the Surface and ways card for ${prefixes.join(', ')}`);
}
/** An unpaved surface row: gravel or natural ground read best, compacted will do. */
const UNPAVED_ROWS = ['surface-gravel', 'surface-ground', 'surface-compacted', 'surface-rough'];
/** A kind of way that is not the road: the forest track is the one an MTB loop has. */
const WAY_ROWS = ['way-track', 'way-path', 'way-cyclePath', 'way-quiet', 'way-'];
/** A graded trail before an ungraded one. */
const GRADE_ROWS = ['trail-s', 'trail-'];
// ── Getting there ─────────────────────────────────────────────────────────────
async function openToursTab(page: Page): Promise<void> {
await openTourTrip(page, { tab: 'tour-planner' });
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, TOUR_NAMES.roadBike)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* Wait until the route in the editor is planned and its heights are in: the
* status badges beside the rail's title are gone once nothing is happening.
*/
async function waitForRoute(page: Page): Promise<void> {
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
await settle(page);
}
/** The Surface and ways card once it has read the ways (and, on an MTB tour, the grades). */
async function waitForWays(page: Page): Promise<void> {
await expect(page.getByTestId('bike-ways-card')).toBeVisible({ timeout: 120_000 });
await expect(page.getByText('Reading the ways along the route…')).toHaveCount(0, { timeout: 120_000 });
await expect(page.getByText('Reading the trail grades…')).toHaveCount(0, { timeout: 120_000 });
await expect(waysSection(page, 'Surface')).toBeVisible({ timeout: 30_000 });
await settle(page);
}
async function openBikeTour(page: Page, name: string): Promise<void> {
await tourRow(page, name).click();
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour' })).toBeVisible({ timeout: 30_000 });
await waitForRoute(page);
await waitForWays(page);
}
async function openToursTabWith(page: Page, name: string): Promise<void> {
await openToursTab(page);
await openBikeTour(page, name);
}
/** Delete the tour the profile guide saved, wherever the run stopped. */
async function deleteNewTour(page: Page): Promise<void> {
const trips =
((await (await page.request.get('/api/trips')).json()) as { trips?: { id: number; title: string }[] }).trips ?? [];
const trip = trips.find((t) => t.title === TOUR_TRIP);
if (!trip) return;
const { tours = [] } = (await (await page.request.get(`/api/trips/${trip.id}/tours`)).json()) as {
tours?: { place_id: number; name: string }[];
};
for (const tour of tours.filter((t) => t.name === NEW_TOUR)) {
await page.request.delete(`/api/trips/${trip.id}/places/${tour.place_id}`);
}
}
// ── The scripts ───────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-bike-profile': {
guide: guide('tour-bike-profile'),
start: async (p) => {
await deleteNewTour(p);
await openToursTab(p);
},
steps: [
{
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }).click();
await expect(editorRail(p).getByRole('heading', { name: 'New tour' })).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-type-select'),
act: async (p) => {
await p.getByTestId('tour-type-select').click();
await p.getByTestId('tour-type-option-bike').click();
await expect(profileField(p)).toBeVisible();
await settle(p);
},
},
{
target: profileField,
act: async (p) => {
await profileField(p).getByRole('button', { name: 'Touring', exact: true }).click();
await expect(p.getByText('Prefers cycle paths and quiet roads, paved where it can.')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-add-place'),
act: async (p) => {
await p.getByTestId('tour-planner-add-place').click();
for (const name of [TOUR_PLACES.selvaHotel, TOUR_PLACES.bolzanoHotel]) {
await p.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
await settle(p);
}
await p.getByTestId('tour-planner-add-place').click();
await waitForRoute(p);
},
},
{
target: summaryCard,
hover: async (p) => {
await summaryCard(p).getByRole('button', { name: 'How the riding time is worked out' }).hover();
},
},
{
prepare: async (p) => {
await p.locator('#tour-planner-name').fill(NEW_TOUR);
await settle(p);
},
target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
await expect(tourRow(p, NEW_TOUR)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: deleteNewTour,
},
'tour-bike-surfaces': {
guide: guide('tour-bike-surfaces'),
start: openToursTab,
steps: [
{
target: (p) => tourRow(p, TOUR_NAMES.mtb),
act: (p) => openBikeTour(p, TOUR_NAMES.mtb),
},
{
target: (p) =>
waysCard(p)
.getByText(/unpaved$/)
.first(),
},
{
target: (p) => waysSection(p, 'Surface'),
act: async (p) => {
await (await firstRow(p, UNPAVED_ROWS)).click();
await settle(p);
},
},
{
target: (p) => waysSection(p, 'Kind of way'),
act: async (p) => {
await (await firstRow(p, WAY_ROWS)).click();
await settle(p);
},
},
],
},
'tour-bike-mtb-grades': {
guide: guide('tour-bike-mtb-grades'),
start: (p) => openToursTabWith(p, TOUR_NAMES.mtb),
steps: [
{
target: profileField,
hover: async (p) => {
await profileField(p).getByRole('button', { name: 'MTB', exact: true }).hover();
},
},
{
target: (p) => waysSection(p, 'Trail grade (mtb:scale)'),
},
{
target: (p) => waysSection(p, 'Trail grade (mtb:scale)').getByRole('list'),
act: async (p) => {
await (await firstRow(p, GRADE_ROWS)).click();
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
});
test('every registered bike tour guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourBikeContext.guides].sort());
});
test('hero: trip-tour-bike', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourBikeContext.id, (p) => openToursTabWith(p, TOUR_NAMES.roadBike));
});
for (const id of tripTourBikeContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
test.setTimeout(8 * 60_000);
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-467
View File
@@ -1,467 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourDetailContext, tripTourDetailGuides } from '../../src/help/contexts/tripTourDetail';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
deleteTripFilesNamed,
ensureTourDetailFixtures,
FERRATA_PACKING,
HUT_BOOKING_FILE,
hutBookingFixture,
removeInariRecording,
tourIdOf,
unkeepTour,
unlinkInariRecording,
unlinkPacking,
} from './fixtures-trip-tour-detail';
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import {
ensureTourRecording,
ensureToursFixtures,
openTourTrip,
TOUR_COLLECTION,
TOUR_NAMES,
type ToursFixtures,
} from './tour-fixtures';
import { openTrip } from './trip-shared';
/**
* Actions for the card a tour opens over the map on the Plan tab, keyed by the
* ids in `src/help/contexts/tripTourDetail.ts`. They run on "Summer in the
* Dolomites" from `tour-fixtures.ts`, and the two about actual tracks on the
* Fushimi Inari hike of the seeded "Autumn in Japan" with its recording
* (`ensureTourRecording`). What a guide changes it changes back in `cleanup`.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourDetailGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures | null = null;
const fixtures = (): ToursFixtures => {
if (!fx) throw new Error('ensureToursFixtures did not run');
return fx;
};
/** The new name the rename guide types. */
const RENAMED = 'Sella Ronda, four passes';
/** The colour it picks: TRACK_COLORS[1], the orange. */
const PICKED_COLOR = '#ea580c';
const COPY_NAME = `${TOUR_NAMES.roadBike} (copy)`;
/**
* The tour card: the box whose head carries the Track color dot. Only the
* tour card has that button in a header, the place card names its own
* "Track color".
*/
const card = (page: Page) => page.locator('header:has(button[aria-label="Track color"])').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 }).first().locator('xpath=ancestor::section[1]');
/** The Places | Tours switch at the top of the right column. */
const toursTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Tours' });
/** A tour's row in the Tours list. */
const row = (page: Page, name: string) => page.getByRole('option', { name, exact: true }).first();
/** The block of distance, times, heights and the profile: the first fact list with the altitudes, and its frame. */
const measures = (page: Page) =>
card(page).locator('dl').filter({ hasText: 'Minimum altitude' }).first().locator('xpath=..');
/** "Files" while the tour has none, "<n> file(s)" once it has. */
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files?)$/ });
const compare = (page: Page) => card(page).getByRole('region', { name: 'Plan and actual' });
const only = (target: (p: Page) => Locator) => ({ target });
/** Switch the right column to Tours and open a tour's card from its row. */
async function openTour(page: Page, name: string): Promise<void> {
if ((await toursTab(page).getAttribute('aria-selected')) !== 'true') await toursTab(page).click();
await expect(row(page, name)).toBeVisible({ timeout: 30_000 });
await row(page, name).click();
await expect(head(page).getByRole('heading', { name, exact: true })).toBeVisible({ timeout: 30_000 });
await settle(page);
}
async function putPlace(page: Page, tripId: number, placeId: number, data: Record<string, unknown>): Promise<void> {
const res = await page.request.put(`/api/trips/${tripId}/places/${placeId}`, { data });
if (!res.ok()) throw new Error(`restore place ${placeId}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-detail-read': {
guide: guide('tour-detail-read'),
// Day 4 open, the first stage of the hut trek, so the card knows the stop it was opened on.
start: async (p) => {
await openTourTrip(p, { day: 4 });
},
steps: [
{
prepare: async (p) => {
await toursTab(p).click();
await expect(row(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
target: (p) => row(p, TOUR_NAMES.hutToHut),
act: async (p) => {
await row(p, TOUR_NAMES.hutToHut).click();
await expect(head(p).getByRole('heading', { name: TOUR_NAMES.hutToHut, exact: true })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
only(head),
// The pill with the clock; hovering it shows the parts of the time.
only((p) =>
head(p)
.locator('span[aria-label]')
.filter({ has: p.locator('svg.lucide-clock') })
.first()
),
only((p) => section(p, 'Trip days')),
only(measures),
only((p) => section(p, 'Origin')),
// No click: the open card is what the guide leaves behind.
only((p) => head(p).getByRole('button', { name: 'Close', exact: true })),
],
},
'tour-rename-colour': {
guide: guide('tour-rename-colour'),
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.roadBike);
},
steps: [
{
target: (p) => head(p).getByRole('button', { name: 'Edit', exact: true }),
act: async (p) => {
await head(p).getByRole('button', { name: 'Edit', exact: true }).click();
await expect(head(p).getByRole('textbox', { name: 'Name' })).toBeVisible();
await typeInto(p, head(p).getByRole('textbox', { name: 'Name' }), RENAMED);
await settle(p);
},
},
{
target: (p) => head(p).getByRole('textbox', { name: 'Name' }).locator('xpath=..'),
act: async (p) => {
await head(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(head(p).getByRole('heading', { name: RENAMED, exact: true })).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => head(p).getByRole('button', { name: 'Track color' }),
act: async (p) => {
await head(p).getByRole('button', { name: 'Track color' }).click();
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toBeVisible();
await settle(p);
},
},
{
target: (p) => card(p).getByRole('button', { name: 'Automatic color' }).locator('xpath=ancestor::section[1]'),
act: async (p) => {
await card(p).getByRole('button', { name: PICKED_COLOR, exact: true }).click();
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toHaveAttribute(
'aria-pressed',
'true',
{ timeout: 20_000 }
);
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, tours } = fixtures();
const id = tours.roadBike ?? (await tourIdOf(p.request, tripId, RENAMED));
await putPlace(p, tripId, id, { name: TOUR_NAMES.roadBike, route_color: null });
},
},
'tour-take-along': {
guide: guide('tour-take-along'),
// The city walk: the one tour with a web page of its own.
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.cityWalk);
},
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);
},
},
{
// Clicking it opens an external tab, so the step only shows it.
prepare: async (p) => {
await p.keyboard.press('Escape');
await expect(p.getByRole('menu')).toHaveCount(0);
// Escape may have closed the card as well; bring it back.
if ((await card(p).count()) === 0) await openTour(p, TOUR_NAMES.cityWalk);
},
target: (p) => footer(p).getByRole('button', { name: /^Website: / }),
},
{
target: (p) => footer(p).getByTestId('tour-gpx-export'),
act: async (p) => {
const download = p.waitForEvent('download', { timeout: 30_000 });
await footer(p).getByTestId('tour-gpx-export').click();
await download;
await settle(p);
},
},
],
},
'tour-copy-keep': {
guide: guide('tour-copy-keep'),
start: async (p) => {
const { tripId, collectionId } = fixtures();
await deleteToursNamed(p.request, tripId, COPY_NAME);
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.roadBike);
},
steps: [
{
// The visible label is Copy; the button is named by what it does.
target: (p) => footer(p).getByRole('button', { name: 'Save as new tour' }),
act: async (p) => {
await footer(p).getByRole('button', { name: 'Save as new tour' }).click();
await expect(head(p).getByRole('heading', { name: COPY_NAME, exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only(head),
{
// Back to the original: that is the one kept for later trips.
prepare: async (p) => {
await openTour(p, TOUR_NAMES.roadBike);
},
target: (p) => footer(p).getByTestId('tour-keep-in-collection'),
act: async (p) => {
await footer(p).getByTestId('tour-keep-in-collection').click();
await expect(p.getByTestId('keep-tour-confirm')).toBeVisible();
await expect(p.getByTestId(`keep-tour-list-${fixtures().collectionId}`)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId(`keep-tour-list-${fixtures().collectionId}`),
act: async (p) => {
await p.getByTestId(`keep-tour-list-${fixtures().collectionId}`).click();
await p.getByTestId('keep-tour-confirm').click();
await expect(p.getByTestId('keep-tour-confirm')).toHaveCount(0, { timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, collectionId } = fixtures();
await deleteToursNamed(p.request, tripId, COPY_NAME);
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
},
},
'tour-packing-link': {
guide: guide('tour-packing-link'),
start: async (p) => {
const { tripId } = fixtures();
await ensureTourDetailFixtures(p.request, tripId);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.ferrata);
},
steps: [
{
target: (p) => card(p).getByRole('button', { name: 'Link a packing list' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Link a packing list' }).click();
await expect(p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`)).toBeVisible({
timeout: 20_000,
});
await settle(p);
},
},
{
target: (p) => p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`),
act: async (p) => {
await p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`).click();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-packing-confirm'),
act: async (p) => {
await p.getByTestId('tour-packing-confirm').click();
await expect(card(p).getByTestId('tour-packing-card')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-packing-card')),
{
target: (p) => card(p).getByRole('button', { name: 'Open packing list' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Open packing list' }).click();
await expect(p.getByText(FERRATA_PACKING.items[0], { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const { tripId, tours } = fixtures();
await unlinkPacking(p.request, tripId, tours.ferrata ?? (await tourIdOf(p.request, tripId, TOUR_NAMES.ferrata)));
},
},
'tour-files': {
guide: guide('tour-files'),
start: async (p) => {
await deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE);
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.hutToHut);
},
steps: [
{
target: (p) => section(p, 'Files').getByText('Upload', { exact: true }),
act: async (p) => {
await card(p).locator('input[type="file"][multiple]').setInputFiles(hutBookingFixture());
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
// The list unfolds itself once the upload lands.
only((p) => section(p, 'Files')),
// No click: opening the file hands the browser a download.
only((p) => card(p).getByRole('button', { name: new RegExp(`^${HUT_BOOKING_FILE.replace('.', '\\.')}`) })),
],
cleanup: (p) => deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE),
},
'tour-activity-link': {
guide: guide('tour-activity-link'),
start: async (p) => {
await ensureTourRecording(p.request);
await unlinkInariRecording(p.request);
await openTrip(p, { day: 5 });
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
{
target: (p) => section(p, 'Actual track').getByRole('button', { name: 'Add actual track' }),
act: async (p) => {
await card(p).getByRole('button', { name: 'Add actual track' }).click();
await expect(p.getByTestId('tour-activity-confirm')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('tour-activity-candidate').first()).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
only((p) => p.getByText('Done on', { exact: true }).first().locator('xpath=ancestor::section[1]')),
{
target: (p) => p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }),
act: async (p) => {
await p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }).click();
await expect(p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) })).toHaveAttribute(
'aria-checked',
'true'
);
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-activity-confirm'),
act: async (p) => {
await p.getByTestId('tour-activity-confirm').click();
await expect(card(p).getByTestId('tour-activity')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-activity')),
],
// The guide ends where ensureTourRecording leaves the hike: the recording linked on day 5.
cleanup: async (p) => {
await ensureTourRecording(p.request);
},
},
'tour-activity-compare': {
guide: guide('tour-activity-compare'),
start: async (p) => {
await ensureTourRecording(p.request);
await openTrip(p, { day: 5 });
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
only((p) => card(p).getByTestId('tour-activity-compare')),
// The fact list under the table: Farthest from the plan and Off the plan.
only((p) => compare(p).locator('dl').last()),
{
target: (p) => compare(p).getByRole('button', { name: 'Compare on map' }),
act: async (p) => {
await compare(p).getByRole('button', { name: 'Compare on map' }).click();
await expect(compare(p).getByRole('button', { name: 'End comparison' })).toBeVisible();
await settle(p);
},
},
only((p) => card(p).getByTestId('tour-activity').locator('footer')),
],
},
'tour-origin-check': {
guide: guide('tour-origin-check'),
// The imported Seceda walk first, then the hut trek, which was drawn in TREK.
start: async (p) => {
await openTourTrip(p, { day: null });
await openTour(p, TOUR_NAMES.recorded);
},
steps: [
only((p) => section(p, 'Origin').getByText('Source', { exact: true }).locator('xpath=..')),
only((p) => section(p, 'Origin')),
{
prepare: async (p) => {
await openTour(p, TOUR_NAMES.hutToHut);
},
target: (p) => section(p, 'Origin'),
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
fx = await ensureToursFixtures(request);
if (fx.collectionId === null)
throw new Error(`no collection "${TOUR_COLLECTION}": ensureToursFixtures could not make it`);
await ensureTourDetailFixtures(request, fx.tripId);
});
// The recording is one more tour in a trip the other screens photograph.
test.afterAll(async ({ request }) => {
await removeInariRecording(request);
});
test('every registered tour card guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourDetailContext.guides].sort());
});
test('hero: trip-tour-detail', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourDetailContext.id, async (p) => {
await openTourTrip(p, { day: 4 });
await openTour(p, TOUR_NAMES.hutToHut);
});
});
for (const id of tripTourDetailContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-513
View File
@@ -1,513 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourHikeContext, tripTourHikeGuides } from '../../src/help/contexts/tripTourHike';
import type { HelpGuide } from '../../src/help/types';
import {
addTripPlaces,
deleteToursNamed,
editorRail,
forgetDraft,
openDetails,
openTour,
openToursTab,
pickTourType,
routeSettled,
SAVED_BY_GUIDES,
startNewTour,
toursRail,
} from './fixtures-trip-tour-hike';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_NAMES, TOUR_PLACES } from './tour-fixtures';
/**
* Actions for hikes and runs in the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourHike.ts`. They run on "Summer in the Dolomites"
* from `tour-fixtures.ts` (the hut trek, the via ferrata hike and the railway
* run) and, where a dense web of paths matters, on the Fushimi Inari hike of
* the seeded Japan trip. The two plan guides save a tour through the UI and
* delete it again by name in `cleanup`; every other guide changes the open
* tour in the editor only and never saves, so a fresh page starts from the
* saved tour again.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourHikeGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
/** The Add point and Trip places pair under the waypoints, one quiet bar. */
const addRow = (page: Page) =>
page.getByTestId('tour-planner-add-point').locator('xpath=ancestor::div[contains(@class,"bg-surface-tertiary")][1]');
const waysCard = (page: Page) => page.getByTestId('hike-ways-card');
const waysSwitch = (page: Page) => waysCard(page).getByRole('button', { name: 'Show along the route', exact: true });
const facetButton = (page: Page, name: 'SAC' | 'Surface' | 'Way type') =>
page.getByRole('group', { name: 'What to show', exact: true }).getByRole('button', { name, exact: true });
/** The first class row of the known ways, the most of the route first. */
const firstKnownRow = (page: Page) => waysCard(page).locator('ul').first().getByRole('button').first();
const waypointRow = (page: Page, n: number) => page.getByTestId('tour-planner-waypoint-row').nth(n);
const openMenu = (page: Page) => page.getByRole('menu');
const ferrataIcons = (page: Page) => page.locator('.trek-ferrata-icon');
const ferrataCard = (page: Page) => page.getByTestId('ferrata-detail-card');
// ── Getting there ─────────────────────────────────────────────────────────────
/** Switch Trail details on and wait until the ways along the line have been read. */
async function showWays(page: Page): Promise<void> {
if ((await waysSwitch(page).getAttribute('aria-pressed')) !== 'true') await waysSwitch(page).click();
await expect(page.getByTestId('sac-known').or(page.getByTestId('surface-known'))).toBeVisible({ timeout: 120_000 });
await settle(page);
}
/** Click Save and wait for the toast that says so. */
async function saveTour(page: Page): Promise<void> {
await saveButton(page).click();
await expect(page.getByText('Tour saved', { exact: true }).first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/**
* The index of the via ferrata Brigata Tridentina's sign among the ones drawn,
* picked by its card: the Sella group has several ferratas in one view. Only a
* sign between the two columns counts, as a click on one under a column lands
* on the column. Falls back to the first sign that is clear of both.
*/
let ferrataIndex = 0;
async function findTridentina(page: Page): Promise<void> {
const left = await editorRail(page).boundingBox();
const right = await toursRail(page).boundingBox();
const minX = (left ? left.x + left.width : 0) + 24;
const maxX = (right ? right.x : VIEWPORT.width) - 24;
const count = await ferrataIcons(page).count();
let fallback = -1;
for (let i = 0; i < count; i++) {
const box = await ferrataIcons(page).nth(i).boundingBox();
if (!box || box.x < minX || box.x + box.width > maxX || box.y < 80 || box.y > VIEWPORT.height - 120) continue;
if (fallback < 0) fallback = i;
await ferrataIcons(page).nth(i).click();
await expect(ferrataCard(page)).toBeVisible();
const name = (await ferrataCard(page).locator('h3').textContent()) ?? '';
await ferrataCard(page).getByRole('button', { name: 'Close', exact: true }).click();
await expect(ferrataCard(page)).toHaveCount(0);
await page.waitForTimeout(300);
if (/tridentina/i.test(name)) {
ferrataIndex = i;
return;
}
}
if (fallback < 0)
throw new Error('no via ferrata sign between the columns: the map is not framed on the Sella group');
ferrataIndex = fallback;
}
// ── The scripts ───────────────────────────────────────────────────────────────
let summerTripId = 0;
let japanTripId = 0;
async function openSummer(page: Page): Promise<void> {
summerTripId = await openToursTab(page, 'summer');
}
async function openJapan(page: Page): Promise<void> {
japanTripId = await openToursTab(page, 'japan');
}
/** The stop the compare-ways guide asks from, found by `legWithOtherWays`. */
let waysFrom = 1;
/**
* The first stop, from the Yotsutsuji crossing on, whose next leg the router
* offers another way for. What the public router knows changes with its data,
* so a fixed leg can come back with "no other way" on one day and two on the next.
*/
async function legWithOtherWays(page: Page): Promise<number> {
const card = page.getByTestId('leg-variants-card');
const rows = await page.getByTestId('tour-planner-waypoint-row').count();
const order = [1, ...Array.from({ length: Math.max(0, rows - 1) }, (_, i) => i).filter((i) => i !== 1)];
for (const i of order) {
await waypointRow(page, i).hover();
await waypointRow(page, i).getByRole('button', { name: 'More actions', exact: true }).click();
const compare = openMenu(page).getByRole('button', { name: 'Compare ways for the next leg', exact: true });
if (!(await compare.isVisible().catch(() => false))) {
await page.keyboard.press('Escape');
continue;
}
await compare.click();
const way = page.getByRole('radio', { name: 'Way A', exact: true });
const none = card.getByText(/knows no other way|did not answer/);
await expect(way.or(none)).toBeVisible({ timeout: 120_000 });
const found = await way.isVisible();
await card.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(card).toHaveCount(0);
if (found) return i;
}
throw new Error('the router knows no other way for any leg of the Inari hike');
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-hike-plan': {
guide: guide('tour-hike-plan'),
start: openSummer,
steps: [
{
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour' }),
act: startNewTour,
},
{
prepare: async (p) => {
await p.getByTestId('tour-type-select').click();
await settle(p);
},
target: (p) => p.getByTestId('tour-type-option-hike'),
act: (p) => pickTourType(p, 'hike'),
},
{
target: (p) => p.locator('#tour-planner-name'),
act: async (p) => {
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.hike);
},
},
{
target: addRow,
act: async (p) => {
// Two trip places stand in for two clicks on the map: they land on real spots every run.
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
await routeSettled(p);
},
},
{
prepare: openDetails,
target: (p) => p.locator('#tour-planner-difficulty'),
act: async (p) => {
await p.locator('#tour-planner-difficulty').click();
await p.getByRole('button', { name: /^T3\b/ }).last().click();
await routeSettled(p);
},
},
{
target: saveButton,
act: saveTour,
},
],
cleanup: async (p) => {
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.hike);
await forgetDraft(p, summerTripId);
},
},
'tour-hike-trail-details': {
guide: guide('tour-hike-trail-details'),
start: async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.hutToHut);
},
steps: [
{
target: waysSwitch,
act: showWays,
},
{
target: (p) => p.getByRole('group', { name: 'What to show', exact: true }),
act: async (p) => {
await facetButton(p, 'SAC').click();
await settle(p);
},
},
{
target: (p) => p.getByTestId('sac-known').locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]'),
},
{
target: firstKnownRow,
act: async (p) => {
await firstKnownRow(p).click();
await settle(p);
},
},
{
prepare: async (p) => {
// Back to the whole route before the next facet, as the step before says.
await firstKnownRow(p).click();
await settle(p);
},
target: (p) => facetButton(p, 'Surface'),
act: async (p) => {
await facetButton(p, 'Surface').click();
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
'tour-hike-round': {
guide: guide('tour-hike-round'),
start: async (p) => {
await openJapan(p);
await openTour(p, TOUR_NAMES.inariHike);
},
steps: [
{
target: (p) => p.getByTestId('tour-planner-round-hike'),
act: async (p) => {
await p.getByTestId('tour-planner-round-hike').click();
await expect(p.getByTestId('round-hike-card')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.locator('#round-hike-distance').locator('xpath=ancestor::div[2]'),
act: async (p) => {
await p.locator('#round-hike-distance').fill('6');
await expect(p.getByTestId('round-hike-window')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('round-hike-find'),
act: async (p) => {
await p.getByTestId('round-hike-find').click();
// Many questions to the router, one after the other; the bar goes when the last has come back.
await expect(p.getByRole('radio', { name: 'Round A', exact: true })).toBeVisible({ timeout: 240_000 });
await expect(p.getByTestId('round-hike-card').getByRole('progressbar')).toHaveCount(0, { timeout: 240_000 });
await settle(p);
},
},
{
target: (p) => p.getByRole('radio', { name: 'Round A', exact: true }),
act: async (p) => {
await p.getByRole('radio', { name: 'Round A', exact: true }).click();
await expect(p.getByTestId('round-hike-apply')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => p.getByTestId('round-hike-apply'),
act: async (p) => {
await p.getByTestId('round-hike-apply').click();
await p.getByRole('button', { name: 'Replace', exact: true }).click();
await expect(p.getByText('Round taken', { exact: true })).toBeVisible({ timeout: 30_000 });
await routeSettled(p);
},
},
],
cleanup: (p) => forgetDraft(p, japanTripId),
},
'tour-hike-compare-ways': {
guide: guide('tour-hike-compare-ways'),
start: async (p) => {
await openJapan(p);
await openTour(p, TOUR_NAMES.inariHike);
waysFrom = await legWithOtherWays(p);
},
steps: [
{
// A stop whose next leg the router knows another way for; the Yotsutsuji crossing usually.
prepare: async (p) => {
await waypointRow(p, waysFrom).hover();
await waypointRow(p, waysFrom).getByRole('button', { name: 'More actions', exact: true }).click();
await expect(openMenu(p)).toBeVisible();
},
target: (p) => openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }),
act: async (p) => {
await openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }).click();
await expect(p.getByTestId('leg-variants-card')).toBeVisible();
await expect(p.getByRole('radio', { name: 'Way A', exact: true })).toBeVisible({ timeout: 120_000 });
await expect(p.getByTestId('leg-variants-card').getByText('Looking for other ways')).toHaveCount(0, {
timeout: 120_000,
});
await settle(p);
},
},
{
target: (p) => p.getByTestId('leg-variants-card'),
},
{
target: (p) => p.getByRole('radio', { name: 'Way A', exact: true }),
act: async (p) => {
await p.getByRole('radio', { name: 'Way A', exact: true }).click();
await expect(p.getByTestId('leg-variants-apply')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => p.getByTestId('leg-variants-apply'),
act: async (p) => {
await p.getByTestId('leg-variants-apply').click();
await expect(p.getByText('Way taken', { exact: true })).toBeVisible({ timeout: 60_000 });
await routeSettled(p);
},
},
],
cleanup: (p) => forgetDraft(p, japanTripId),
},
'tour-ferrata-add': {
guide: guide('tour-ferrata-add'),
start: async (p) => {
await openSummer(p);
// Opening the ferrata hike frames the map on the Sella group, and a new tour without
// points keeps that view. Not through Back to Tours: the neutral rail frames the whole
// trip again, too far out for the via ferrata signs. Add tour on the right goes straight
// from the open tour to a new one.
await openTour(p, TOUR_NAMES.ferrata);
await toursRail(p).getByRole('button', { name: 'Add tour', exact: true }).click();
await p.getByRole('button', { name: 'Plan new tour', exact: true }).last().click();
await expect(editorRail(p).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
timeout: 15_000,
});
await pickTourType(p, 'hike');
await settle(p);
},
steps: [
{
target: (p) => p.getByRole('button', { name: 'Via ferratas', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Via ferratas', exact: true }).click();
await expect(p.getByTestId('ferrata-count-badge')).toBeVisible({ timeout: 60_000 });
await expect(p.getByTestId('ferrata-count-spinner')).toHaveCount(0, { timeout: 90_000 });
await expect(ferrataIcons(p).first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: findTridentina,
target: (p) => ferrataIcons(p).nth(ferrataIndex),
act: async (p) => {
await ferrataIcons(p).nth(ferrataIndex).click();
await expect(ferrataCard(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('ferrata-adopt'),
act: async (p) => {
await p.getByTestId('ferrata-adopt').click();
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(2, { timeout: 15_000 });
await routeSettled(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-ferrata-leg').first(),
},
{
target: (p) => p.getByTestId('tour-ferrata-crossings'),
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
'tour-run-plan': {
guide: guide('tour-run-plan'),
start: openSummer,
steps: [
{
prepare: async (p) => {
await startNewTour(p);
await p.getByTestId('tour-type-select').click();
await settle(p);
},
target: (p) => p.getByTestId('tour-type-option-run'),
act: (p) => pickTourType(p, 'run'),
},
{
target: (p) => p.getByTestId('tour-run-profile'),
act: async (p) => {
await p.getByTestId('tour-run-profile').getByRole('button', { name: 'Trail running', exact: true }).click();
await settle(p);
},
},
{
target: (p) => p.locator('#tour-planner-run-pace'),
act: async (p) => {
await p.locator('#tour-planner-run-pace').fill('7:30');
await p.locator('#tour-planner-run-pace').press('Enter');
},
},
{
prepare: async (p) => {
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
await routeSettled(p);
await expect(p.getByTestId('tour-run-time-sum')).toBeVisible({ timeout: 30_000 });
},
target: (p) => p.getByTestId('tour-run-time-sum'),
},
{
prepare: async (p) => {
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.run);
},
target: saveButton,
act: saveTour,
},
],
cleanup: async (p) => {
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.run);
await forgetDraft(p, summerTripId);
},
},
'tour-run-ways': {
guide: guide('tour-run-ways'),
start: async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.run);
},
steps: [
{
target: waysSwitch,
act: showWays,
},
{
// The card's head: the unpaved share and the hardest graded path, read once the ways are in.
target: (p) => p.getByRole('region', { name: 'Trail details', exact: true }).locator('> div').first(),
},
{
prepare: async (p) => {
await facetButton(p, 'Surface').click();
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
await settle(p);
},
target: firstKnownRow,
act: async (p) => {
await firstKnownRow(p).click();
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, summerTripId),
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }, testInfo) => {
// A cold run fetches every route of the Tours demo trips once (minutes), later runs read the cache.
testInfo.setTimeout(600_000);
await ensureToursFixtures(request);
});
test('every registered hike and run guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHikeContext.guides].sort());
});
test('hero: trip-tour-hike', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourHikeContext.id, async (p) => {
await openSummer(p);
await openTour(p, TOUR_NAMES.ferrata);
});
});
for (const id of tripTourHikeContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
test.setTimeout(600_000);
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-321
View File
@@ -1,321 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourHorseContext, tripTourHorseGuides } from '../../src/help/contexts/tripTourHorse';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
finishMapPan,
forgetTourDraft,
horsePlan,
NEW_RIDE,
openSavedRide,
openToursTab,
RECORDED_RIDE,
recordedRideFile,
routeTitle,
serveHorseRoutes,
tourRow,
type HorsePlan,
} from './fixtures-trip-tour-horse';
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures } from './tour-fixtures';
/**
* Actions for horse rides on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourHorse.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`), with the Seiser Alm in view: the saved "Seiser Alm
* horse ride" for the overview, a new ride built from the horse routes the
* card is given (`horsePlan`, the same answer for every picture), and a ride
* recorded on a GPS device imported from its file. The new ride and the
* import are deleted again in `cleanup`; the gap guide saves nothing, but its
* draft is kept on the account, so its cleanup drops it before the next guide
* of the trip opens the Tours tab.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourHorseGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let plan: HorsePlan;
let rideFile = '';
// ── Where things are ──────────────────────────────────────────────────────────
/** The editor's left column: a section named after what it is for. */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
const planNewTour = (page: Page) => page.getByRole('button', { name: 'Plan new tour', exact: true });
const typeSelect = (page: Page) => page.getByTestId('tour-type-select');
const horseOption = (page: Page) => page.getByTestId('tour-type-option-horse');
const horseCard = (page: Page) => page.getByRole('region', { name: 'Horse routes' });
const findButton = (page: Page) =>
page.getByTestId('horse-routes-card').getByRole('button', { name: 'Find in map view' });
const routeRow = (page: Page, id: string) => page.getByTestId(`horse-route-${id}`);
/**
* A dialog's accessible name is its eyebrow ("Horse route", "Import preview"),
* not its title, so the two dialogs are found by the title heading they hold.
*/
const dialogTitled = (page: Page, title: string) =>
page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
const routeDialog = (page: Page, id: string) => dialogTitled(page, routeTitle(plan, id));
const sectionChip = (page: Page, n: number) => page.getByTestId(`horse-section-${n}`);
const takeSection = (page: Page) => page.getByTestId('horse-section-adopt');
const joinGapSwitch = (page: Page) => page.getByRole('button', { name: 'Join with a manual section', exact: true });
const horseLeg = (page: Page) => page.getByTestId('tour-planner-horse-leg').first();
const manualLeg = (page: Page) => page.getByTestId('tour-planner-free-leg').first();
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
const previewDialog = (page: Page) => dialogTitled(page, RECORDED_RIDE.file);
const recordedCard = (page: Page) => page.getByRole('region', { name: 'Recorded ride' });
// ── Getting there ─────────────────────────────────────────────────────────────
/** Plan new tour, and the type set to Horse ride. */
async function startRide(page: Page): Promise<void> {
await planNewTour(page).click();
await expect(typeSelect(page)).toBeVisible({ timeout: 15_000 });
await typeSelect(page).click();
await horseOption(page).click();
await expect(horseCard(page)).toBeVisible();
await settle(page);
}
/**
* Find in map view, and the list. A view the card finds too large says so
* instead of searching; then the map is zoomed in once over its middle and the
* button pressed again, which is what the notice asks of the reader too.
*/
async function findRoutes(page: Page): Promise<void> {
const zoomNotice = page.getByText('Zoom in further to look for horse routes.');
// The planner opens around zoom 11, near a degree wide; a search may span half of one.
for (let attempt = 0; attempt < 5; attempt++) {
await findButton(page).click();
await expect(page.getByTestId('horse-routes-list').or(zoomNotice)).toBeVisible({ timeout: 45_000 });
if (await page.getByTestId('horse-routes-list').isVisible()) break;
const box = await page.getByTestId('tour-map-surface').boundingBox();
if (!box) throw new Error('the planner map has no box');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -480);
await finishMapPan(page);
}
await expect(page.getByTestId('horse-routes-list')).toBeVisible();
await settle(page);
}
async function openRoute(page: Page, id: string): Promise<void> {
await routeRow(page, id).click();
await expect(routeDialog(page, id)).toBeVisible({ timeout: 15_000 });
await settle(page);
}
async function pickSection(page: Page, n: number): Promise<void> {
await sectionChip(page, n).click();
await expect(sectionChip(page, n)).toHaveAttribute('aria-pressed', 'true');
await settle(page);
}
/** Take the open dialog's section and wait for its badge in the Waypoints card. */
async function adopt(page: Page, id: string): Promise<void> {
await takeSection(page).click();
await expect(routeDialog(page, id)).toHaveCount(0);
await expect(horseLeg(page)).toBeVisible({ timeout: 15_000 });
await settle(page);
}
/** The gap guide's start: a ride holding its first section, and the route with the gap open. */
async function rideWithFirstSection(page: Page): Promise<void> {
await serveHorseRoutes(page, plan);
await openToursTab(page);
await startRide(page);
await findRoutes(page);
await openRoute(page, plan.first.routeId);
await pickSection(page, plan.first.section);
await adopt(page, plan.first.routeId);
await openRoute(page, plan.gap.routeId);
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-horse-plan': {
guide: guide('tour-horse-plan'),
start: async (p) => {
await deleteToursNamed(p.request, NEW_RIDE);
await serveHorseRoutes(p, plan);
await openToursTab(p);
},
steps: [
{
target: planNewTour,
act: async (p) => {
await planNewTour(p).click();
await expect(typeSelect(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(horseOption(p)).toBeVisible();
await settle(p);
},
target: horseOption,
act: async (p) => {
await horseOption(p).click();
await expect(horseCard(p)).toBeVisible();
await settle(p);
},
},
{ target: findButton, act: findRoutes },
{
target: (p) => routeRow(p, plan.first.routeId),
act: (p) => openRoute(p, plan.first.routeId),
},
{
prepare: (p) => pickSection(p, plan.first.section),
target: (p) => routeDialog(p, plan.first.routeId),
// The pointer on the section chosen, so its tooltip names what the tags say along it.
hover: async (p) => {
await sectionChip(p, plan.first.section).hover();
},
},
{
target: takeSection,
act: (p) => adopt(p, plan.first.routeId),
},
{
prepare: async (p) => {
await typeInto(p, p.locator('#tour-planner-name'), NEW_RIDE);
await p.locator('#tour-planner-name').press('Tab');
// Heights are asked for the line before a ride can be saved.
await expect(saveButton(p)).toBeEnabled({ timeout: 60_000 });
await settle(p);
},
target: saveButton,
act: async (p) => {
await saveButton(p).click();
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
await expect(tourRow(p, NEW_RIDE)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: (p) => deleteToursNamed(p.request, NEW_RIDE),
},
'tour-horse-gap': {
guide: guide('tour-horse-gap'),
start: rideWithFirstSection,
steps: [
{
prepare: (p) => pickSection(p, plan.gap.section),
target: (p) => p.getByTestId('horse-section-gap'),
},
{
target: joinGapSwitch,
act: async (p) => {
await joinGapSwitch(p).click();
await expect(joinGapSwitch(p)).toHaveAttribute('aria-pressed', 'true');
await expect(takeSection(p)).toBeEnabled();
await settle(p);
},
},
{
target: takeSection,
act: async (p) => {
await takeSection(p).click();
await expect(routeDialog(p, plan.gap.routeId)).toHaveCount(0);
await expect(manualLeg(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{ target: manualLeg },
],
cleanup: (p) => forgetTourDraft(p.request),
},
'tour-horse-recorded': {
guide: guide('tour-horse-recorded'),
start: async (p) => {
await deleteToursNamed(p.request, RECORDED_RIDE.name);
await openToursTab(p);
},
steps: [
{
target: (p) => p.getByTestId('tour-rail-add'),
act: async (p) => {
await p.getByTestId('tour-rail-add').click();
const entry = p.getByRole('button', { name: 'Import GPX or KML', exact: true });
await expect(entry).toBeVisible();
// The file picker of the operating system cannot be photographed; its chooser is answered instead.
const chooser = p.waitForEvent('filechooser');
await entry.click();
await (await chooser).setFiles(rideFile);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await expect(previewDialog(p).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await previewDialog(p).getByTestId('tour-type-select').click();
await horseOption(p).click();
await expect(previewDialog(p).getByTestId('gpx-preview-horse')).toBeVisible();
await settle(p);
},
target: (p) => previewDialog(p).getByTestId('gpx-preview-horse'),
},
{
target: (p) => previewDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
await expect(recordedCard(p)).toBeVisible({ timeout: 60_000 });
await finishMapPan(p);
},
},
{
target: recordedCard,
// On the badge with the most to say, so the picture carries its tooltip.
hover: async (p) => {
await recordedCard(p).getByText('Access not checked', { exact: true }).hover();
},
},
],
cleanup: (p) => deleteToursNamed(p.request, RECORDED_RIDE.name),
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
test.setTimeout(600_000);
await ensureToursFixtures(request);
plan = await horsePlan(request);
console.log(`[trip-tour-horse] horse routes: ${plan.source}`);
rideFile = await recordedRideFile();
});
test.afterAll(async ({ request }) => {
// Here as well as in the cleanups, so a failed run leaves no ride behind.
await deleteToursNamed(request, NEW_RIDE, RECORDED_RIDE.name);
await forgetTourDraft(request);
});
test('every registered horse ride guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHorseContext.guides].sort());
});
test('hero: trip-tour-horse', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourHorseContext.id, async (p) => {
await serveHorseRoutes(p, plan);
await openSavedRide(p);
});
});
for (const id of tripTourHorseContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-415
View File
@@ -1,415 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourImportContext, tripTourImportGuides } from '../../src/help/contexts/tripTourImport';
import type { HelpGuide } from '../../src/help/types';
import {
deleteToursNamed,
disconnectWanderer,
ensureWandererConnection,
forgetTourDraft,
IMPORTED_NAME,
importFile,
RECORDED_NAME,
restoreRecordedTrack,
tourNamed,
tripIdByTitle,
WANDERER_TRAIL,
} from './fixtures-trip-tour-import';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript, type StepAction } from './guide';
import { ensureToursFixtures, openTourTrip, SKI_TRIP, TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for getting tours into a trip, keyed by the ids in
* `src/help/contexts/tripTourImport.ts`. They run on the Tours tab of
* "Summer in the Dolomites" (`ensureToursFixtures`): a GPX file of the
* Rasciesa balcony walk goes in through the Import preview, the recorded
* Seceda ridge walk that the fixtures imported is read, planned over and put
* back, a run of that trip is taken over into the ski week, and the Rasciesa
* walk comes in once more from a wanderer instance. What a guide adds it
* deletes again in `cleanup`, and the Seceda walk is always given its
* recorded track back, because the fixtures never rebuild a tour.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourImportGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
/** The right column of the Tours tab. */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
const addTour = (page: Page) => page.getByTestId('tour-rail-add');
/** The menu the + opens; a portal on the body, labelled like its button. */
const addMenuItem = (page: Page, label: string) =>
page.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: label, exact: true });
/** The left column while an import is shown: a section named after the tour. */
const importRail = (page: Page, name: string) => page.getByRole('region', { name, exact: true });
const railCard = (page: Page, rail: string, card: string) =>
importRail(page, rail).getByRole('region', { name: card, exact: true });
/** The left column while a tour is edited (and while nothing is open). */
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
const previewDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Import preview' });
const previewSection = (page: Page, has: 'tracks' | 'as') =>
has === 'tracks'
? previewDialog(page)
.locator('section')
.filter({ has: page.getByTestId('gpx-preview-track') })
: previewDialog(page)
.locator('section')
.filter({ has: page.getByTestId('tour-type-select') });
/**
* The take-over dialog. Its header labels it by the eyebrow ("Take over a
* tour"), not by the title, so it is found by the list section the title
* names, which stays right whichever header line ends up as the label.
*/
const library = (page: Page) =>
page.getByRole('dialog').filter({ has: page.getByRole('region', { name: 'From another trip', exact: true }) });
const wandererRail = (page: Page) => page.getByRole('region', { name: 'wanderer', exact: true });
// ── Getting there ─────────────────────────────────────────────────────────────
/** The Tours tab of the Dolomites trip (or the ski week), with its list of tours in. */
async function openTours(page: Page, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
await openTourTrip(page, { trip, tab: 'tour-planner' });
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** The + and its menu, as the first two steps of every way in show them. */
function addMenuSteps(entry: string, act: (page: Page) => Promise<void>): StepAction[] {
return [
{
target: addTour,
act: async (p) => {
await addTour(p).click();
await expect(addMenuItem(p, entry)).toBeVisible();
await settle(p);
},
},
{ target: (p) => addMenuItem(p, entry), act },
];
}
/** Picks the Rasciesa GPX in the file chooser the menu entry opens, and waits for the preview. */
async function chooseImportFile(page: Page): Promise<void> {
const file = await importFile();
const chooser = page.waitForEvent('filechooser');
await addMenuItem(page, 'Import GPX or KML').click();
await (await chooser).setFiles(file);
await expect(previewDialog(page)).toBeVisible({ timeout: 30_000 });
await expect(previewDialog(page).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** Opens the recorded Seceda walk from the list: an import is shown as it came in. */
async function viewRecorded(page: Page): Promise<void> {
await tourRow(page, RECORDED_NAME).click();
await expect(railCard(page, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 20_000 });
await settle(page);
}
/** Asks for the editable version and waits until the router has answered and the card compares. */
async function analyse(page: Page): Promise<void> {
await railCard(page, RECORDED_NAME, 'Editable version')
.getByRole('button', { name: 'Plan an editable version' })
.click();
await expect(page.getByRole('button', { name: 'Replace the import' })).toBeVisible({ timeout: 120_000 });
await settle(page);
}
/** Takes the suggestion over the import in place; the editor opens and has to route before it can save. */
async function replaceImport(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Replace the import' }).click();
await expect(editorRail(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({ timeout: 120_000 });
await settle(page);
}
async function saveEditor(page: Page): Promise<void> {
await editorRail(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Tour saved').first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Puts the Dolomites trip back as the fixtures left it: no Rasciesa tour, the Seceda walk as recorded, no draft. */
async function resetSummer(page: Page): Promise<void> {
const tripId = await tripIdByTitle(page.request, TOUR_TRIP);
await deleteToursNamed(page.request, tripId, IMPORTED_NAME, WANDERER_TRAIL);
await restoreRecordedTrack(page.request, tripId, RECORDED_NAME);
await forgetTourDraft(page.request, tripId);
}
// ── Scripts ───────────────────────────────────────────────────────────────────
const TAKEN_OVER_NAME = 'Morning run on the railway trail';
const SCRIPTS: Record<string, GuideScript> = {
'tour-import-gpx': {
guide: guide('tour-import-gpx'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
},
steps: [
...addMenuSteps('Import GPX or KML', chooseImportFile),
{ target: (p) => previewSection(p, 'tracks') },
// Hike is preset, and the Rasciesa walk is one: the step shows the field and leaves it.
{ target: (p) => previewSection(p, 'as') },
{
target: (p) => p.getByTestId('gpx-preview-import'),
act: async (p) => {
await p.getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
// One track: it opens on the map at once, as a GPX tour.
await expect(railCard(p, IMPORTED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, IMPORTED_NAME)).toBeVisible();
await settle(p);
},
},
],
cleanup: resetSummer,
},
'tour-view-import': {
guide: guide('tour-view-import'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
},
steps: [
{ target: (p) => tourRow(p, RECORDED_NAME), act: viewRecorded },
{ target: (p) => railCard(p, RECORDED_NAME, 'Tour summary') },
{
target: (p) => importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }),
act: async (p) => {
await importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }).click();
await expect(importRail(p, RECORDED_NAME)).toHaveCount(0);
await settle(p);
},
},
],
},
'tour-plan-from-import': {
guide: guide('tour-plan-from-import'),
start: async (p) => {
await resetSummer(p);
await openTours(p);
await viewRecorded(p);
},
steps: [
{
target: (p) =>
railCard(p, RECORDED_NAME, 'Editable version').getByRole('button', { name: 'Plan an editable version' }),
act: analyse,
},
{ target: (p) => railCard(p, RECORDED_NAME, 'Editable version') },
{ target: (p) => p.getByRole('button', { name: 'Replace the import' }), act: replaceImport },
{ target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }), act: saveEditor },
],
cleanup: resetSummer,
},
'tour-restore-recorded': {
guide: guide('tour-restore-recorded'),
// The walk is planned over first, the way the guide before this one does
// it, then the editor is closed so step 1 opens it from the list.
start: async (p) => {
await resetSummer(p);
await openTours(p);
await viewRecorded(p);
await analyse(p);
await replaceImport(p);
await saveEditor(p);
await editorRail(p).getByRole('button', { name: 'Back to Tours' }).click();
await expect(editorRail(p).getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 20_000 });
await settle(p);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, RECORDED_NAME).click();
await expect(editorRail(p).getByRole('region', { name: 'Recorded track', exact: true })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
target: (p) => editorRail(p).getByRole('region', { name: 'Recorded track', exact: true }),
},
{
target: (p) => editorRail(p).getByRole('button', { name: 'Restore recorded track' }),
act: async (p) => {
await editorRail(p).getByRole('button', { name: 'Restore recorded track' }).click();
await expect(confirmDialog(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }),
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }).click();
await expect(p.getByText('Recorded track restored').first()).toBeVisible({ timeout: 30_000 });
// It comes back as a GPX tour, shown as it was recorded.
await expect(railCard(p, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: resetSummer,
},
'tour-take-over': {
guide: guide('tour-take-over'),
start: async (p) => {
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME, TOUR_NAMES.run);
await forgetTourDraft(p.request, skiId);
await openTours(p, 'ski');
},
steps: [
...addMenuSteps('Take over a tour from another trip', async (p) => {
await addMenuItem(p, 'Take over a tour from another trip').click();
await expect(library(p)).toBeVisible();
await expect(library(p).getByTestId('tour-library-list')).toBeVisible({ timeout: 30_000 });
await settle(p);
}),
{
target: (p) => library(p).getByRole('region', { name: 'From another trip' }),
act: async (p) => {
await library(p).getByPlaceholder('Search by name').fill('railway');
await expect(
library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run })
).toBeVisible({ timeout: 20_000 });
await settle(p);
},
},
{
target: (p) => library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }),
act: async (p) => {
await library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }).click();
await expect(library(p).getByTestId('tour-library-preview')).toBeVisible({ timeout: 20_000 });
await expect(library(p).getByTestId('tour-library-confirm')).toBeEnabled({ timeout: 20_000 });
await p.waitForTimeout(800); // the preview's line
await settle(p);
},
},
{
target: (p) => library(p).getByLabel('Name in this trip'),
act: async (p) => {
await library(p).getByLabel('Name in this trip').fill(TAKEN_OVER_NAME);
await settle(p);
},
},
{
target: (p) => library(p).getByTestId('tour-library-confirm'),
act: async (p) => {
await library(p).getByTestId('tour-library-confirm').click();
await expect(library(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, TAKEN_OVER_NAME)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME);
await forgetTourDraft(p.request, skiId);
},
},
'tour-import-wanderer': {
guide: guide('tour-import-wanderer'),
// Connected here and taken away in cleanup: with a connection the neutral
// rail grows the wanderer button, which no other Tours picture shows.
start: async (p) => {
await resetSummer(p);
await ensureWandererConnection(p.request);
await openTours(p);
},
steps: [
{
target: (p) => p.getByTestId('tour-rail-open-wanderer'),
act: async (p) => {
await p.getByTestId('tour-rail-open-wanderer').click();
await expect(wandererRail(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('textbox', { name: 'Search trails' }),
act: async (p) => {
await wandererRail(p).getByRole('textbox', { name: 'Search trails' }).fill('Rasciesa');
await expect(wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL })).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }),
act: async (p) => {
await wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }).click();
await expect(wandererRail(p).getByRole('button', { name: 'Import into trip' })).toBeEnabled({
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => wandererRail(p).getByRole('button', { name: 'Import into trip' }),
act: async (p) => {
await wandererRail(p).getByRole('button', { name: 'Import into trip' }).click();
await expect(wandererRail(p)).toHaveCount(0, { timeout: 60_000 });
await expect(railCard(p, WANDERER_TRAIL, 'Tour summary')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await resetSummer(p);
await disconnectWanderer(p.request);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
const tripId = await tripIdByTitle(request, TOUR_TRIP);
if (!(await tourNamed(request, tripId, RECORDED_NAME)))
throw new Error(`"${RECORDED_NAME}" is missing: the fixtures could not import it`);
});
test.afterAll(async ({ request }) => {
// Instance-wide: left on, every Tours tab would offer wanderer. Here as well
// as in the guide's cleanup, so a failed run puts it back too.
await disconnectWanderer(request);
});
test('every registered tour import guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourImportContext.guides].sort());
});
// The screen as a reader meets it: the Import preview of a recorded walk over the Tours tab.
test('hero: trip-tour-import', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourImportContext.id, async (p) => {
await resetSummer(p);
await openTours(p);
await addTour(p).click();
await chooseImportFile(p);
});
});
for (const id of tripTourImportContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-385
View File
@@ -1,385 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourMapContext, tripTourMapGuides } from '../../src/help/contexts/tripTourMap';
import type { HelpGuide } from '../../src/help/types';
import {
ALONG_TOUR,
alongCard,
alongCategories,
alongCategoryList,
alongCorridor,
alongList,
alongSearch,
baseSegment,
candidateRow,
closeOverlay,
enterTerrain,
gradeBands,
gradeMix,
gradeSwitch,
MAP_TOUR,
openSkiTour,
openTourInPlanner,
overviewButton,
PADDLE_TOUR,
pauseButton,
pickAddablePlace,
pisteKeyButton,
pisteKeyPanel,
pistesToggle,
playButton,
poiAdd,
poiCard,
poiOutcome,
poiSlot,
poiUndo,
reliefSlider,
restartButton,
seamarksToggle,
searchAlong,
speedGroup,
terrainToggle,
tourRow,
waitForFlyover,
} from './fixtures-trip-tour-map';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip } from './tour-fixtures';
/**
* Actions for the map of the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourMap.ts`. They run on the Dolomites trip of the
* Tours fixtures (the Pisciadù hike for the maps and 3D, the Puez-Odle hut
* trek for the places along it, the boat tour on Lago di Braies for the
* seamarks) and on the ski week for the pistes. No guide saves anything: the
* base map, the overlays and 3D are the planner's own view, and the stop taken
* in along the tour stays an unsaved change of the draft. Drafts live on the
* account now, one per trip, so that guide discards its draft again before it
* ends, or the next guide on the Dolomites would open the hut trek restored.
*
* Every picture here draws live tiles (OpenTopoMap, OpenHikingMap, Esri,
* OpenSnowMap, OpenSeaMap, the Mapzen terrain), the places along the tour come
* from Overpass and TREK's places index, and a new stop is routed by Valhalla.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourMapGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
/** The place the add-stop guide takes in, found in its `start`: an index among the places not in the tour yet. */
let addable = 0;
/** The speed the flyover guide picks; slow enough that Pause is still there when its picture is taken. */
const SPEED = '2×';
/**
* The 3D map's own control row. The flat map's vector base map draws on a
* MapLibre canvas of its own, so a canvas alone cannot tell 3D from flat.
*/
const terrainDock = (page: Page) => page.getByTestId('terrain-control-row');
/**
* Search along the tour with Summits ticked beside the three kinds a hike
* starts with, 1 km either side. Viewpoints, huts and water are asked of
* Overpass alone, whose public mirrors are often down; summits come from
* TREK's places index as well, so the list has places even then.
*/
async function searchAlongWithSummits(page: Page): Promise<void> {
await alongCard(page).scrollIntoViewIfNeeded();
await alongCategories(page).click();
await expect(alongCategoryList(page)).toBeVisible();
await alongCategoryList(page).getByRole('button', { name: 'Summits', exact: true }).click();
await closeOverlay(page);
await expect(alongCategoryList(page)).toBeHidden();
// 250 m, 500 m, 1 km, 2 km: the third.
await alongCorridor(page).getByRole('button').nth(2).click();
await searchAlong(page);
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-map-layers': {
guide: guide('tour-map-layers'),
start: (p) => openTourTrip(p, { tab: 'tour-planner' }).then(() => undefined),
steps: [
{
target: (p) => tourRow(p, MAP_TOUR),
act: async (p) => {
await tourRow(p, MAP_TOUR).click();
await expect(tourRow(p, MAP_TOUR)).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
{
target: (p) => baseSegment(p, 'Topo'),
act: async (p) => {
await baseSegment(p, 'Topo').click();
await expect(baseSegment(p, 'Topo')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
// Only there while the admin allows OpenHikingMap: the Tours fixtures switch tours-hiking-map on.
target: (p) => baseSegment(p, 'Hiking'),
act: async (p) => {
await baseSegment(p, 'Hiking').click();
await expect(baseSegment(p, 'Hiking')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
],
},
'tour-along-search': {
guide: guide('tour-along-search'),
start: (p) => openTourInPlanner(p, ALONG_TOUR),
steps: [
{ target: (p) => alongCard(p) },
{
prepare: async (p) => {
await alongCategories(p).click();
await expect(alongCategoryList(p)).toBeVisible();
},
target: (p) => alongCategoryList(p),
act: async (p) => {
// A hike starts with Viewpoints, Huts and shelters and Drinking water; Summits joins them.
await alongCategoryList(p).getByRole('button', { name: 'Summits', exact: true }).click();
await closeOverlay(p);
await expect(alongCategoryList(p)).toBeHidden();
},
},
{
target: (p) => alongCorridor(p),
// 250 m, 500 m, 1 km, 2 km: the third.
act: (p) => alongCorridor(p).getByRole('button').nth(2).click(),
},
{
target: (p) => alongSearch(p),
act: async (p) => {
await alongSearch(p).click();
await expect(alongSearch(p)).toHaveText(/Search again/, { timeout: 90_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await expect(
alongList(p),
'the search along the tour found nothing (Overpass or the places index)'
).toBeVisible();
},
target: (p) => alongList(p),
},
],
},
'tour-along-add-stop': {
guide: guide('tour-along-add-stop'),
start: async (p) => {
await openTourInPlanner(p, ALONG_TOUR);
await searchAlongWithSummits(p);
addable = await pickAddablePlace(p);
},
steps: [
{
target: (p) => candidateRow(p, addable),
act: async (p) => {
await candidateRow(p, addable).click();
await expect(poiCard(p)).toBeVisible();
await settle(p);
},
},
{ target: (p) => poiSlot(p) },
{
target: (p) => poiAdd(p),
act: async (p) => {
await poiAdd(p).click();
// "is a stop now. The tour changes by:" once Valhalla planned it, or the line that it could not be told.
await expect(poiOutcome(p)).toHaveText(/changes by|could not be worked out/, { timeout: 90_000 });
await settle(p);
},
},
{ target: (p) => poiOutcome(p) },
],
// Nothing was saved, but the draft with the new stop sits on the account: leaving the planner and
// discarding it deletes it there, so the next guide on this trip opens the saved tours.
cleanup: async (p) => {
if (await poiUndo(p).isVisible()) await poiUndo(p).click();
const back = p.getByRole('button', { name: 'Back to Tours', exact: true });
if (!(await back.isVisible())) return;
const deleted = p
.waitForResponse((r) => r.url().includes('/tour-draft') && r.request().method() === 'DELETE', {
timeout: 15_000,
})
.catch(() => null);
await back.click();
const discard = p.getByRole('button', { name: 'Discard changes', exact: true });
// The dialog only asks while the planner holds unsaved changes; without them Back leaves at once.
await discard.waitFor({ state: 'visible', timeout: 5_000 }).then(
() => discard.click(),
() => undefined
);
await deleted;
},
},
'tour-3d-terrain': {
guide: guide('tour-3d-terrain'),
start: (p) => openTourInPlanner(p, MAP_TOUR),
steps: [
{ target: (p) => terrainToggle(p), act: (p) => enterTerrain(p) },
{
target: (p) => gradeSwitch(p),
// Into the tour's colour and back to the grades, as the step tells.
act: async (p) => {
await gradeSwitch(p).click();
await expect(gradeSwitch(p)).toHaveAttribute('aria-pressed', 'false');
await settle(p);
await gradeSwitch(p).click();
await expect(gradeSwitch(p)).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
target: (p) => gradeMix(p),
act: async (p) => {
await gradeMix(p).click();
await expect(gradeBands(p)).toBeVisible();
},
},
{
target: (p) => reliefSlider(p),
// From 1.0× to 2.0×, ten steps of 0.1: a range input is moved by its keys, not filled.
act: async (p) => {
await reliefSlider(p).focus();
for (let i = 0; i < 10; i++) await p.keyboard.press('ArrowRight');
await settle(p);
},
},
{
target: (p) => terrainToggle(p),
act: async (p) => {
await terrainToggle(p).click();
await expect(terrainDock(p)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
],
},
'tour-3d-flyover': {
guide: guide('tour-3d-flyover'),
start: async (p) => {
await openTourInPlanner(p, MAP_TOUR);
await enterTerrain(p);
await waitForFlyover(p);
await settle(p);
},
steps: [
{
target: (p) => playButton(p),
// The loop: the pointer on Play flyover, the click, then about five seconds of
// the camera following the dot. Only the browser's Date is pinned, so the
// flyover's animation frames run on their own clock.
clip: async (p, frame) => {
await playButton(p).hover();
await frame();
await frame();
await playButton(p).click();
await expect(pauseButton(p)).toBeVisible();
for (let i = 0; i < 24; i++) {
await p.waitForTimeout(200);
await frame();
}
},
},
{
target: (p) => speedGroup(p),
act: (p) => speedGroup(p).getByRole('button', { name: SPEED, exact: true }).click(),
},
{
// Back to the start and in flight again, so Pause is on the dock whatever the last step left.
prepare: async (p) => {
await restartButton(p).click();
await expect(pauseButton(p)).toBeVisible();
},
target: (p) => pauseButton(p),
act: async (p) => {
await pauseButton(p).click();
await expect(playButton(p)).toBeVisible();
},
},
{
target: (p) => overviewButton(p),
act: async (p) => {
await overviewButton(p).click();
await settle(p);
},
},
],
},
'tour-map-overlays': {
guide: guide('tour-map-overlays'),
start: async (p) => {
await openSkiTour(p);
},
steps: [
{
prepare: async (p) => {
// On by default for a ski tour; "on" rather than "down", or the key next to it is missing.
await expect(pistesToggle(p)).toHaveAttribute('data-status', 'on', { timeout: 30_000 });
},
target: (p) => pistesToggle(p),
},
{
target: (p) => pisteKeyButton(p),
act: async (p) => {
await pisteKeyButton(p).click();
await expect(pisteKeyPanel(p)).toBeVisible();
},
},
{
target: (p) => pisteKeyPanel(p),
act: async (p) => {
await closeOverlay(p);
await expect(pisteKeyPanel(p)).toBeHidden();
},
},
{
prepare: (p: Page) => openTourInPlanner(p, PADDLE_TOUR),
target: (p) => seamarksToggle(p),
act: async (p) => {
await seamarksToggle(p).click();
await expect(seamarksToggle(p)).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
],
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
// The Dolomites and the ski week with their tours, the hiking map allowed.
await ensureToursFixtures(request);
});
test('every registered tour map guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourMapContext.guides].sort());
});
test('hero: trip-tour-map', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourMapContext.id, (p) => openTourInPlanner(p, MAP_TOUR));
});
for (const id of tripTourMapContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-648
View File
@@ -1,648 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourPaddleContext, tripTourPaddleGuides } from '../../src/help/contexts/tripTourPaddle';
import type { HelpGuide } from '../../src/help/types';
import {
CARRY_COPY,
deleteToursWhere,
deleteTripsNamed,
ensureCarryCopy,
ensureWindTrip,
finishMapMove,
forgetPaddleDraft,
forgetPaddleDraftBeforeLoad,
holdPaddleDownloads,
isPaddleImport,
openToursOf,
PADDLE_TOUR,
paddleGpxFixture,
pointOnLine,
restorePaddleDownloads,
summerTripId,
WIND_TRIP,
} from './fixtures-trip-tour-paddle';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip } from './tour-fixtures';
/**
* Actions for paddle tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourPaddle.ts`. They run on "Rowing round Lago di
* Braies", the boat tour of "Summer in the Dolomites" (`ensureToursFixtures`):
* four legs drawn by hand along the shore, Boat Other, Water Lake, 3 km/h.
*
* The editing guides never save: what they change is a draft, which the
* planner keeps on the account and restores on the next page load, so
* `cleanup` discards it and every guide drops it again before it opens its
* page and after it ends, failed or not (`forgetPaddleDraft`). The checklist guide saves on a
* copy (`ensureCarryCopy`), the wind guide works in a short trip from
* tomorrow (`ensureWindTrip`), and the import guide brings in a recorded
* track; each takes its own away again, and `afterAll` does so once more for
* a run that failed half way. No water data downloads while this file runs
* (`holdPaddleDownloads`), so a leg TREK cannot plan stays unplanned and says so.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourPaddleGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let gpxFile = '';
// ── Where things are ──────────────────────────────────────────────────────────
/** The editor's left column, a section named after what it is for. */
const editorRail = (p: Page) => p.getByRole('region', { name: 'Plan a tour' });
const planNewTour = (p: Page) => p.getByRole('button', { name: 'Plan new tour', exact: true });
const typeSelect = (p: Page) => editorRail(p).getByTestId('tour-type-select');
const kayakOption = (p: Page) => p.getByTestId('tour-type-option-kayak');
const paddleFields = (p: Page) => editorRail(p).getByTestId('tour-planner-paddle-fields');
const lakeStyle = (p: Page) => editorRail(p).getByTestId('tour-planner-lake-style');
const speedInput = (p: Page) => editorRail(p).locator('#tour-planner-paddle-speed');
const tourList = (p: Page) => p.getByRole('listbox', { name: 'Tours of this trip' });
const tourRow = (p: Page, name: string | RegExp) =>
tourList(p).getByRole('option', typeof name === 'string' ? { name, exact: true } : { name });
/** A waypoint row by the name the fixture gave the point. */
const pointRow = (p: Page, name: string) =>
editorRail(p)
.getByTestId('tour-planner-waypoint-row')
.filter({ has: p.getByTestId('tour-planner-waypoint-name').getByText(name, { exact: true }) })
.first();
const moreActions = (p: Page, name: string) =>
pointRow(p, name).getByRole('button', { name: 'More actions', exact: true });
/** The point's name, inside the part of the row whose hover brings its controls out. */
const pointName = (p: Page, name: string) =>
pointRow(p, name).getByTestId('tour-planner-waypoint-name').getByText(name, { exact: true });
const menu = (p: Page) => p.getByRole('menu');
const menuItem = (p: Page, label: string) => menu(p).getByRole('button', { name: label, exact: true });
const freeLeg = (p: Page) => editorRail(p).getByTestId('tour-planner-free-leg').first();
const legFailure = (p: Page) => editorRail(p).getByTestId('tour-planner-leg-failure').first();
const routeOnWater = (p: Page) => editorRail(p).getByTestId('tour-planner-route-on-water');
const portageCard = (scope: Page | Locator) => scope.getByTestId('tour-planner-portage-leg').first();
const lockCard = (scope: Page | Locator) => scope.getByTestId('tour-planner-lock').first();
const timeCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Time estimate' });
const windCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Wind' });
const checkCard = (p: Page) => editorRail(p).getByRole('region', { name: 'Checklist' });
const safetyRow = (p: Page) => editorRail(p).getByRole('button', { name: /^Paddling has risks/ });
const safetyDialog = (p: Page) => p.getByRole('dialog', { name: 'Safety on the water' });
const addMenu = (p: Page) => p.getByRole('group', { name: 'Add tour', exact: true });
const previewDialog = (p: Page) => p.getByRole('dialog').filter({ has: p.getByTestId('gpx-preview-import') });
const marksCard = (p: Page) => p.getByTestId('track-marks-card');
// ── Getting there ─────────────────────────────────────────────────────────────
/** Open a paddle tour of the list in the editor and wait for its points. */
async function openPaddle(p: Page, name: string = PADDLE_TOUR): Promise<void> {
await expect(tourRow(p, name)).toBeVisible({ timeout: 30_000 });
await tourRow(p, name).click();
await expect(editorRail(p).getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 20_000 });
await expect(pointRow(p, 'Under the Seekofel')).toBeVisible({ timeout: 20_000 });
await finishMapMove(p);
}
async function openPaddleScreen(p: Page): Promise<void> {
await openTourTrip(p, { tab: 'tour-planner' });
await openPaddle(p);
}
/** The waypoint row's menu: its controls come out on hover, as a plan row's do. */
async function openMenu(p: Page, point: string): Promise<void> {
// Centred first, so the click on More actions needs no scroll that slides the row from under the pointer.
await pointRow(p, point).evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await pointName(p, point).hover();
await moreActions(p, point).click();
await expect(menu(p)).toBeVisible();
await settle(p);
}
/** Carrying on land asks the public walking router for the path; it answers or gives up within a minute. */
async function waitForPortageWay(p: Page): Promise<void> {
await expect(editorRail(p).getByText('Looking for a path', { exact: true })).toHaveCount(0, { timeout: 60_000 });
await settle(p);
}
async function carryFrom(p: Page, point: string): Promise<void> {
await openMenu(p, point);
await menuItem(p, 'Carry the boat from here').click();
await expect(portageCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
await waitForPortageWay(p);
}
/** A minutes field commits when it is left. */
async function typeMinutes(field: Locator, value: string): Promise<void> {
await field.fill(value);
await field.press('Enter');
}
async function openStop(card: Locator): Promise<void> {
const head = card.getByRole('button').first();
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click();
await expect(head).toHaveAttribute('aria-expanded', 'true');
}
async function lockAt(p: Page, point: string, times?: { wait: string; passage: string }): Promise<void> {
await openMenu(p, point);
await menuItem(p, 'Lock at this point').click();
await expect(lockCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
if (times) {
await openStop(lockCard(editorRail(p)));
await typeMinutes(p.getByTestId('tour-planner-lock-wait'), times.wait);
await typeMinutes(p.getByTestId('tour-planner-lock-transit'), times.passage);
}
await settle(p);
}
/** Drop what a guide changed in the editor; nothing of it was saved. */
async function discardEdits(p: Page): Promise<void> {
await p.keyboard.press('Escape').catch(() => undefined);
const discard = editorRail(p).getByRole('button', { name: 'Discard', exact: true });
if (!(await discard.isVisible().catch(() => false)) || !(await discard.isEnabled().catch(() => false))) return;
await discard.click();
const confirm = p.getByRole('button', { name: 'Discard changes', exact: true });
if (await confirm.isVisible({ timeout: 3_000 }).catch(() => false)) await confirm.click();
await expect(planNewTour(p)).toBeVisible({ timeout: 15_000 });
}
async function removeImports(p: Page): Promise<void> {
await deleteToursWhere(p.request, await summerTripId(p.request), isPaddleImport);
}
const only = (target: (p: Page) => Locator) => ({ target });
/**
* The step that rings a row's More actions button. Its controls are only in
* the layout while the pointer is on the row, and the picture centres the
* button by scrolling the rail, which slides the row out from under a pointer
* put there before. So the row is centred first, and after the scroll the
* pointer goes back onto its name and then onto the button the text names.
*/
const pointAtMoreActions = (point: string) => ({
prepare: async (p: Page) => {
await pointRow(p, point).evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }));
await pointName(p, point).hover();
},
target: (p: Page) => moreActions(p, point),
hover: async (p: Page) => {
await pointName(p, point).hover();
await moreActions(p, point).hover();
},
act: async (p: Page) => {
await moreActions(p, point).click();
await expect(menu(p)).toBeVisible();
await settle(p);
},
});
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-paddle-setup': {
guide: guide('tour-paddle-setup'),
start: async (p) => {
await openTourTrip(p, { tab: 'tour-planner' });
},
steps: [
{
target: planNewTour,
act: async (p) => {
await planNewTour(p).click();
await expect(typeSelect(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(kayakOption(p)).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Tour type', exact: true }),
hover: async (p) => {
await kayakOption(p).hover();
},
act: async (p) => {
await kayakOption(p).click();
await expect(paddleFields(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => paddleFields(p).getByRole('group', { name: 'Boat', exact: true }),
act: async (p) => {
const kayak = paddleFields(p)
.getByRole('group', { name: 'Boat', exact: true })
.getByRole('button', { name: 'Kayak', exact: true });
await kayak.click();
await expect(kayak).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
{
target: (p) => paddleFields(p).getByRole('group', { name: 'Water', exact: true }),
act: async (p) => {
await paddleFields(p)
.getByRole('group', { name: 'Water', exact: true })
.getByRole('button', { name: 'Lake', exact: true })
.click();
await expect(lakeStyle(p)).toBeVisible();
await settle(p);
},
},
{
target: lakeStyle,
act: async (p) => {
await lakeStyle(p).getByRole('button', { name: 'Near the shore', exact: true }).click();
await expect(lakeStyle(p)).toContainText('Stays near the bank');
await settle(p);
},
},
{
// The label row, the field and the line under it: the block above the input's own div.
target: (p) => speedInput(p).locator('xpath=ancestor::div[2]'),
act: async (p) => {
await speedInput(p).fill('5');
await speedInput(p).press('Enter');
await expect(paddleFields(p).getByText('Own speed', { exact: true })).toBeVisible();
await settle(p);
},
},
only((p) => editorRail(p).getByTestId('tour-planner-add-point')),
],
cleanup: discardEdits,
},
'tour-paddle-water-route': {
guide: guide('tour-paddle-water-route'),
start: openPaddleScreen,
steps: [
only(freeLeg),
pointAtMoreActions('Under the Seekofel'),
{
target: (p) => menuItem(p, 'Plan the next leg on the water'),
act: async (p) => {
await menuItem(p, 'Plan the next leg on the water').click();
// The water router answers at once; the region lookup behind it may take a few seconds.
await expect(legFailure(p)).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
{
target: legFailure,
act: async (p) => {
await legFailure(p).getByRole('button', { name: 'Draw straight', exact: true }).click();
await expect(editorRail(p).getByTestId('tour-planner-leg-failure')).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
{
target: routeOnWater,
act: async (p) => {
await routeOnWater(p).click();
await expect(legFailure(p)).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-portage': {
guide: guide('tour-paddle-portage'),
start: openPaddleScreen,
steps: [
pointAtMoreActions('South shore'),
{
target: (p) => menuItem(p, 'Carry the boat from here'),
act: async (p) => {
await menuItem(p, 'Carry the boat from here').click();
await expect(portageCard(editorRail(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => portageCard(editorRail(p)),
act: async (p) => {
await openStop(portageCard(editorRail(p)));
await expect(p.getByTestId('tour-planner-portage-way')).toBeVisible();
await settle(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-portage-way'),
act: async (p) => {
await waitForPortageWay(p);
const follow = p
.getByTestId('tour-planner-portage-way')
.getByRole('button', { name: 'Follow the path', exact: true });
if ((await follow.getAttribute('aria-pressed')) !== 'true') await follow.click();
await waitForPortageWay(p);
},
},
{
target: (p) => p.getByTestId('tour-planner-portage-extra'),
act: async (p) => {
await typeMinutes(p.getByTestId('tour-planner-portage-extra'), '15');
await expect(portageCard(editorRail(p))).toContainText('+15 min');
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-lock': {
guide: guide('tour-paddle-lock'),
start: openPaddleScreen,
steps: [
pointAtMoreActions('Chapel'),
{
target: (p) => menuItem(p, 'Lock at this point'),
act: async (p) => {
await menuItem(p, 'Lock at this point').click();
await expect(lockCard(editorRail(p))).toContainText('Time missing', { timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => lockCard(editorRail(p)),
act: async (p) => {
await openStop(lockCard(editorRail(p)));
await expect(p.getByTestId('tour-planner-lock-wait')).toBeVisible();
await settle(p);
},
},
{
target: (p) => lockCard(editorRail(p)),
act: async (p) => {
await typeMinutes(p.getByTestId('tour-planner-lock-wait'), '10');
await typeMinutes(p.getByTestId('tour-planner-lock-transit'), '5');
await expect(lockCard(editorRail(p))).toContainText('15 min');
await settle(p);
},
},
],
cleanup: discardEdits,
},
'tour-paddle-time': {
guide: guide('tour-paddle-time'),
start: async (p) => {
await openPaddleScreen(p);
// One carry and one lock without times: the card then has every row it can show, and a gap to name.
// The lock goes on a point the carry does not touch: a lock at either end of the portage is carried
// round, and its times then drop out of the estimate, so the card would name no gap.
await carryFrom(p, 'South shore');
await lockAt(p, 'Under the Seekofel');
await expect(timeCard(p)).toBeVisible();
},
steps: [
only(timeCard),
only((p) => timeCard(p).getByTestId('paddle-route-strip')),
only((p) => timeCard(p).getByTestId('paddle-time-water')),
{
target: (p) => timeCard(p).getByTestId('paddle-time-carry'),
act: async (p) => {
const chip = timeCard(p).getByTestId('paddle-time-carry-speed');
await chip.fill('2.5');
await chip.press('Enter');
await expect(chip).toHaveValue('2.5');
await settle(p);
},
},
only((p) => timeCard(p).getByText(/lock has no times yet/)),
],
cleanup: discardEdits,
},
'tour-paddle-wind': {
guide: guide('tour-paddle-wind'),
start: async (p) => {
const wind = await ensureWindTrip(p.request);
await openToursOf(p, wind.tripId);
await openPaddle(p, wind.name);
// The forecast is asked of the server, which asks Open-Meteo.
await expect(windCard(p).getByTestId('paddle-wind-hours')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
steps: [
only(windCard),
only((p) => windCard(p).getByTestId('paddle-wind-mean').locator('xpath=ancestor::div[1]')),
{
target: (p) => windCard(p).getByTestId('paddle-wind-hours'),
// A bar around midday, so its tooltip shows in the picture.
hover: async (p) => {
await windCard(p).getByTestId('paddle-wind-hours').locator(':scope > span').nth(5).hover();
},
},
{
target: (p) => windCard(p).getByRole('button', { name: 'What wind means for paddling' }),
act: async (p) => {
const fold = windCard(p).getByRole('button', { name: 'What wind means for paddling' });
await fold.click();
await expect(fold).toHaveAttribute('aria-expanded', 'true');
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteTripsNamed(p.request, WIND_TRIP);
},
},
'tour-paddle-checklist': {
guide: guide('tour-paddle-checklist'),
start: async (p) => {
await ensureCarryCopy(p.request);
await openTourTrip(p, { tab: 'tour-planner' });
await openPaddle(p, CARRY_COPY);
await carryFrom(p, 'South shore');
// Away from the portage, so the lock is passed through and not carried round.
await lockAt(p, 'Under the Seekofel', { wait: '10', passage: '5' });
await editorRail(p).getByRole('button', { name: 'Save', exact: true }).click();
// The checklist is read for the saved route: the carry and the lock come back as rows of their own.
await expect(checkCard(p).getByTestId('paddle-check-rows')).toBeVisible({ timeout: 90_000 });
await settle(p);
},
steps: [
only(checkCard),
only((p) => checkCard(p).getByTestId('paddle-check-data')),
only((p) => checkCard(p).getByTestId('paddle-check-rows')),
{
target: (p) => checkCard(p).getByTestId('paddle-check-rows').getByRole('button').first(),
act: async (p) => {
await checkCard(p).getByTestId('paddle-check-rows').getByRole('button').first().click();
await finishMapMove(p);
},
},
{
target: safetyRow,
act: async (p) => {
await safetyRow(p).click();
await expect(safetyDialog(p)).toBeVisible();
await settle(p);
await p.keyboard.press('Escape');
await expect(safetyDialog(p)).toHaveCount(0);
},
},
],
cleanup: async (p) => {
await deleteToursWhere(p.request, await summerTripId(p.request), (t) => t.name === CARRY_COPY);
},
},
'tour-paddle-track-marks': {
guide: guide('tour-paddle-track-marks'),
start: async (p) => {
await removeImports(p);
await openTourTrip(p, { tab: 'tour-planner' });
},
steps: [
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(addMenu(p)).toBeVisible();
await settle(p);
},
target: addMenu,
hover: async (p) => {
await addMenu(p).getByRole('button', { name: 'Import GPX or KML', exact: true }).hover();
},
act: async (p) => {
const chooser = p.waitForEvent('filechooser');
await addMenu(p).getByRole('button', { name: 'Import GPX or KML', exact: true }).click();
await (await chooser).setFiles(gpxFile);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await previewDialog(p).getByTestId('tour-type-select').click();
await kayakOption(p).click();
await expect(previewDialog(p).getByTestId('gpx-preview-paddle')).toBeVisible();
await settle(p);
},
target: (p) => previewDialog(p).getByTestId('gpx-preview-paddle'),
act: async (p) => {
const fields = previewDialog(p).getByTestId('gpx-preview-paddle');
await fields
.getByRole('group', { name: 'Boat', exact: true })
.getByRole('button', { name: 'Kayak', exact: true })
.click();
await fields
.getByRole('group', { name: 'Water', exact: true })
.getByRole('button', { name: 'Lake', exact: true })
.click();
await expect(previewDialog(p).getByTestId('gpx-preview-import')).toBeEnabled();
await settle(p);
},
},
{
target: (p) => previewDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
await expect(tourRow(p, /kayak round/i)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) => tourRow(p, /kayak round/i),
act: async (p) => {
await tourRow(p, /kayak round/i).click();
await expect(marksCard(p)).toBeVisible({ timeout: 30_000 });
await finishMapMove(p);
},
},
{
target: (p) => p.getByTestId('track-marks-pick-portage'),
act: async (p) => {
await p.getByTestId('track-marks-pick-portage').click();
await expect(marksCard(p)).toContainText('Click the line where you get out.');
const out = await pointOnLine(p, 0.3);
await p.mouse.click(out.x, out.y);
await expect(marksCard(p)).toContainText('Now click where you put back in.');
const back = await pointOnLine(p, 0.36);
await p.mouse.click(back.x, back.y);
await expect(portageCard(marksCard(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: (p) => p.getByTestId('track-marks-pick-lock'),
act: async (p) => {
await p.getByTestId('track-marks-pick-lock').click();
await expect(marksCard(p)).toContainText('Click the line where the lock is.');
const at = await pointOnLine(p, 0.72);
await p.mouse.click(at.x, at.y);
await expect(lockCard(marksCard(p))).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await openStop(lockCard(marksCard(p)));
await typeMinutes(marksCard(p).getByTestId('tour-planner-lock-wait'), '10');
await typeMinutes(marksCard(p).getByTestId('tour-planner-lock-transit'), '10');
await settle(p);
},
target: (p) => marksCard(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await marksCard(p).getByRole('button', { name: 'Save', exact: true }).click();
// Saved, the card has nothing left to save and its buttons go.
await expect(marksCard(p).getByRole('button', { name: 'Save', exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await settle(p);
},
},
],
cleanup: removeImports,
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await forgetPaddleDraft(request);
await holdPaddleDownloads(request);
gpxFile = await paddleGpxFixture(request);
});
test.afterAll(async ({ request }) => {
// What a guide made for itself, again here so a failed run leaves none of it in the other screens' pictures.
await deleteTripsNamed(request, WIND_TRIP).catch(() => undefined);
const tripId = await summerTripId(request).catch(() => null);
if (tripId !== null) {
await deleteToursWhere(request, tripId, (t) => t.name === CARRY_COPY || isPaddleImport(t)).catch(() => undefined);
}
await forgetPaddleDraft(request).catch(() => undefined);
await restorePaddleDownloads(request);
});
test('every registered paddle tour guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourPaddleContext.guides].sort());
});
test('hero: trip-tour-paddle', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await forgetPaddleDraftBeforeLoad(page);
await captureHero(page, tripTourPaddleContext.id, openPaddleScreen);
await forgetPaddleDraft(page.request);
});
for (const id of tripTourPaddleContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
// Every guide starts without a draft and leaves none, also when it fails before its cleanup runs.
await forgetPaddleDraftBeforeLoad(page);
try {
await captureGuide(page, SCRIPTS[id]);
} finally {
await forgetPaddleDraft(page.request).catch(() => undefined);
}
});
}
-853
View File
@@ -1,853 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { tripTourPlannerContext, tripTourPlannerGuides } from '../../src/help/contexts/tripTourPlanner';
import type { HelpGuide } from '../../src/help/types';
import { clearNotices } from '../screenshots/shot';
import {
centreOf,
closeEditor,
DRAWN_OFFSETS,
editorRail,
ensureOldPlan,
ensureSpare,
forgetPlannerState,
glide,
hutLine,
landMap,
layerPill,
mapSearch,
menuItem,
offDay,
openMenu,
openPlanner,
openRowMenu,
openSaved,
pin,
PLANNER_TOURS,
removeTour,
routeBand,
saveSpareElsewhere,
screenOf,
SEARCH_QUERY,
SHAPING_OFFSET,
shapingPin,
spotAt,
spotOnRoute,
summaryCard,
tourIdOf,
tourRow,
toursRail,
tripIdOf,
useTopo,
waitRouted,
waypointRows,
waypointsCard,
type HutLine,
} from './fixtures-trip-tour-planner';
import { captureGuide, captureHero, dismissReleaseNotice, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_NAMES, TOUR_PLACES } from './tour-fixtures';
import { confirmDialog, tripReady } from './trip-shared';
/**
* Actions for the Tours tab, the tour planner, keyed by the ids in
* `src/help/contexts/tripTourPlanner.ts`. They run on "Summer in the
* Dolomites" (`ensureToursFixtures`) plus one plain day hike of this screen's
* own, "Rifugio Firenze to Col Raiser" (`ensureSpare`), cut from the hut
* trek's line. The editing guides open that hike, change it and throw the
* change away again; the two that save put it back as it was made. A new tour
* is drawn through spots on the hut trek's line, so every leg has a path.
*
* Every route here is planned by the router the app asks (Valhalla), and the
* map search goes through the server's place search, so the run needs the
* network. The Topo map is switched on before anything is clicked onto the
* map: its raster tiles are what `screenOf` finds a spot on the screen by.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourPlannerGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let geo: HutLine | null = null;
async function line(page: Page): Promise<HutLine> {
geo ??= await hutLine(page.request, await tripIdOf(page.request));
return geo;
}
// ── Where things are ──────────────────────────────────────────────────────────
const planNew = (page: Page) => page.getByRole('button', { name: 'Plan new tour', exact: true });
const addPoint = (page: Page) => page.getByTestId('tour-planner-add-point');
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
const backToTours = (page: Page) => page.getByRole('button', { name: 'Back to Tours', exact: true });
const pointSet = (page: Page) => page.getByRole('status').filter({ hasText: 'Point set' });
const nameField = (page: Page) => page.locator('#tour-planner-name');
const description = (page: Page) => page.locator('#tour-planner-description');
const website = (page: Page) => page.locator('#tour-planner-website');
const breaks = (page: Page) => page.locator('#tour-planner-breaks');
const planned = (page: Page) => page.locator('#tour-planner-planned-duration');
const rowButton = (page: Page, index: number) =>
waypointRows(page)
.nth(index)
.getByRole('button')
.filter({ has: page.getByTestId('tour-planner-waypoint-name') });
const shapingRow = (page: Page) => waypointRows(page).filter({ hasText: 'Shaping point' }).first();
const tourTypeField = (page: Page) => page.getByTestId('tour-type-select');
const typeList = (page: Page) => page.getByRole('group', { name: 'Tour type', exact: true });
const profileGroup = (page: Page) =>
page.getByTestId('tour-planner-bike-fields').getByRole('group', { name: 'Profile' });
const placePicker = (page: Page) => page.getByTestId('trip-place-picker');
const filterButton = (page: Page) => toursRail(page).getByRole('button', { name: 'Show', exact: true });
const filterMenu = (page: Page) => toursRail(page).getByRole('group', { name: 'Show', exact: true });
const totalsCard = (page: Page) => toursRail(page).getByRole('region', { name: 'Trip totals', exact: true });
const remoteNotice = (page: Page) => page.getByTestId('tour-remote-change');
// The dialog takes its name from the eyebrow above the title ("Not saved"), so it is found by its heading.
const conflictDialog = (page: Page) =>
page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: 'Changed in the meantime', exact: true }) });
/** A place of the trip in the picker, by its own name: a tour named after the place would match a substring too. */
const placeCandidate = (page: Page, name: string) =>
placePicker(page)
.getByTestId('trip-place-candidate')
.filter({ has: page.getByText(name, { exact: true }) });
// ── Getting there ─────────────────────────────────────────────────────────────
/** The trip on its Plan tab, for the one guide that starts by switching to Tours. */
async function openPlanTab(page: Page): Promise<void> {
const tripId = await tripIdOf(page.request);
await forgetPlannerState(page);
await page.goto(`/trips/${tripId}`);
await clearNotices(page);
await dismissReleaseNotice(page);
await tripReady(page);
await settle(page);
}
/** The details are open by default and remembered; a guide that types into them makes sure. */
async function openDetails(page: Page): Promise<void> {
const show = page.getByRole('button', { name: 'Show details', exact: true });
if (await show.isVisible().catch(() => false)) await show.click();
await expect(description(page)).toBeVisible();
}
/** Plan new tour, on the Topo map. */
async function startDraft(page: Page): Promise<void> {
await planNew(page).click();
await expect(page.getByRole('heading', { name: 'New tour', exact: true })).toBeVisible();
await useTopo(page);
}
/** Pick the map search's answer that lies in the Dolomites; the first one when none says so. */
async function searchTheHut(page: Page): Promise<void> {
await typeInto(page, mapSearch(page), SEARCH_QUERY);
const options = page.getByRole('listbox', { name: 'Search place or address' }).getByRole('option');
await expect(options.first(), 'the map search found nothing for the hut').toBeVisible({ timeout: 30_000 });
const local = options.filter({
hasText: /Santa Cristina|Gardena|Gröden|Bolzano|Bozen|Südtirol|Alto Adige|Trentino/i,
});
await ((await local.count()) ? local.first() : options.first()).click();
await landMap(page);
}
/** Click spot `i` of the drawing onto the map and wait for its row. */
async function clickSpot(page: Page, i: number): Promise<void> {
const at = await screenOf(page, spotAt(await line(page), DRAWN_OFFSETS[i]));
await page.mouse.click(at.x, at.y);
await expect(waypointRows(page)).toHaveCount(i + 1, { timeout: 15_000 });
}
/** A new tour of four points, drawn and routed, the way the drawing guide draws it. */
async function drawDraft(page: Page): Promise<void> {
await startDraft(page);
await searchTheHut(page);
for (let i = 0; i < DRAWN_OFFSETS.length; i++) {
await clickSpot(page, i);
if (i > 0) await waitRouted(page);
}
}
/** The Tours tab with the spare hike open in the editor. */
async function openSpare(page: Page): Promise<void> {
await openPlanner(page);
await openSaved(page, PLANNER_TOURS.spare);
}
/** Where along the hut trek the spare's stop `k` (from 0) lies, in metres from the hut. */
function stopOffset(g: HutLine, k: number): number {
const length = g.line.length - g.hut;
const cut = [0, Math.round(length / 3), Math.round((2 * length) / 3), length - 1][k];
return g.along[g.hut + cut] - g.along[g.hut];
}
/** Drag the mouse from one point to another in small steps, for a pin Leaflet does not drag itself. */
async function dragBetween(page: Page, from: { x: number; y: number }, to: { x: number; y: number }): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + 6, from.y + 6, { steps: 3 });
await page.mouse.move(to.x, to.y, { steps: 16 });
await page.mouse.up();
}
async function backToNeutral(page: Page): Promise<void> {
await closeEditor(page);
await settle(page);
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-open-workspace': {
guide: guide('tour-open-workspace'),
start: openPlanTab,
steps: [
{
// The trip's own tab bar; the Places | Tours switch of the plan is a tablist of tabs, not buttons.
target: (p) => p.getByRole('button', { name: 'Tours', exact: true }).first(),
act: async (p) => {
await p.getByRole('button', { name: 'Tours', exact: true }).first().click();
await expect(planNew(p)).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await landMap(p);
},
},
{ target: planNew },
{ target: toursRail },
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(p.getByRole('group', { name: 'Add tour', exact: true })).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Add tour', exact: true }),
act: async (p) => {
await p.keyboard.press('Escape');
if (
await p
.getByRole('group', { name: 'Add tour', exact: true })
.isVisible()
.catch(() => false)
) {
await p.getByTestId('tour-rail-add').click();
}
await expect(p.getByRole('group', { name: 'Add tour', exact: true })).toHaveCount(0);
},
},
{ target: mapSearch },
{ target: layerPill },
{
target: (p) => tourRow(p, TOUR_NAMES.recorded),
act: async (p) => {
await tourRow(p, TOUR_NAMES.recorded).click();
await expect(p.getByRole('heading', { name: 'GPX tour', exact: true })).toBeVisible({ timeout: 30_000 });
await landMap(p);
},
},
],
cleanup: backToNeutral,
},
'tour-draw-route': {
guide: guide('tour-draw-route'),
start: openPlanner,
steps: [
{ target: planNew, act: startDraft },
{ target: mapSearch, act: searchTheHut },
{
target: addPoint,
act: async (p) => {
await expect(addPoint(p)).toHaveAttribute('aria-pressed', 'true');
await clickSpot(p, 0);
},
},
// Up for four seconds after a click, and held while the pointer rests on it.
{ target: pointSet },
{
target: (p) => pin(p, 1),
clip: async (p, frame) => {
for (let i = 1; i < DRAWN_OFFSETS.length; i++) {
const from = await centreOf(pin(p, i));
const to = await screenOf(p, spotAt(await line(p), DRAWN_OFFSETS[i]));
await glide(p, { x: from.x + 20, y: from.y + 20 }, to, frame);
await p.mouse.click(to.x, to.y);
await expect(waypointRows(p)).toHaveCount(i + 1, { timeout: 15_000 });
// The route arrives while the frames run: a beat each.
for (let k = 0; k < 6; k++) {
await p.waitForTimeout(300);
await frame();
}
await waitRouted(p);
await frame();
}
for (let k = 0; k < 3; k++) await frame();
},
},
{ prepare: waitRouted, target: waypointsCard },
],
cleanup: backToNeutral,
},
'tour-type-profile': {
guide: guide('tour-type-profile'),
start: async (p) => {
await openPlanner(p);
await openSaved(p, TOUR_NAMES.roadBike);
},
steps: [
{
target: tourTypeField,
act: async (p) => {
await tourTypeField(p).click();
await expect(typeList(p)).toBeVisible();
},
},
{
target: typeList,
act: async (p) => {
// The tour stays a bike tour: picking its own type closes the list and changes nothing.
await typeList(p).getByTestId('tour-type-option-bike').click();
await expect(typeList(p)).toHaveCount(0);
await settle(p);
},
},
{
target: profileGroup,
act: async (p) => {
await profileGroup(p).getByRole('button', { name: 'Touring', exact: true }).click();
await waitRouted(p);
},
},
{ target: summaryCard },
],
cleanup: backToNeutral,
},
'tour-edit-points': {
guide: guide('tour-edit-points'),
start: openSpare,
steps: [
{
target: (p) => pin(p, 2),
act: async (p) => {
const g = await line(p);
const from = await centreOf(pin(p, 2));
const to = await screenOf(p, spotAt(g, stopOffset(g, 1) + 150));
await dragBetween(p, from, to);
await waitRouted(p);
},
},
{
target: (p) => rowButton(p, 2),
act: async (p) => {
await rowButton(p, 2).click();
await settle(p);
},
},
{
prepare: async (p) => {
await waypointRows(p).nth(2).hover();
},
target: (p) => waypointRows(p).nth(2).getByTestId('tour-planner-waypoint-drag-handle'),
dropTo: (p) => waypointRows(p).nth(1),
act: async (p) => {
await waypointRows(p)
.nth(2)
.getByTestId('tour-planner-waypoint-drag-handle')
.dragTo(waypointRows(p).nth(1), { targetPosition: { x: 60, y: 4 } });
await waitRouted(p);
},
},
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Move waypoint down').click();
await waitRouted(p);
},
},
{
prepare: async (p) => {
await waypointRows(p).nth(2).hover();
},
target: (p) => waypointRows(p).nth(2).getByRole('button', { name: 'Remove waypoint', exact: true }),
act: async (p) => {
await waypointRows(p).nth(2).getByRole('button', { name: 'Remove waypoint', exact: true }).click();
await expect(waypointRows(p)).toHaveCount(3);
await waitRouted(p);
},
},
{
target: (p) => p.getByRole('button', { name: 'Undo waypoint edit', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Undo waypoint edit', exact: true }).click();
await expect(waypointRows(p)).toHaveCount(4);
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-shaping-points': {
guide: guide('tour-shaping-points'),
start: async (p) => {
await openSpare(p);
// On the Leaflet map a click on the route reaches the map as well, so with Add point on it
// also appends a new end on the same spot, whose pin then covers the shaping point. With
// the tool off the click only shapes the route, as the guide says. The tool is remembered
// in this page's session storage only, so the next guide finds it on again.
if ((await addPoint(p).getAttribute('aria-pressed')) === 'true') await addPoint(p).click();
await expect(addPoint(p)).toHaveAttribute('aria-pressed', 'false');
await settle(p);
},
steps: [
{
target: routeBand,
hover: async (p) => {
const at = await spotOnRoute(p, 0.1);
await p.mouse.move(at.x, at.y);
},
act: async (p) => {
const at = await spotOnRoute(p, 0.1);
await p.mouse.click(at.x, at.y);
await expect(shapingPin(p)).toHaveCount(1, { timeout: 15_000 });
await expect(waypointRows(p)).toHaveCount(5);
await waitRouted(p);
},
},
{
target: shapingPin,
// A pin's tooltip or a neighbour can sit over the dot's edge; its centre is where the hand goes.
hover: async (p) => {
const at = await centreOf(shapingPin(p));
await p.mouse.move(at.x, at.y);
},
clip: async (p, frame) => {
const from = await centreOf(shapingPin(p));
const to = await screenOf(p, spotAt(await line(p), SHAPING_OFFSET));
await glide(p, { x: from.x - 30, y: from.y + 20 }, from, frame, 4);
await p.mouse.down();
await p.mouse.move(from.x + 6, from.y + 6, { steps: 3 });
await frame();
await glide(p, { x: from.x + 6, y: from.y + 6 }, to, frame, 16);
await p.mouse.up();
for (let k = 0; k < 6; k++) {
await p.waitForTimeout(300);
await frame();
}
await waitRouted(p);
for (let k = 0; k < 3; k++) await frame();
},
},
{ target: shapingRow },
{
prepare: (p) => openRowMenu(p, shapingRow(p)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Make it a stop').click();
await expect(waypointRows(p).filter({ hasText: 'Shaping point' })).toHaveCount(0);
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-loop-reverse': {
guide: guide('tour-loop-reverse'),
start: openSpare,
steps: [
{
target: (p) => p.getByTestId('tour-planner-return-to-start'),
act: async (p) => {
await p.getByTestId('tour-planner-return-to-start').click();
await expect(waypointRows(p)).toHaveCount(5);
await waitRouted(p);
},
},
{ target: (p) => waypointRows(p).last() },
{
target: (p) => p.getByTestId('tour-planner-reverse'),
act: async (p) => {
await p.getByTestId('tour-planner-reverse').click();
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-leg-by-hand': {
guide: guide('tour-leg-by-hand'),
start: openSpare,
steps: [
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Draw the next leg by hand').click();
await expect(p.getByTestId('tour-planner-free-leg')).toBeVisible();
await waitRouted(p);
},
},
{ target: (p) => p.getByTestId('tour-planner-free-leg') },
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: openMenu,
act: async (p) => {
await menuItem(p, 'Route the next leg again').click();
await expect(p.getByTestId('tour-planner-free-leg')).toHaveCount(0);
await waitRouted(p);
},
},
{
prepare: (p) => openRowMenu(p, waypointRows(p).nth(1)),
target: (p) => menuItem(p, 'Plan the next leg again'),
act: async (p) => {
await menuItem(p, 'Plan the next leg again').click();
await waitRouted(p);
},
},
],
cleanup: backToNeutral,
},
'tour-trip-places': {
guide: guide('tour-trip-places'),
start: openSpare,
steps: [
{
target: (p) => p.getByTestId('tour-planner-add-place'),
act: async (p) => {
await p.getByTestId('tour-planner-add-place').click();
await expect(placePicker(p)).toBeVisible();
await settle(p);
},
},
{ target: placePicker },
{
target: (p) => placeCandidate(p, TOUR_PLACES.firenze),
act: async (p) => {
await placeCandidate(p, TOUR_PLACES.firenze).click();
await expect(waypointRows(p)).toHaveCount(5);
await p.keyboard.press('Escape');
await waitRouted(p);
},
},
{ target: (p) => waypointRows(p).last().getByTestId('tour-planner-waypoint-linked') },
],
cleanup: backToNeutral,
},
'tour-details-times': {
guide: guide('tour-details-times'),
start: async (p) => {
await openSpare(p);
await openDetails(p);
},
steps: [
{ target: nameField },
{
target: description,
act: async (p) => {
await typeInto(
p,
description(p),
'From the hut across the Cisles meadows and down to the gondola. Last ride down at 17:30.'
);
await typeInto(p, website(p), 'https://www.valgardena.it/en/');
await settle(p);
},
},
{
target: breaks,
act: async (p) => {
await typeInto(p, breaks(p), '0:45');
await breaks(p).press('Tab');
await settle(p);
},
},
{
target: planned,
act: async (p) => {
await typeInto(p, planned(p), '3:30');
await planned(p).press('Enter');
await expect(p.getByTestId('tour-planner-planned-manual')).toBeVisible();
await settle(p);
},
},
],
cleanup: backToNeutral,
},
'tour-save': {
guide: guide('tour-save'),
start: async (p) => {
await openPlanner(p);
await drawDraft(p);
},
steps: [
{
target: nameField,
act: async (p) => {
await typeInto(p, nameField(p), PLANNER_TOURS.drawn);
await settle(p);
},
},
{ target: (p) => p.getByRole('heading', { name: 'New tour', exact: true }), act: waitRouted },
{
target: saveButton,
act: async (p) => {
await expect(saveButton(p)).toBeEnabled({ timeout: 30_000 });
await saveButton(p).click();
await expect(p.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{ target: (p) => tourRow(p, PLANNER_TOURS.drawn) },
],
cleanup: async (p) => {
await backToNeutral(p);
await removeTour(p.request, await tripIdOf(p.request), PLANNER_TOURS.drawn);
},
},
'tour-edit-saved': {
guide: guide('tour-edit-saved'),
start: async (p) => {
await ensureSpare(p.request, { reset: true });
await openPlanner(p);
},
steps: [
{
target: (p) => tourRow(p, PLANNER_TOURS.spare),
act: async (p) => {
await openSaved(p, PLANNER_TOURS.spare);
await openDetails(p);
},
},
{
target: description,
act: async (p) => {
await typeInto(
p,
description(p),
'From the hut across the Cisles meadows to the Col Raiser gondola, about an hour and a half.'
);
await settle(p);
},
},
{
target: saveButton,
act: async (p) => {
await expect(saveButton(p)).toBeEnabled({ timeout: 30_000 });
await saveButton(p).click();
await expect(p.getByText('Tour saved').first()).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: backToTours,
act: async (p) => {
await backToTours(p).click();
await expect(planNew(p)).toBeVisible();
await settle(p);
},
},
],
cleanup: async (p) => {
await backToNeutral(p);
await ensureSpare(p.request, { reset: true });
},
},
'tour-summary-profile': {
guide: guide('tour-summary-profile'),
start: openSpare,
steps: [
{ target: summaryCard },
{ target: (p) => summaryCard(p).getByRole('button', { name: 'How the walking time is estimated', exact: true }) },
{ target: (p) => p.locator('#tour-planner-editable-elevation-profile') },
{
target: (p) => p.getByRole('button', { name: 'Collapse elevation profile', exact: true }),
act: async (p) => {
await p.getByRole('button', { name: 'Collapse elevation profile', exact: true }).click();
await expect(p.getByRole('button', { name: 'Expand elevation profile', exact: true })).toBeVisible();
},
},
],
cleanup: async (p) => {
const expand = p.getByRole('button', { name: 'Expand elevation profile', exact: true });
if (await expand.isVisible().catch(() => false)) await expand.click();
await backToNeutral(p);
},
},
'tour-list-manage': {
guide: guide('tour-list-manage'),
start: openPlanner,
steps: [
{
target: totalsCard,
act: async (p) => {
await totalsCard(p).getByTestId('tour-scope-toggle').click();
await expect(totalsCard(p).getByTestId('tour-scope-hike')).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
await tourRow(p, TOUR_NAMES.mtb).hover();
},
target: (p) => toursRail(p).getByRole('button', { name: `Add to day: ${TOUR_NAMES.mtb}`, exact: true }),
act: async (p) => {
await toursRail(p)
.getByRole('button', { name: `Add to day: ${TOUR_NAMES.mtb}`, exact: true })
.click();
await expect(openMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: openMenu,
act: async (p) => {
await openMenu(p)
.getByRole('button', { name: /^Day 10\b/ })
.click();
await expect(tourRow(p, TOUR_NAMES.mtb).getByTestId('tour-days-badge')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
{
target: filterButton,
act: async (p) => {
await filterButton(p).click();
await expect(filterMenu(p)).toBeVisible();
},
},
{
target: filterMenu,
act: async (p) => {
await filterMenu(p)
.getByRole('button', { name: /^Unplanned/ })
.click();
await p.keyboard.press('Escape');
await expect(filterMenu(p)).toHaveCount(0);
await settle(p);
},
},
],
cleanup: async (p) => {
const tripId = await tripIdOf(p.request);
const mtb = await tourIdOf(p.request, tripId, TOUR_NAMES.mtb);
if (mtb) await offDay(p.request, tripId, 9, mtb);
await forgetPlannerState(p);
},
},
'tour-delete': {
guide: guide('tour-delete'),
start: async (p) => {
await ensureOldPlan(p.request);
await openPlanner(p);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, PLANNER_TOURS.old).hover();
},
target: (p) => toursRail(p).getByRole('button', { name: `Delete ${PLANNER_TOURS.old}`, exact: true }),
act: async (p) => {
await toursRail(p)
.getByRole('button', { name: `Delete ${PLANNER_TOURS.old}`, exact: true })
.click();
await expect(confirmDialog(p)).toBeVisible();
await settle(p);
},
},
{
target: confirmDialog,
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Delete permanently', exact: true }).click();
await expect(tourRow(p, PLANNER_TOURS.old)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await removeTour(p.request, await tripIdOf(p.request), PLANNER_TOURS.old);
await forgetPlannerState(p);
},
},
'tour-saved-elsewhere': {
guide: guide('tour-saved-elsewhere'),
start: async (p) => {
await ensureSpare(p.request, { reset: true });
await openSpare(p);
await openDetails(p);
await typeInto(p, description(p), 'From the hut across the Cisles meadows, with lunch at the hut first.');
// Someone else saves the same tour meanwhile; the websocket brings the newer revision in.
await saveSpareElsewhere(p.request);
await expect(remoteNotice(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
steps: [
{ target: remoteNotice },
{ target: (p) => remoteNotice(p).getByRole('button', { name: 'Load newer', exact: true }) },
{
target: saveButton,
act: async (p) => {
await saveButton(p).click();
await expect(conflictDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: conflictDialog,
act: async (p) => {
await p.getByTestId('tour-conflict-reload').click();
await expect(conflictDialog(p)).toHaveCount(0);
await waitRouted(p);
},
},
],
cleanup: async (p) => {
await backToNeutral(p);
await ensureSpare(p.request, { reset: true });
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await ensureSpare(request, { reset: true });
const tripId = await tripIdOf(request);
await request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
});
test.afterAll(async ({ request }) => {
// The spare and what the guides made from it stay out of every other screen's pictures.
const tripId = await tripIdOf(request);
for (const name of Object.values(PLANNER_TOURS)) await removeTour(request, tripId, name);
await request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
});
test('every registered tour planner guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourPlannerContext.guides].sort());
});
test('hero: trip-tour-planner', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourPlannerContext.id, openPlanner);
});
for (const id of tripTourPlannerContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-568
View File
@@ -1,568 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourSkiContext, tripTourSkiGuides } from '../../src/help/contexts/tripTourSki';
import type { HelpGuide } from '../../src/help/types';
import { useTopo } from './fixtures-trip-tour-planner';
import {
deleteSkiTours,
forgetSkiDraft,
PLANNED_SKI_TOUR,
SKI_POINTS,
SKI_TRACK,
skiTrackFixture,
} from './fixtures-trip-tour-ski';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for Ski & winter tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourSki.ts`. They run on the ski week of the Tours
* demo data ("Ski week in Val Gardena", `ensureToursFixtures`): the planner
* guides start a new tour around Selva and plan it on the piste router, the
* map guide opens the saved tours, the import guide brings in a recorded
* cross-country track (`fixtures-trip-tour-ski.ts`). An unsaved draft is kept
* on the account and opened again on the next page load, so every guide starts
* by forgetting it and every test forgets it again when it ends, passed or not;
* what a guide saves is taken away in its `cleanup`.
*
* Every leg here is the piste router's (POST /api/trips/:id/ski/route over the
* TREK Places winter data): without it the legs fail and the guides that read
* planned legs fail with them, loudly, at the step that waits for the legs.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourSkiGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
// ── Where things are ──────────────────────────────────────────────────────────
/** The left rail, neutral or with a tour open: both are the section "Plan a tour". */
const leftRail = (page: Page) => page.locator('section[aria-label="Plan a tour"]').first();
/** The right rail, "Tours of this trip". */
const rightRail = (page: Page) => page.locator('aside[aria-label="Tours of this trip"]').first();
const typeSelect = (page: Page) => leftRail(page).getByTestId('tour-type-select');
const skiFields = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-fields');
const disciplines = (scope: Locator) => scope.getByRole('group', { name: 'Discipline' });
const skiOptions = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-options');
const addPoint = (page: Page) => leftRail(page).getByTestId('tour-planner-add-point');
const legSteps = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-leg-steps');
const legFailures = (page: Page) => leftRail(page).getByTestId('tour-planner-ski-leg-failure');
/** A failure row the router reports, not the calm "summer paths" note. */
const legProblem = (page: Page) => legFailures(page).filter({ has: page.getByRole('alert') });
const liftClasses = (page: Page) => skiOptions(page).getByRole('group', { name: 'Leave out' });
const pisteRoute = (page: Page) => leftRail(page).locator('section[aria-label="Piste route"]');
const overview = (page: Page) => rightRail(page).getByTestId('tour-planner-overview');
const tourRow = (page: Page, name: string | RegExp) => rightRail(page).getByRole('option', { name }).first();
const importDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Import preview' });
const classifyDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Classify ski track' });
const LIFT_LABELS = ['Surface lifts', 'Open chairlifts', 'Gondolas', 'Cable cars', 'Funiculars and trains'];
// ── Getting there ─────────────────────────────────────────────────────────────
/**
* The ski week on its Tours tab, with the map over Val Gardena. The planner
* map frames, with nothing open, the area the plan map showed last, and that
* lives in memory only: so the plan opens on day 2 (the Sella Ronda) first and
* the Tours tab is clicked rather than loaded. A draft left on the account
* would open in the editor instead of the neutral rail, so it is forgotten
* before the page loads.
*
* With `topo`, the map is switched to Topo. The default Map is a vector style
* drawn on a canvas and has no tile images, and the pistes overlay's tiles come
* slowly or not at all, so the guides that click points on the map need the
* Topo raster to find them by (see `toScreen`).
*/
async function openSkiPlanner(page: Page, opts: { topo?: boolean } = {}): Promise<void> {
await forgetSkiDraft(page);
await openTourTrip(page, { trip: 'ski', day: 2 });
await page.getByRole('button', { name: 'Tours', exact: true }).first().click();
await expect(leftRail(page)).toBeVisible({ timeout: 30_000 });
await expect(rightRail(page)).toBeVisible();
await expect(
leftRail(page).getByRole('button', { name: 'Plan new tour' }),
'the editor opened on a leftover draft'
).toBeVisible({
timeout: 30_000,
});
if (opts.topo) await useTopo(page);
await settle(page);
}
/** A new tour of type Ski & winter, its discipline picked. */
async function newSkiDraft(page: Page, discipline = 'Alpine'): Promise<void> {
await leftRail(page).getByRole('button', { name: 'Plan new tour' }).click();
await pickSkiType(page);
await disciplines(skiFields(page)).getByRole('button', { name: discipline, exact: true }).click();
await expect(skiOptions(page)).toBeVisible();
}
async function pickSkiType(page: Page): Promise<void> {
await typeSelect(page).click();
await page.getByTestId('tour-type-option-ski').click();
await expect(skiFields(page)).toBeVisible();
}
/** Set points on: it is on by default in 2D, and a press turns it off, so only press when it is off. */
async function setPointsOn(page: Page): Promise<void> {
if ((await addPoint(page).getAttribute('aria-pressed')) !== 'true') await addPoint(page).click();
await expect(addPoint(page)).toHaveAttribute('aria-pressed', 'true');
}
/** Click the points on the map, then wait until every leg is planned (or, with `expectFailure`, one failed). */
async function placePoints(page: Page, points: [number, number][], expectFailure = false): Promise<void> {
await setPointsOn(page);
const at = await frameOn(page, points);
for (const p of at) {
await page.mouse.click(p.x, p.y);
await page.waitForTimeout(700);
}
if (expectFailure) {
await expect(legProblem(page).first()).toBeVisible({ timeout: 180_000 });
} else {
await expect(legSteps(page)).toHaveCount(points.length - 1, { timeout: 180_000 });
await expect(legProblem(page)).toHaveCount(0);
}
await settle(page);
}
/** The whole plan guide up to its last step, for the hero and the summary guide. */
async function plannedSkiDraft(page: Page): Promise<void> {
await openSkiPlanner(page, { topo: true });
await newSkiDraft(page);
await placePoints(page, SKI_POINTS);
await leftRail(page).locator('#tour-planner-name').fill(PLANNED_SKI_TOUR);
await settle(page);
}
/** Open a saved ski tour of the trip from the list: the Sella Ronda, or the winter walk where the router left it out. */
async function openSavedSkiTour(page: Page): Promise<void> {
const ronda = tourRow(page, new RegExp(`^${TOUR_NAMES.skiRonda}`));
const row = (await ronda.count()) ? ronda : tourRow(page, new RegExp(`^${TOUR_NAMES.winterWalk}`));
await row.click();
await expect(skiFields(page)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/** Back to the list, throwing a draft away. */
async function leaveEditor(page: Page): Promise<void> {
const discard = leftRail(page).getByRole('button', { name: 'Discard', exact: true });
if (!(await discard.isVisible().catch(() => false)) || !(await discard.isEnabled())) return;
await discard.click();
const confirm = confirmDialog(page).getByRole('button', { name: 'Discard changes' });
if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click();
}
// ── The map, by its tiles ─────────────────────────────────────────────────────
type ScreenPoint = { x: number; y: number };
/**
* Where a coordinate is on the screen. The Leaflet map exposes no handle, but
* its tiles say where they are: a loaded tile's src names its z/x/y, and its box
* is where that tile sits, so the web-mercator pixel of any point follows from
* one of them. Off-screen points come out off-screen, which is what `frameOn`
* checks.
*/
async function toScreen(page: Page, points: [number, number][]): Promise<ScreenPoint[]> {
// Tiles still on their way after a pan or a zoom: wait for one before measuring.
await expect
.poll(
() =>
page.evaluate(
() =>
Array.from(
document.querySelectorAll<HTMLImageElement>('.leaflet-tile-pane img.leaflet-tile-loaded')
).filter(
(img) =>
/\/\d+\/\d+\/\d+(?:@2x)?\.(?:png|jpe?g|webp)/.test(img.src) && img.getBoundingClientRect().width > 0
).length
),
{ message: 'no loaded map tile to measure the map by: is the Topo map on?', timeout: 30_000 }
)
.toBeGreaterThan(0);
return page.evaluate((pts) => {
const tiles = Array.from(document.querySelectorAll<HTMLImageElement>('.leaflet-tile-pane img.leaflet-tile-loaded'))
.map((img) => {
const m = /\/(\d+)\/(\d+)\/(\d+)(?:@2x)?\.(?:png|jpe?g|webp)/.exec(img.src);
return m ? { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]), box: img.getBoundingClientRect() } : null;
})
.filter((t): t is { z: number; x: number; y: number; box: DOMRect } => !!t && t.box.width > 0);
if (!tiles.length) throw new Error('no loaded map tile to measure the map by');
const counts = new Map<number, number>();
for (const t of tiles) counts.set(t.z, (counts.get(t.z) ?? 0) + 1);
const z = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
const tile = tiles.find((t) => t.z === z)!;
const scale = tile.box.width / 256;
const world = (lat: number, lng: number) => {
const n = 256 * 2 ** z;
const s = Math.sin((lat * Math.PI) / 180);
return { x: ((lng + 180) / 360) * n, y: (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n };
};
return pts.map(([lat, lng]) => {
const w = world(lat, lng);
return { x: tile.box.left + (w.x - tile.x * 256) * scale, y: tile.box.top + (w.y - tile.y * 256) * scale };
});
}, points);
}
/** The part of the map between the two rails and above the layer row, with a margin. */
async function openMapArea(page: Page): Promise<{ left: number; right: number; top: number; bottom: number }> {
const map = await page.locator('#trek-map, .leaflet-container').first().boundingBox();
const left = await leftRail(page).boundingBox();
const right = await rightRail(page).boundingBox();
if (!map) throw new Error('the tour map has no box');
return {
left: Math.max(map.x, (left?.x ?? 0) + (left?.width ?? 0)) + 70,
right: Math.min(map.x + map.width, right?.x ?? VIEWPORT.width) - 70,
top: map.y + 90,
bottom: map.y + map.height - 130,
};
}
/**
* Zoom the map until every point is in its open part and they are far enough
* apart to click: out around the middle while one is outside, in around the
* points while they huddle. The wheel is the one zoom this map has.
*/
async function frameOn(page: Page, points: [number, number][]): Promise<ScreenPoint[]> {
let zoomedIn = false;
let backedOut = false;
for (let round = 0; round < 16; round++) {
const area = await openMapArea(page);
const at = await toScreen(page, points);
const inside = at.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom);
const xs = at.map((p) => p.x);
const ys = at.map((p) => p.y);
const spread = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
if (inside && (spread > 380 || backedOut)) return at;
if (!inside) {
await page.mouse.move((area.left + area.right) / 2, (area.top + area.bottom) / 2);
await page.mouse.wheel(0, 240);
if (zoomedIn) {
await page.waitForTimeout(900);
await settle(page);
zoomedIn = false;
backedOut = true;
const back = await toScreen(page, points);
if (back.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom)) return back;
continue;
}
} else {
const cx = xs.reduce((s, x) => s + x, 0) / xs.length;
const cy = ys.reduce((s, y) => s + y, 0) / ys.length;
await page.mouse.move(cx, cy);
await page.mouse.wheel(0, -240);
zoomedIn = true;
}
await page.waitForTimeout(900);
await settle(page);
}
const area = await openMapArea(page);
const at = await toScreen(page, points);
if (!at.every((p) => p.x > area.left && p.x < area.right && p.y > area.top && p.y < area.bottom)) {
throw new Error('the ski points could not be brought into the open part of the map');
}
return at;
}
// ── The guides ────────────────────────────────────────────────────────────────
const SCRIPTS: Record<string, GuideScript> = {
'tour-ski-plan': {
guide: guide('tour-ski-plan'),
start: (p) => openSkiPlanner(p, { topo: true }),
steps: [
{
target: (p) => leftRail(p).getByRole('button', { name: 'Plan new tour' }),
act: async (p) => {
await leftRail(p).getByRole('button', { name: 'Plan new tour' }).click();
await expect(typeSelect(p)).toBeVisible();
},
},
{
prepare: async (p) => {
await typeSelect(p).click();
await expect(p.getByTestId('tour-type-option-ski')).toBeVisible();
},
target: (p) => p.getByTestId('tour-type-option-ski'),
act: async (p) => {
await p.getByTestId('tour-type-option-ski').click();
await expect(skiFields(p)).toBeVisible();
},
},
{
target: (p) => disciplines(skiFields(p)),
act: async (p) => {
await disciplines(skiFields(p)).getByRole('button', { name: 'Alpine', exact: true }).click();
await expect(skiOptions(p)).toBeVisible();
},
},
{
target: addPoint,
act: async (p) => placePoints(p, SKI_POINTS),
},
{ target: (p) => legSteps(p).first() },
{
prepare: async (p) => {
await leftRail(p).locator('#tour-planner-name').fill(PLANNED_SKI_TOUR);
},
target: (p) => leftRail(p).getByRole('button', { name: 'Save', exact: true }),
act: async (p) => {
await leftRail(p).getByRole('button', { name: 'Save', exact: true }).click();
await expect(p.getByText('Tour saved').first()).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, new RegExp(`^${PLANNED_SKI_TOUR}`))).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteSkiTours(p, PLANNED_SKI_TOUR);
},
},
'tour-ski-options': {
guide: guide('tour-ski-options'),
start: async (p) => {
await openSkiPlanner(p);
await newSkiDraft(p);
},
steps: [
{ target: skiOptions },
{
target: (p) => p.locator('#tour-planner-ski-difficulty'),
act: async (p) => {
await p.locator('#tour-planner-ski-difficulty').click();
await p
.getByRole('group', { name: 'Hardest runs' })
.getByRole('button', { name: 'Advanced', exact: true })
.click();
await expect(p.locator('#tour-planner-ski-difficulty')).toContainText('Advanced');
},
},
{
target: (p) => skiOptions(p).getByRole('group', { name: 'Prefer' }),
act: async (p) => {
await skiOptions(p)
.getByRole('group', { name: 'Prefer' })
.getByRole('button', { name: 'Most skiing' })
.click();
await skiOptions(p).getByRole('group', { name: 'Pace' }).getByRole('button', { name: 'Relaxed' }).click();
},
},
{
// The first switch row: Ride lifts, the one every discipline has.
target: (p) => skiOptions(p).getByRole('button', { name: 'Ride lifts', exact: true }).locator('xpath=..'),
hover: async (p) => skiOptions(p).getByRole('button', { name: 'Ride lifts', exact: true }).hover(),
},
{
target: liftClasses,
act: async (p) => {
await liftClasses(p).getByRole('button', { name: 'Surface lifts' }).click();
await expect(liftClasses(p).getByRole('button', { name: 'Surface lifts' })).toHaveAttribute(
'aria-pressed',
'true'
);
},
},
],
cleanup: leaveEditor,
},
'tour-ski-fix-leg': {
guide: guide('tour-ski-fix-leg'),
// Every kind of lift left out, so the leg up from Selva has no way to go.
start: async (p) => {
await openSkiPlanner(p, { topo: true });
await newSkiDraft(p);
for (const name of LIFT_LABELS) await liftClasses(p).getByRole('button', { name }).click();
await placePoints(p, SKI_POINTS.slice(0, 2), true);
},
steps: [
{ target: (p) => legProblem(p).first() },
{
target: (p) => legProblem(p).first().getByRole('button').filter({ hasNotText: 'Draw straight' }).first(),
act: async (p) => {
await legProblem(p).first().getByRole('button').filter({ hasNotText: 'Draw straight' }).first().click();
// The leg is asked for again: planned, or failed for the next reason.
await p.waitForTimeout(2000);
await expect
.poll(async () => (await legSteps(p).count()) + (await legProblem(p).count()), { timeout: 180_000 })
.toBeGreaterThan(0);
await settle(p);
},
},
{
// The fix planned the leg: leave the lifts out again for a failure to draw straight.
prepare: async (p) => {
if ((await legProblem(p).count()) > 0) return;
for (const name of LIFT_LABELS) {
const badge = liftClasses(p).getByRole('button', { name });
if ((await badge.getAttribute('aria-pressed')) !== 'true') await badge.click();
}
await expect(legProblem(p).first()).toBeVisible({ timeout: 180_000 });
await settle(p);
},
target: (p) => legProblem(p).first().getByRole('button', { name: 'Draw straight' }),
act: async (p) => {
await legProblem(p).first().getByRole('button', { name: 'Draw straight' }).click();
await expect(legProblem(p)).toHaveCount(0, { timeout: 30_000 });
},
},
],
cleanup: leaveEditor,
},
'tour-ski-summary': {
guide: guide('tour-ski-summary'),
start: plannedSkiDraft,
steps: [
{ target: pisteRoute },
{ target: (p) => pisteRoute(p).getByTestId('ski-route-summary') },
{
target: (p) => overview(p).getByText('Ski time', { exact: true }).locator('xpath=..'),
},
],
cleanup: leaveEditor,
},
'tour-ski-pistes-layer': {
guide: guide('tour-ski-pistes-layer'),
start: async (p) => {
await openSkiPlanner(p);
await openSavedSkiTour(p);
await expect(p.getByTestId('map-overlay-pistes')).toHaveAttribute('aria-pressed', 'true');
},
steps: [
{ target: (p) => p.getByTestId('map-overlay-pistes') },
{
target: (p) => p.getByTestId('piste-key-button'),
act: async (p) => {
await p.getByTestId('piste-key-button').click();
await expect(p.getByRole('group', { name: 'Piste key' })).toBeVisible();
},
},
{
target: (p) => p.getByRole('group', { name: 'Piste key' }),
hover: async (p) => p.getByRole('link', { name: 'Full key at OpenSnowMap' }).hover(),
},
],
cleanup: async (p) => {
await p.keyboard.press('Escape');
},
},
'tour-ski-import': {
guide: guide('tour-ski-import'),
start: async (p) => {
await deleteSkiTours(p, SKI_TRACK.name);
await openSkiPlanner(p);
},
steps: [
{
prepare: async (p) => {
await rightRail(p).getByTestId('tour-rail-add').click();
await expect(p.getByRole('group', { name: 'Add tour' })).toBeVisible();
},
target: (p) => p.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: 'Import GPX or KML' }),
act: async (p) => {
const file = await skiTrackFixture(p.request);
const chooser = p.waitForEvent('filechooser');
await p.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: 'Import GPX or KML' }).click();
await (await chooser).setFiles(file);
await expect(importDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) => importDialog(p).getByTestId('tour-type-select'),
act: async (p) => {
await importDialog(p).getByTestId('tour-type-select').click();
await p.getByTestId('tour-type-option-ski').click();
await expect(importDialog(p).getByTestId('tour-ski-fields')).toBeVisible();
},
},
{
target: (p) => importDialog(p).getByTestId('tour-ski-fields'),
act: async (p) => {
const fields = importDialog(p).getByTestId('tour-ski-fields');
await disciplines(fields).getByRole('button', { name: 'Cross-country', exact: true }).click();
await fields.getByRole('group', { name: 'Technique' }).getByRole('button', { name: 'Classic' }).click();
await expect(importDialog(p).getByTestId('gpx-preview-import')).toBeEnabled();
},
},
{
target: (p) => importDialog(p).getByTestId('gpx-preview-import'),
act: async (p) => {
await importDialog(p).getByTestId('gpx-preview-import').click();
await expect(importDialog(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, /Vallunga cross-country/)).toBeVisible({ timeout: 30_000 });
},
},
{
prepare: async (p) => {
await tourRow(p, /Vallunga cross-country/).click();
await expect(p.getByTestId('ski-track-card')).toBeVisible({ timeout: 30_000 });
await settle(p);
},
target: (p) => p.getByTestId('ski-track-card').getByRole('button', { name: 'Change' }),
act: async (p) => {
await p.getByTestId('ski-track-card').getByRole('button', { name: 'Change' }).click();
await expect(classifyDialog(p)).toBeVisible();
},
},
{
prepare: async (p) => {
await classifyDialog(p)
.getByRole('group', { name: 'Technique' })
.getByRole('button', { name: 'Skating' })
.click();
await expect(p.getByTestId('ski-classify-save')).toBeEnabled();
},
target: (p) => p.getByTestId('ski-classify-save'),
act: async (p) => {
await p.getByTestId('ski-classify-save').click();
await expect(p.getByText('Classification saved').first()).toBeVisible({ timeout: 30_000 });
await expect(p.getByTestId('ski-track-technique')).toContainText('Skating');
await settle(p);
},
},
],
cleanup: async (p) => {
await deleteSkiTours(p, SKI_TRACK.name);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
});
// A guide that fails, and the hero, which has no cleanup, leave their draft on
// the account. The page goes first, so no edit still settling can write the
// draft again after it is forgotten.
test.afterEach(async ({ page }) => {
await page.goto('about:blank').catch(() => undefined);
await forgetSkiDraft(page);
});
test('every registered ski and winter guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourSkiContext.guides].sort());
});
test('hero: trip-tour-ski', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourSkiContext.id, plannedSkiDraft);
});
for (const id of tripTourSkiContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-287
View File
@@ -1,287 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { tripTourStagesContext, tripTourStagesGuides } from '../../src/help/contexts/tripTourStages';
import type { HelpGuide } from '../../src/help/types';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES, TOUR_PLACES, type ToursFixtures } from './tour-fixtures';
import { confirmDialog } from './trip-shared';
/**
* Actions for multi-day tours on the Tours tab, keyed by the ids in
* `src/help/contexts/tripTourStages.ts`. They run on "Summer in the
* Dolomites" (`ensureToursFixtures`): the three-stage Puez-Odle hut trek on
* days 4 to 6, and the Pisciadù via ferrata, a day tour whose route passes the
* Rifugio Pisciadù, which is where its first day is made to end.
*
* Splitting the ferrata hike is never saved: the draft lives only in this
* test's browser, and cleanup forgets it. Putting the hut trek on its days
* takes it off them first, through the API, and cleanup puts the seed's
* placements and start times back.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourStagesGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures;
// ── Where things are ──────────────────────────────────────────────────────────
/** The right rail of the Tours tab, "Tours of this trip". */
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
/** A tour's row in that rail; the option is named after the tour. */
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
/** The editor's Days field: Day tour or Multi-day tour. */
const stageMode = (page: Page) => page.getByTestId('tour-stage-mode');
const waypointsCard = (page: Page) => page.getByRole('region', { name: 'Waypoints', exact: true });
const waypointRow = (page: Page, name: string) =>
page.getByTestId('tour-planner-waypoint-row').filter({ hasText: name }).first();
/** The one context menu that is open; its entries are plain buttons. */
const openMenu = (page: Page) => page.getByRole('menu');
const menuItem = (page: Page, name: string | RegExp) =>
openMenu(page).getByRole('button', typeof name === 'string' ? { name, exact: true } : { name });
const stageItem = (page: Page, n: number) => page.locator(`[data-testid="tour-stage-item"][data-stage="${n}"]`);
// ── Getting there ─────────────────────────────────────────────────────────────
async function openToursTab(page: Page): Promise<number> {
const tripId = await openTourTrip(page, { tab: 'tour-planner' });
await expect(tourRow(page, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
return tripId;
}
/** The route's measures arrive after the points: nothing is drawn or counted before. */
async function waitForTheRoute(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
await expect(waypointsCard(page)).toBeVisible();
await expect(page.getByRole('region', { name: 'Tour summary', exact: true })).toBeVisible({ timeout: 120_000 });
await expect(page.locator('[data-testid="tour-planner-status"] .animate-spin')).toHaveCount(0, { timeout: 120_000 });
await settle(page);
}
/** Open a tour in the editor from its row; on the name, not on the plus or the fold at the row's end. */
async function openInEditor(page: Page, name: string): Promise<void> {
await tourRow(page, name).click({ position: { x: 80, y: 14 } });
await waitForTheRoute(page);
}
/** The draft kept on the account, and the copy older builds left in the browser. */
async function forgetDraft(page: Page, tripId: number): Promise<void> {
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
await page.evaluate((id) => {
try {
localStorage.removeItem(`tour-draft-${id}`);
} catch {
// Nothing kept, nothing to forget.
}
}, tripId);
}
type AssignmentRow = { id: number; place_id: number };
/** Takes the hut trek off every day it is on, so the guide can put it on again. */
async function clearHutTrekDays(api: APIRequestContext): Promise<void> {
const placeId = fx.tours.hutToHut;
if (!placeId) throw new Error(`no "${TOUR_NAMES.hutToHut}": the tour fixtures could not build it`);
for (const dayId of fx.dayIds) {
const res = await api.get(`/api/trips/${fx.tripId}/days/${dayId}/assignments`);
const { assignments = [] } = (await res.json()) as { assignments?: AssignmentRow[] };
for (const a of assignments.filter((x) => x.place_id === placeId)) {
const del = await api.delete(`/api/trips/${fx.tripId}/days/${dayId}/assignments/${a.id}`);
expect(del.ok(), `take the hut trek off day ${dayId}`).toBe(true);
}
}
}
// ── The guides ────────────────────────────────────────────────────────────────
let tripId = 0;
const SCRIPTS: Record<string, GuideScript> = {
'tour-multi-day': {
guide: guide('tour-multi-day'),
start: async (p) => {
tripId = await openToursTab(p);
await expect(tourRow(p, TOUR_NAMES.ferrata)).toBeVisible();
},
steps: [
{
target: (p) => tourRow(p, TOUR_NAMES.ferrata),
act: async (p) => {
await openInEditor(p, TOUR_NAMES.ferrata);
await expect(waypointRow(p, TOUR_PLACES.pisciadu)).toBeVisible();
},
},
{
target: (p) => stageMode(p).getByRole('button', { name: 'Multi-day tour', exact: true }),
act: async (p) => {
await stageMode(p).getByRole('button', { name: 'Multi-day tour', exact: true }).click();
await expect(
stageMode(p).getByText('In the menu of a stop, choose End stage here to split the route.')
).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
const row = waypointRow(p, TOUR_PLACES.pisciadu);
await row.scrollIntoViewIfNeeded();
await row.hover();
await row.getByRole('button', { name: 'More actions', exact: true }).click();
await expect(menuItem(p, 'End stage here')).toBeVisible();
},
target: (p) => menuItem(p, 'End stage here'),
act: async (p) => {
await menuItem(p, 'End stage here').click();
await expect(p.getByTestId('tour-stage-divider')).toHaveCount(2);
await settle(p);
},
},
{ target: waypointsCard },
{
target: (p) => p.getByTestId('tour-stage-row').first(),
act: async (p) => {
await p.getByTestId('tour-stage-row').first().click();
await expect(waypointRow(p, TOUR_PLACES.pisciadu).locator('button[aria-current="true"]')).toBeVisible();
await settle(p);
},
},
{
prepare: async (p) => {
await stageMode(p).getByRole('button', { name: 'Day tour', exact: true }).click();
await expect(confirmDialog(p).getByRole('button', { name: 'Remove stage ends', exact: true })).toBeVisible();
await settle(p);
},
target: confirmDialog,
act: async (p) => {
await confirmDialog(p).getByRole('button', { name: 'Remove stage ends', exact: true }).click();
await expect(p.getByTestId('tour-stage-divider')).toHaveCount(0);
await settle(p);
},
},
],
cleanup: (p) => forgetDraft(p, tripId),
},
'tour-stages-on-days': {
guide: guide('tour-stages-on-days'),
start: async (p) => {
await clearHutTrekDays(p.request);
tripId = await openToursTab(p);
await expect(tourRow(p, TOUR_NAMES.hutToHut).getByTestId('tour-days-badge')).toHaveCount(0);
},
steps: [
{
prepare: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).hover();
},
target: (p) =>
tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: `Add to day: ${TOUR_NAMES.hutToHut}` }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut)
.getByRole('button', { name: `Add to day: ${TOUR_NAMES.hutToHut}` })
.click();
await expect(menuItem(p, /^Day 4(?!\d)/)).toBeVisible();
},
},
{
target: (p) => menuItem(p, /^Day 4(?!\d)/),
act: async (p) => {
await menuItem(p, /^Day 4(?!\d)/).click();
await expect(menuItem(p, 'Stages from this day on')).toBeVisible();
await settle(p);
},
},
{
target: (p) => menuItem(p, 'Stages from this day on'),
act: async (p) => {
await menuItem(p, 'Stages from this day on').click();
await expect(p.getByText('Stages from day 4 on.')).toBeVisible({ timeout: 30_000 });
await expect(tourRow(p, TOUR_NAMES.hutToHut).getByTestId('tour-days-badge')).toHaveText(/4, 5, 6/, {
timeout: 30_000,
});
await settle(p);
},
},
{
target: (p) => tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Show stages', exact: true }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Show stages', exact: true }).click();
await expect(p.getByTestId('tour-stage-item')).toHaveCount(3, { timeout: 30_000 });
await expect(stageItem(p, 3).getByTestId('tour-stage-day')).toHaveAttribute(
'data-stage-day',
String(fx.dayIds[5])
);
await settle(p);
},
},
{
target: (p) => stageItem(p, 3).getByTestId('tour-stage-day'),
act: async (p) => {
// The menu of days opens and closes again: the pictures move no stage.
await stageItem(p, 3).getByTestId('tour-stage-day').click();
await expect(openMenu(p)).toBeVisible();
// A click beside the menu closes it, on the rail's heading, which does
// nothing by itself. Escape does not reach the menu here: the stage list
// stops every key press that bubbles out of it, and the portalled menu
// bubbles through it in React's tree. The pill itself is no way out
// either, as the menu is pushed up over it at the foot of the window.
await toursRail(p).getByRole('heading', { name: 'Tours of this trip', exact: true }).click();
await expect(openMenu(p)).toHaveCount(0);
await settle(p);
},
},
{
target: (p) => stageItem(p, 2).getByTestId('tour-stage-open'),
act: async (p) => {
await stageItem(p, 2).getByTestId('tour-stage-open').click();
await waitForTheRoute(p);
await expect(waypointRow(p, TOUR_PLACES.genova).locator('button[aria-current="true"]')).toBeVisible({
timeout: 30_000,
});
await settle(p);
},
},
],
cleanup: async (p) => {
// The seed's placements come back with their start times (08:30, 08:00, 08:00).
await clearHutTrekDays(p.request);
fx = await ensureToursFixtures(p.request);
await forgetDraft(p, tripId);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
test.setTimeout(600_000);
fx = await ensureToursFixtures(request);
});
test('every registered stages guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourStagesContext.guides].sort());
});
/** The hut trek open in the editor: its three stages in the list of points and in the summary. */
async function heroStart(page: Page): Promise<void> {
await openToursTab(page);
await openInEditor(page, TOUR_NAMES.hutToHut);
await expect(page.getByTestId('tour-stage-divider')).toHaveCount(3);
await expect(page.getByTestId('tour-stage-row')).toHaveCount(3);
await settle(page);
}
test('hero: trip-tour-stages', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourStagesContext.id, heroStart);
});
for (const id of tripTourStagesContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-386
View File
@@ -1,386 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripTourWalkContext, tripTourWalkGuides } from '../../src/help/contexts/tripTourWalk';
import type { HelpGuide } from '../../src/help/types';
import { ensureWalkHours, GOOGLE_WALK, resetKyotoVisits, WALK_HOURS } from './fixtures-trip-tour-walk';
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, openTourTrip, TOUR_NAMES } from './tour-fixtures';
import { openTrip } from './trip-shared';
/**
* Actions for the city walk, keyed by the ids in
* `src/help/contexts/tripTourWalk.ts`. They run on "Summer in the Dolomites"
* from `ensureToursFixtures`, whose Bolzano old town walk is on day 1 at
* 15:00 with seven stops (the museum a must, the Dominican Church optional),
* and on the Higashiyama walk on day 9 of the seeded "Autumn in Japan", the
* day before the picture day, where its stops are checked off. Nothing a
* guide does here is saved: every edit stays a draft in the planner and is
* discarded in `cleanup`, and the marks of the check-off guide are taken
* back through the API.
*/
const guide = (id: string): HelpGuide => {
const g = tripTourWalkGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
const WALK = TOUR_NAMES.cityWalk;
const KYOTO_WALK = TOUR_NAMES.kyotoWalk;
const MUSEUM = WALK_HOURS.stop;
// ── Where things are ──────────────────────────────────────────────────────────
/** A tour's row in Tours of this trip: an option named by the tour. */
const tourRow = (page: Page, name: string) => page.getByRole('option', { name, exact: true });
/** A row of the editor's Waypoints card. */
const waypointRow = (page: Page, name: string) =>
page.getByTestId('tour-planner-waypoint-row').filter({ hasText: name }).first();
/** The menu a row's More actions opens; only one is open at a time. */
const openMenu = (page: Page) => page.getByRole('menu').last();
/** The station dialog: the shared dialog shell around `station-dialog`. */
const stationDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByTestId('station-dialog') });
/** The Google Maps import dialog, by its title. */
const importDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Walk from a Google Maps link' });
/**
* A stop row of the day plan. Every row of a day card is a div with
* `role="button"`; a stop, a tour included, is the one whose name opens with
* the label of the lock on its picture (as in `trip-days.guide.ts`).
*/
const planRow = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first();
/** The walk's stops in its detail card, as the section that holds them, its head with the counts included. */
const stationsSection = (page: Page) => page.getByTestId('walk-stations').locator('xpath=ancestor::section[1]');
const stationRow = (page: Page, name: string) => page.getByTestId('walk-station').filter({ hasText: name }).first();
// ── Getting there ─────────────────────────────────────────────────────────────
/**
* A draft is kept on the account per trip, and one left over from an earlier
* guide would open in the editor instead of Plan new tour. Older builds kept
* it in the browser as `tour-draft-<tripId>`. Every page these guides open
* starts without either.
*/
async function forgetDrafts(page: Page): Promise<void> {
const res = await page.request.get('/api/trips');
const { trips = [] } = (await res.json()) as { trips?: { id: number }[] };
for (const trip of trips) await page.request.delete(`/api/trips/${trip.id}/tour-draft`).catch(() => undefined);
await page.addInitScript(() => {
try {
for (const key of Object.keys(localStorage)) if (key.startsWith('tour-draft-')) localStorage.removeItem(key);
} catch {
// Storage blocked: there is no draft to restore either.
}
});
}
/** The Tours tab of the Dolomites trip, nothing open in the editor, the trip's tours listed. */
async function openToursTab(page: Page): Promise<void> {
await forgetDrafts(page);
await openTourTrip(page, { tab: 'tour-planner' });
await expect(page.getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 30_000 });
await expect(tourRow(page, WALK)).toBeVisible({ timeout: 30_000 });
await settle(page);
}
/**
* The Plan tab with day 1 selected. The planner reopens a trip on the tab it
* was last on in this browser tab (`trip-tab-<id>` in sessionStorage), so
* after the Tours tab a plain visit lands on Tours again and day 1 has no
* header to click; the remembered tab is forgotten first.
*/
async function openPlanTab(page: Page): Promise<void> {
await page.evaluate(() => {
try {
for (const key of Object.keys(sessionStorage)) if (key.startsWith('trip-tab-')) sessionStorage.removeItem(key);
} catch {
// Storage blocked: nothing is remembered either, and the trip opens on Plan.
}
});
await openTourTrip(page, { day: 1 });
}
/**
* The Bolzano walk open in the editor, with its route current: the arrivals
* under the stops only appear once the route belongs to the stops as they are.
*/
async function openWalk(page: Page): Promise<void> {
await openToursTab(page);
await tourRow(page, WALK).click();
await expect(page.getByTestId('tour-planner-waypoint-row')).toHaveCount(7, { timeout: 30_000 });
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('walk-station-schedule').first()).toBeVisible({ timeout: 60_000 });
await settle(page);
}
/** Select a stop of the open walk, so its row actions stay out while the pointer moves. */
async function selectStop(page: Page, name: string): Promise<void> {
await waypointRow(page, name).getByTestId('tour-planner-waypoint-name').click();
await expect(waypointRow(page, name).getByRole('button', { name: 'More actions' })).toBeVisible();
await settle(page);
}
/**
* Leave the editor without saving: Discard at the foot of the left column,
* and Discard changes where it asks. A dialog still open over it goes first.
*/
async function discardDraft(page: Page): Promise<void> {
for (let i = 0; i < 3 && (await page.getByRole('dialog').count()) > 0; i++) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
const discard = page.getByRole('button', { name: 'Discard', exact: true });
if ((await discard.count()) > 0 && (await discard.first().isEnabled())) {
await discard.first().click();
const confirm = page.getByRole('button', { name: 'Discard changes', exact: true });
if (await confirm.isVisible({ timeout: 2_000 }).catch(() => false)) await confirm.click();
}
await expect(page.getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 15_000 });
await forgetDrafts(page);
}
/** The first place the search found that is not a stop yet, a highlight where there is one. */
async function firstNewPoi(page: Page): Promise<Locator> {
const rows = page.getByTestId('tour-poi-list').locator('li').filter({ hasNotText: 'In the tour' });
const starred = rows.filter({ has: page.getByTestId('tour-poi-highlight') });
return ((await starred.count()) > 0 ? starred : rows).first().getByRole('button');
}
const only = (target: (p: Page) => Locator) => ({ target });
const SCRIPTS: Record<string, GuideScript> = {
'tour-walk-plan': {
guide: guide('tour-walk-plan'),
start: openToursTab,
steps: [
{
prepare: async (p) => {
await p.getByRole('button', { name: 'Plan new tour' }).first().click();
await expect(p.getByTestId('tour-type-select')).toBeVisible({ timeout: 15_000 });
await p.getByTestId('tour-type-select').click();
await expect(p.getByTestId('tour-type-option-walk')).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('group', { name: 'Tour type' }),
hover: async (p) => {
await p.getByTestId('tour-type-option-walk').hover();
},
act: async (p) => {
await p.getByTestId('tour-type-option-walk').click();
await expect(p.getByTestId('tour-type-select')).toContainText('City / sightseeing walk');
await settle(p);
},
},
{
// The two ways to add a stop, side by side under the empty Waypoints card.
target: (p) => p.getByTestId('tour-planner-add-place').locator('xpath=..'),
act: async (p) => {
// Drawing seven stops by clicking the map is no picture of its own; the
// rest of the guide works on the walk the fixtures planned that way.
await discardDraft(p);
await tourRow(p, WALK).click();
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(7, { timeout: 30_000 });
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
{
prepare: (p) => selectStop(p, MUSEUM),
target: (p) => waypointRow(p, MUSEUM).getByRole('button', { name: 'More actions' }),
act: async (p) => {
await waypointRow(p, MUSEUM).getByRole('button', { name: 'More actions' }).click();
await openMenu(p).getByRole('button', { name: 'Edit stop' }).click();
await expect(stationDialog(p)).toBeVisible();
await settle(p);
},
},
only((p) => stationDialog(p).getByLabel('Stay in minutes')),
{
target: (p) => stationDialog(p).getByRole('group', { name: 'Importance' }),
act: async (p) => {
await stationDialog(p).getByTestId('station-save').click();
await expect(stationDialog(p)).toHaveCount(0);
await settle(p);
},
},
only((p) => p.getByTestId('tour-planner-overview')),
],
cleanup: discardDraft,
},
'tour-walk-sights': {
guide: guide('tour-walk-sights'),
start: openWalk,
steps: [
{
target: (p) => p.getByTestId('tour-poi-themes'),
act: async (p) => {
await p.getByTestId('tour-poi-theme-culture').click();
await expect(p.getByTestId('tour-poi-theme-culture')).toHaveAttribute('aria-pressed', 'true');
await settle(p);
},
},
only((p) => p.getByTestId('tour-poi-categories')),
{
target: (p) => p.getByTestId('tour-poi-search'),
act: async (p) => {
await p.getByTestId('tour-poi-search').click();
// TREK's places index first, OpenStreetMap behind it, through the server.
await expect(p.getByTestId('tour-poi-list')).toBeVisible({ timeout: 90_000 });
await settle(p);
},
},
{
prepare: async (p) => {
await (await firstNewPoi(p)).click();
await expect(p.getByTestId('tour-poi-overlay')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
target: (p) => p.getByTestId('tour-poi-insert'),
},
{
target: (p) => p.getByTestId('tour-poi-add'),
act: async (p) => {
await p.getByTestId('tour-poi-add').click();
await expect(p.getByTestId('tour-poi-outcome')).toBeVisible({ timeout: 15_000 });
// The route is planned again with the new stop; the notice then says by how much.
await expect(p.getByTestId('tour-poi-outcome')).not.toContainText('being planned again', { timeout: 60_000 });
await settle(p);
},
},
],
cleanup: discardDraft,
},
'tour-walk-day-times': {
guide: guide('tour-walk-day-times'),
// Open, the walk's row in Tours of this trip is selected, so its + stays in sight.
start: openWalk,
steps: [
only((p) => tourRow(p, WALK).getByRole('button', { name: `Add to day: ${WALK}` })),
{
prepare: async (p) => {
await openPlanTab(p);
await expect(planRow(p, WALK)).toBeVisible({ timeout: 20_000 });
await settle(p);
},
target: (p) => planRow(p, WALK),
act: openWalk,
},
only((p) => p.getByTestId('walk-day-line')),
only((p) => waypointRow(p, MUSEUM).getByTestId('walk-station-schedule')),
],
cleanup: discardDraft,
},
'tour-walk-google-import': {
guide: guide('tour-walk-google-import'),
start: openToursTab,
steps: [
{
prepare: async (p) => {
await p.getByTestId('tour-rail-add').click();
await expect(p.getByRole('button', { name: 'Import walk from Google Maps' })).toBeVisible();
await settle(p);
},
target: (p) => p.getByRole('button', { name: 'Import walk from Google Maps' }),
act: async (p) => {
await p.getByRole('button', { name: 'Import walk from Google Maps' }).click();
await expect(importDialog(p)).toBeVisible();
await p.getByTestId('walk-import-url').fill(GOOGLE_WALK.link);
await settle(p);
},
},
{
// The field with the link in it and Read the link under it.
target: (p) => importDialog(p),
act: async (p) => {
await p.getByTestId('walk-import-read').click();
await expect(p.getByTestId('walk-import-preview')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('walk-import-station')).toHaveCount(GOOGLE_WALK.count);
await settle(p);
},
},
only((p) => p.getByTestId('walk-import-preview').locator('section').first()),
{
target: (p) => p.getByTestId('walk-import-load'),
act: async (p) => {
await p.getByTestId('walk-import-load').click();
await expect(importDialog(p)).toHaveCount(0);
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(GOOGLE_WALK.count, { timeout: 20_000 });
// The way on foot between the stops is TREK's own: wait for the router's line.
await expect(p.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
await settle(p);
},
},
],
cleanup: discardDraft,
},
'tour-walk-check-off': {
guide: guide('tour-walk-check-off'),
start: async (p) => {
await resetKyotoVisits(p.request);
await openTrip(p, { day: 9 });
await expect(planRow(p, KYOTO_WALK)).toBeVisible({ timeout: 20_000 });
},
steps: [
{
target: (p) => planRow(p, KYOTO_WALK),
act: async (p) => {
// On the name: the row's centre can land on one of its badges.
await planRow(p, KYOTO_WALK).getByText(KYOTO_WALK, { exact: true }).first().click();
await expect(p.getByTestId('walk-stations')).toBeVisible({ timeout: 20_000 });
await expect(p.getByTestId('walk-station')).toHaveCount(7, { timeout: 20_000 });
await settle(p);
},
},
only(stationsSection),
{
target: (p) => stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Mark visited' }),
act: async (p) => {
await stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Mark visited' }).click();
await expect(stationRow(p, 'Kiyomizu-dera').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await stationRow(p, 'Sannenzaka').getByRole('button', { name: 'Mark visited' }).click();
await expect(stationRow(p, 'Sannenzaka').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await stationRow(p, 'Kodai-ji').getByRole('button', { name: 'Skip' }).click();
await expect(stationRow(p, 'Kodai-ji').getByRole('button', { name: 'Take back' })).toBeVisible({
timeout: 15_000,
});
await settle(p);
},
},
only((p) => stationsSection(p).locator('> div').first()),
],
cleanup: async (p) => {
await resetKyotoVisits(p.request);
},
},
};
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
await ensureToursFixtures(request);
await ensureWalkHours(request);
});
test('every registered city walk guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourWalkContext.guides].sort());
});
test('hero: trip-tour-walk', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripTourWalkContext.id, openWalk);
});
for (const id of tripTourWalkContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[id]);
});
}
-420
View File
@@ -1,420 +0,0 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { tripToursContext, tripToursGuides } from '../../src/help/contexts/tripTours';
import type { HelpGuide } from '../../src/help/types';
import {
backOnDay,
clearHiddenTours,
ensureLunch,
LUNCH,
modeTab,
offAllDays,
openToursPlan,
removeByName,
resetTime,
tourIdOf,
} from './fixtures-trip-tours';
import { beat, captureGuide, captureHero, dragLoop, settle, VIEWPORT, type GuideScript } from './guide';
import { ensureToursFixtures, TOUR_GPX, TOUR_NAMES, tourGpxFixture, type ToursFixtures } from './tour-fixtures';
import { closeMenu, modal, selectDay } from './trip-shared';
/**
* Actions for the tours on the Plan tab, keyed by the ids in
* `src/help/contexts/tripTours.ts`. They run on "Summer in the Dolomites"
* (`ensureToursFixtures`): ten days, the tours on days 1 to 9, day 10 empty,
* and two tours on no day (the MTB loop and the recorded Seceda walk), which
* the add-to-day guide puts on day 10. What a guide writes it takes back in
* `cleanup`: the fixtures never rebuild a tour that exists, so a tour left on
* the wrong day would stay there for every later picture.
*/
const guide = (id: string): HelpGuide => {
const g = tripToursGuides.find((x) => x.id === id);
if (!g) throw new Error(`no registered guide "${id}"`);
return g;
};
let fx: ToursFixtures;
// ── Where things are ──────────────────────────────────────────────────────────
/** The list of the right column; its rows are options named by the tour. */
const tourList = (page: Page) => page.getByRole('listbox', { name: 'Tours', exact: true });
const tourRow = (page: Page, name: string) => tourList(page).getByRole('option', { name, exact: true });
const importButton = (page: Page) => page.getByRole('button', { name: 'Import GPX or KML' });
/** The head band of the list: the import, the filter and the hidden-on-the-map line. */
const listHead = (page: Page) =>
page
.locator('div.border-b.border-edge-faint')
.filter({ has: importButton(page) })
.last();
const filterButton = (page: Page) => listHead(page).getByRole('button', { name: 'Show', exact: true });
const filterMenu = (page: Page) => listHead(page).getByRole('group', { name: 'Show', exact: true });
/** A day card's head band (`.dp-day-header`, a div with the button role). */
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) });
const dayCard = (page: Page, n: number) => dayHeader(page, n).locator('xpath=..');
/**
* A row of the days column. Its accessible name opens with the label of the
* lock on its picture, a tour's tile included, which tells it from the note and
* booking rows beside it.
*/
const planRow = (page: Page, name: string) =>
page
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
.filter({ hasText: name })
.first();
/** The menu of a row of the days column or of a tour's +; a tooltip shares the popover class. */
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last();
/** The menu the round + of a tour row opens (a ContextMenu, role menu). */
const dayMenu = (page: Page) => page.getByRole('menu').last();
/** The tour's card over the map (DetailShell), known by its Trip days section. */
const tourCard = (page: Page) =>
page
.locator('div.rounded-\\[20px\\]')
.filter({ has: page.getByText('Trip days', { exact: true }) })
.first();
const previewDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByTestId('gpx-preview-import') });
async function rowMenu(page: Page, name: string): Promise<void> {
const row = planRow(page, name);
await row.scrollIntoViewIfNeeded();
await row.hover();
await row.getByRole('button', { name: 'More options' }).click();
await expect(menu(page).getByRole('button', { name: 'Edit', exact: true })).toBeVisible();
await beat(page, 300);
}
async function hoverRow(page: Page, name: string): Promise<void> {
await tourRow(page, name).scrollIntoViewIfNeeded();
await tourRow(page, name).hover();
await beat(page, 200);
}
const id = async (page: Page, name: string): Promise<number> => {
const found = await tourIdOf(page.request, fx.tripId, name);
if (!found) throw new Error(`the tour "${name}" is missing: see the [tour-fixtures] warnings`);
return found;
};
const only = (target: (p: Page) => Locator) => ({ target });
const openTours = (p: Page) => openToursPlan(p, { tours: true }).then(() => undefined);
/**
* The MTB loop and the Seceda walk on no day, as the fixtures made them. A
* guide that failed before its cleanup leaves them on day 10, and then the
* Unplanned filter holds the walk alone and the add-to-day picture shows a
* day that is no longer empty.
*/
async function noDayTours(page: Page): Promise<void> {
for (const name of [TOUR_NAMES.mtb, TOUR_NAMES.recorded]) {
await offAllDays(page.request, fx.tripId, fx.dayIds, await id(page, name));
}
}
const SCRIPTS: Record<string, GuideScript> = {
'tour-show-list': {
guide: guide('tour-show-list'),
start: (p) => openToursPlan(p).then(() => undefined),
steps: [
{
target: (p) => modeTab(p, 'Tours'),
act: async (p) => {
await modeTab(p, 'Tours').click();
await expect(tourRow(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.hutToHut)),
{
target: (p) => tourRow(p, TOUR_NAMES.hutToHut),
act: async (p) => {
await tourRow(p, TOUR_NAMES.hutToHut).click();
await expect(tourCard(p)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only(tourCard),
only((p) => modeTab(p, 'Places')),
],
},
'tour-add-to-day': {
guide: guide('tour-add-to-day'),
start: async (p) => {
await noDayTours(p);
await openTours(p);
},
steps: [
{
prepare: (p) => hoverRow(p, TOUR_NAMES.mtb),
target: (p) => tourRow(p, TOUR_NAMES.mtb).getByRole('button', { name: 'Add to day', exact: true }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.mtb).getByRole('button', { name: 'Add to day', exact: true }).click();
await expect(dayMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: dayMenu,
act: async (p) => {
await dayMenu(p)
.getByRole('button', { name: /^Day 10,/ })
.click();
await expect(tourRow(p, TOUR_NAMES.mtb).getByTestId('tour-days-badge')).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.mtb)),
{
// Day 10 is the last card of the days column, below the fold when the
// trip opens, and a drop can only land where the pointer can reach.
prepare: async (p) => {
await dayHeader(p, 10).scrollIntoViewIfNeeded();
await settle(p);
},
target: (p) => tourRow(p, TOUR_NAMES.recorded),
dropTo: (p) => dayHeader(p, 10),
clip: async (p, frame) => {
await dragLoop(p, tourRow(p, TOUR_NAMES.recorded), dayHeader(p, 10), frame);
await expect(dayCard(p, 10).getByText(TOUR_NAMES.recorded).first()).toBeVisible({ timeout: 15_000 });
await settle(p);
await frame();
await frame();
},
},
{
// The hut trek has three stages, so the day it is given asks how next.
prepare: async (p) => {
await hoverRow(p, TOUR_NAMES.hutToHut);
await tourRow(p, TOUR_NAMES.hutToHut).getByRole('button', { name: 'Add to day', exact: true }).click();
await expect(dayMenu(p)).toBeVisible();
await dayMenu(p)
.getByRole('button', { name: /^Day 8,/ })
.click();
await expect(dayMenu(p).getByRole('button', { name: /^Stages from this day on/ })).toBeVisible({
timeout: 10_000,
});
await settle(p);
},
target: dayMenu,
// Nothing chosen: the hut trek keeps its days 4 to 6.
act: closeMenu,
},
],
cleanup: noDayTours,
},
'tour-day-times': {
guide: guide('tour-day-times'),
start: async (p) => {
await ensureLunch(p.request, fx.tripId, fx.dayIds[1]);
await openTours(p);
},
steps: [
{
prepare: (p) => rowMenu(p, TOUR_NAMES.horse),
target: menu,
act: async (p) => {
await menu(p).getByRole('button', { name: 'Edit', exact: true }).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('10:30');
await times.first().blur();
await expect(modal(p).getByTestId('tour-day-end-hint')).toBeVisible();
await beat(p, 300);
},
// The two fields and the worked-out end under them share one block.
target: (p) => modal(p).getByTestId('tour-day-end-hint').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(planRow(p, TOUR_NAMES.horse).getByText(/10:30/)).toBeVisible({ timeout: 15_000 });
await settle(p);
},
},
only((p) => planRow(p, TOUR_NAMES.horse).getByTestId('tour-day-overrun')),
],
cleanup: async (p) => {
await resetTime(p.request, fx.tripId, fx.dayIds[1], await id(p, TOUR_NAMES.horse), '10:00');
await removeByName(p.request, fx.tripId, LUNCH.name);
},
},
'tour-remove-from-day': {
guide: guide('tour-remove-from-day'),
start: openTours,
steps: [
{
prepare: (p) => rowMenu(p, TOUR_NAMES.run),
target: (p) => menu(p).getByRole('button', { name: 'Remove from day', exact: true }),
act: async (p) => {
await menu(p).getByRole('button', { name: 'Remove from day', exact: true }).click();
await expect(planRow(p, TOUR_NAMES.run)).toHaveCount(0, { timeout: 15_000 });
await settle(p);
},
},
only((p) => tourRow(p, TOUR_NAMES.run)),
{
// The card's day buttons belong to the open day, so day 1 is opened first.
prepare: async (p) => {
await selectDay(p, 1);
await tourRow(p, TOUR_NAMES.cityWalk).click();
await expect(tourCard(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 15_000 });
await settle(p);
},
target: (p) => tourCard(p).getByRole('button', { name: 'Remove from Day' }),
},
],
cleanup: async (p) => {
await backOnDay(p.request, fx.tripId, fx.dayIds[7], await id(p, TOUR_NAMES.run), '07:00');
},
},
'tour-filter-list': {
guide: guide('tour-filter-list'),
// Under Unplanned the Type group lists the types of the unplanned tours
// only, so Bike tour is there only while the MTB loop is on no day.
start: async (p) => {
await noDayTours(p);
await openTours(p);
},
steps: [
{
target: filterButton,
act: async (p) => {
await filterButton(p).click();
await expect(filterMenu(p)).toBeVisible();
await settle(p);
},
},
{
target: (p) => filterMenu(p).getByRole('group', { name: 'Status', exact: true }),
act: async (p) => {
await filterMenu(p)
.getByRole('group', { name: 'Status', exact: true })
.getByRole('button', { name: /^Unplanned/ })
.click();
await expect(filterButton(p)).toContainText('Unplanned');
await settle(p);
},
},
{
target: filterMenu,
act: async (p) => {
await filterMenu(p)
.getByRole('group', { name: 'Type', exact: true })
.getByRole('button', { name: /^Bike tour/ })
.click();
await expect(tourRow(p, TOUR_NAMES.recorded)).toHaveCount(0);
await p.keyboard.press('Escape');
await expect(filterMenu(p)).toHaveCount(0);
await settle(p);
},
},
only(filterButton),
],
},
'tour-hide-on-map': {
guide: guide('tour-hide-on-map'),
start: async (p) => {
await openTours(p);
await clearHiddenTours(p);
},
steps: [
{
prepare: (p) => hoverRow(p, TOUR_NAMES.roadBike),
target: (p) => tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Hide on the map' }),
act: async (p) => {
await tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Hide on the map' }).click();
await expect(tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Show on the map' })).toBeVisible();
// Off the row, so the picture shows the eye a hidden tour keeps.
await p.mouse.move(VIEWPORT.width / 2, VIEWPORT.height / 2);
await settle(p);
},
},
{
target: (p) => tourRow(p, TOUR_NAMES.roadBike),
hover: async (p) => {
await tourRow(p, TOUR_NAMES.roadBike).getByRole('button', { name: 'Show on the map' }).hover();
},
},
{
target: (p) => listHead(p).getByRole('status'),
act: async (p) => {
await listHead(p).getByRole('button', { name: 'Show all', exact: true }).click();
await expect(listHead(p).getByRole('status')).toHaveCount(0);
await settle(p);
},
},
],
cleanup: clearHiddenTours,
},
'tour-import-plan-tab': {
guide: guide('tour-import-plan-tab'),
start: async (p) => {
await removeByName(p.request, fx.tripId, TOUR_GPX.rasciesa.name);
await openToursPlan(p);
},
steps: [
{
target: (p) => modeTab(p, 'Tours'),
act: async (p) => {
await modeTab(p, 'Tours').click();
await expect(importButton(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: importButton,
act: async (p) => {
const file = await tourGpxFixture('rasciesa');
const chooser = p.waitForEvent('filechooser');
await importButton(p).click();
await (await chooser).setFiles(file);
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
{
target: (p) =>
previewDialog(p)
.locator('section')
.filter({ has: p.getByText('Import as', { exact: true }) })
.first(),
act: async (p) => {
await previewDialog(p).getByTestId('gpx-preview-import').click();
await expect(previewDialog(p)).toHaveCount(0, { timeout: 30_000 });
await expect(tourRow(p, TOUR_GPX.rasciesa.name)).toBeVisible({ timeout: 30_000 });
await settle(p);
},
},
],
cleanup: (p) => removeByName(p.request, fx.tripId, TOUR_GPX.rasciesa.name),
},
};
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ request }) => {
fx = await ensureToursFixtures(request);
});
test('every registered Plan-tab tours guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripToursContext.guides].sort());
});
test('hero: trip-tours', async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureHero(page, tripToursContext.id, openTours);
});
for (const gid of tripToursContext.guides) {
test(`pictures: ${gid}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT);
await captureGuide(page, SCRIPTS[gid]);
});
}
+3 -205
View File
@@ -20,25 +20,8 @@ const guide = (id: string): HelpGuide => {
return g
}
/**
* The flight the seed creates. Its row on day 1 is its title, then the time
* pill (13:05), then the Departure span label: the time came first in 3ff88062f.
*/
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND .*Departure/ }
/** The train the day fixtures put on day 5: platform, seat and a booking code for the detail's tiles. */
const TRAIN = { title: 'Nozomi 21 Tokyo → Kyoto' }
/**
* What car-stops creates on day 3, the Hakone day. Every end and stop is one of
* the trip's own places, which the picker offers before anything is typed, so
* the guide needs no geocoder. The car comes back where it was collected.
*/
const CAR = {
title: 'Rental car through Hakone',
base: 'Hakone Open-Air Museum',
stops: ['Owakudani', 'Lake Ashi'] as const,
}
/** The file the export writes: the trip's title made safe, then the section (bookingsExport.ts). */
const CSV_FILE = 'autumn-in-japan-transports.csv'
/** 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.
@@ -73,13 +56,6 @@ const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getB
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 same row found by the title it shows, for a check that does not care about its pills. */
const transportRowTitled = (page: Page, title: string) =>
page.locator('[data-dp="transport-row"]').filter({ hasText: title }).first()
/** The tab bar's search box, and the bar it sits in (search, filter, views, options, export). */
const searchBox = (page: Page) => page.getByRole('textbox', { name: 'Search', exact: true }).first()
const bookingsBar = (page: Page) => searchBox(page).locator('xpath=../..')
const exportButton = (page: Page) => page.getByRole('button', { name: /^Export (as spreadsheet|the \d+ of \d+ shown rows) \(CSV\)$/ })
/** 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. */
@@ -124,25 +100,6 @@ async function searchLocation(page: Page, box: Locator, query: string, match: Re
await expect(box).toHaveValue(match, { timeout: 15_000 })
}
/**
* The transport form's location picker offers the trip's own places while the
* box is empty, in the same list the search answers in, so a place is picked
* without a geocoder in the way.
*/
async function pickTripPlace(page: Page, box: Locator, name: string): Promise<void> {
await box.click()
const option = suggestions(box).locator('button').filter({ hasText: name }).first()
await expect(option, `the trip's place "${name}" is not offered`).toBeVisible({ timeout: 10_000 })
await option.click()
await expect(box).toHaveValue(name, { timeout: 10_000 })
}
/** The car's stops block: its eyebrow, the stop rows and Add stop under it. */
const carStops = (page: Page) => modal(page).getByText('Stops along the way', { exact: true }).locator('xpath=..')
/** The Rename box the detail's title opens; its head names it. */
const renameDialog = (page: Page) => page.getByRole('dialog', { name: 'Rename' })
/** 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()
@@ -346,88 +303,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
cleanup: p => deleteByTitle(p, TAXI.title),
},
'car-stops': {
guide: guide('car-stops'),
start: p => openTrip(p),
steps: [
{
// Day 3 is the Hakone day: the three places the drive uses are its own.
prepare: p => openDayMenu(p, 3),
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: p => typeMenu(p).getByRole('button', { name: /^Car$/ }).first(),
act: async p => {
await typeMenu(p).getByRole('button', { name: /^Car$/ }).first().click()
await expect(typeMenu(p)).toHaveCount(0)
await expect(carStops(p)).toBeVisible()
await expect(modal(p).getByText('Pickup time', { exact: true })).toBeVisible()
await settle(p)
},
},
{
// Times before any stop exists: with no stop rows the two time boxes
// are Pickup time and Return time, in that order.
prepare: async p => {
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), CAR.title)
await pickTripPlace(p, locationBox(p, 0), CAR.base)
await pickTripPlace(p, locationBox(p, 1), CAR.base)
await modal(p).getByPlaceholder('00:00').nth(0).fill('09:00')
await modal(p).getByPlaceholder('00:00').nth(1).fill('18:00')
await settle(p)
},
target: p => block(p, 'Route'),
},
{
// The stops come after From and To in the form, so their pickers are
// the third and fourth location boxes.
prepare: async p => {
for (const [i, name] of CAR.stops.entries()) {
await modal(p).getByRole('button', { name: 'Add stop', exact: true }).click()
await pickTripPlace(p, locationBox(p, 2 + i), name)
}
await settle(p)
},
target: carStops,
},
{
// The second stop's Move up: the first one's is disabled.
target: p => modal(p).getByRole('button', { name: 'Move up', exact: true }).nth(1),
act: async p => {
await modal(p).getByRole('button', { name: 'Move up', exact: true }).nth(1).click()
await expect(locationBox(p, 2)).toHaveValue(CAR.stops[1])
await expect(locationBox(p, 3)).toHaveValue(CAR.stops[0])
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(modal(p)).toHaveCount(0, { timeout: 20_000 })
const { tripId } = seededTrip()
await expect.poll(async () => {
const res = await p.request.get(`/api/trips/${tripId}/reservations`)
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
const list = Array.isArray(body) ? body : (body.reservations ?? [])
return list.some(r => r.title === CAR.title)
}, { timeout: 20_000 }).toBe(true)
await settle(p)
},
},
],
cleanup: p => deleteByTitle(p, CAR.title),
},
'import-transport-file': {
guide: guide('import-transport-file'),
// See import-booking-file: the extractor and no addon, and the mail has to
@@ -634,55 +509,6 @@ const SCRIPTS: Record<string, GuideScript> = {
await resetLegModes(p, 0)
},
},
'transport-details': {
guide: guide('transport-details'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
target: p => card(p, TRAIN.title),
act: async p => {
await card(p, TRAIN.title).click()
await expect(bookingDetail(p, TRAIN.title)).toBeVisible()
await settle(p)
},
},
// Pointed at, not clicked: a headless clipboard refuses the write and
// the picture would carry the failure toast.
only(p => bookingDetail(p, TRAIN.title).getByRole('button', { name: 'Copy booking code' })),
// The tile row: departure and arrival with their stations, then Platform and Seat.
only(p => bookingDetail(p, TRAIN.title).getByText('Platform', { exact: true }).first().locator('xpath=ancestor::div[2]')),
{
prepare: async p => {
await bookingDetail(p, TRAIN.title).getByRole('button', { name: TRAIN.title, exact: true }).click()
await expect(renameDialog(p)).toBeVisible()
await beat(p, 300)
},
target: renameDialog,
// Cancel, not Save: the train keeps the name the other guides find it by.
act: async p => {
await renameDialog(p).getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(renameDialog(p)).toHaveCount(0)
await settle(p)
},
},
{
target: p => bookingDetail(p, TRAIN.title).getByRole('button', { name: 'On map', exact: true }),
act: async p => {
await bookingDetail(p, TRAIN.title).getByRole('button', { name: 'On map', exact: true }).click()
// The Plan opens on day 5 with the train's line switched on.
await expect(transportRowTitled(p, TRAIN.title).getByRole('button', { name: 'Hide booking routes' })).toBeVisible({ timeout: 20_000 })
await settle(p)
await p.waitForTimeout(1500)
},
},
],
cleanup: async p => {
const { tripId } = seededTrip()
await p.evaluate(id => {
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
}, tripId)
},
},
'edit-transport': {
guide: guide('edit-transport'),
start: p => openTrip(p),
@@ -773,34 +599,6 @@ const SCRIPTS: Record<string, GuideScript> = {
}, tripId)
},
},
'export-transports-csv': {
guide: guide('export-transports-csv'),
start: p => openTrip(p, { tab: 'transports' }),
steps: [
{
// A search in force, so the picture shows the tab narrowed to what the file will hold.
prepare: async p => {
await typeInto(p, searchBox(p), 'Kyoto')
await expect(card(p, FLIGHT.title)).toHaveCount(0)
await expect(card(p, TRAIN.title)).toBeVisible()
await settle(p)
},
target: bookingsBar,
},
{
target: exportButton,
act: async p => {
const [download] = await Promise.all([p.waitForEvent('download'), exportButton(p).click()])
expect(download.suggestedFilename()).toBe(CSV_FILE)
await settle(p)
},
},
],
cleanup: async p => {
if (await searchBox(p).isVisible().catch(() => false)) await searchBox(p).fill('')
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
},
},
'airtrail-import': {
guide: guide('airtrail-import'),
// The connection is made here and taken away in cleanup, so the hero and
@@ -812,7 +610,7 @@ const SCRIPTS: Record<string, GuideScript> = {
// 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: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
await settle(p)
},
+26 -137
View File
@@ -1,16 +1,15 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
import { ensureUsers, seededTrip, ADMIN_USERS, JOURNEY_TITLE } from './fixtures'
import { 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 trip's Settings (people, sharing, the linked journal), the columns and
* the undo. They run on the seeded "Autumn in Japan"; what a guide creates (a
* member, a guest, a link, a linked journal) it leaves for the next one or
* takes away again in `cleanup`.
* 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 => {
@@ -19,18 +18,9 @@ const guide = (id: string): HelpGuide => {
return g
}
/**
* A part of the Settings dialog by the words it opens with: a DialogSection
* (its label) or a LinkCard (its title). The areas around them are sections
* too, but they open with the area's own name, so they never match.
*/
/** 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/)
const accessBlock = (page: Page) => block(page, /^Access\s*\d/)
/** An area in the dialog's list on the left; each carries the area's name as its label. */
const area = (page: Page, name: 'People' | 'Sharing' | 'Journal') => modal(page).getByRole('button', { name, exact: true })
/** The Trip settings pill beside the trip's name. */
const settingsPill = (page: Page) => page.getByRole('button', { name: 'Trip settings', exact: true })
/**
* The days column's fold tab: the one button straight inside the column's
* frame, which sits 10px in from the left. Its name is Collapse while the
@@ -38,23 +28,12 @@ const settingsPill = (page: Page) => page.getByRole('button', { name: 'Trip sett
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
*/
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
const leftDivider = (page: Page) => page.getByRole('separator', { name: 'Panel width' }).first()
async function clickSettings(page: Page): Promise<void> {
await settingsPill(page).click()
await expect(modal(page)).toBeVisible()
await expect(modal(page).getByRole('heading', { name: 'Trip settings' })).toBeVisible()
await settle(page)
}
async function openSettings(page: Page): Promise<void> {
async function openShare(page: Page): Promise<void> {
await openTrip(page)
await clickSettings(page)
}
async function pickArea(page: Page, name: 'Sharing' | 'Journal'): Promise<void> {
await area(page, name).click()
await expect(area(page, name)).toHaveAttribute('aria-current', 'page')
await 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)
}
@@ -63,12 +42,6 @@ const closeModal = async (page: Page): Promise<void> => {
await expect(modal(page)).toHaveCount(0)
}
/** Take the journal off the trip again; a trip without one answers that too, which is fine. */
async function unlinkJournal(page: Page): Promise<void> {
const { tripId } = seededTrip()
await page.request.delete(`/api/trips/${tripId}/journal`)
}
const only = (target: (p: Page) => Locator) => ({ target })
const SCRIPTS: Record<string, GuideScript> = {
@@ -77,8 +50,12 @@ const SCRIPTS: Record<string, GuideScript> = {
start: p => openTrip(p),
steps: [
{
target: settingsPill,
act: clickSettings,
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 => {
@@ -90,20 +67,17 @@ const SCRIPTS: Record<string, GuideScript> = {
target: inviteBlock,
act: async p => {
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
await expect(accessBlock(p).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
only(accessBlock),
only(p => block(p, /^Access\s*\d/)),
],
cleanup: closeModal,
},
'trip-invite-link': {
guide: guide('trip-invite-link'),
start: async p => {
await openSettings(p)
await pickArea(p, 'Sharing')
},
start: openShare,
steps: [
{
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
@@ -114,7 +88,6 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
only(p => block(p, /^Trip invite link/)),
// The row with Regenerate and Disable.
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
],
cleanup: async p => {
@@ -125,7 +98,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'add-guest': {
guide: guide('add-guest'),
start: openSettings,
start: openShare,
steps: [
only(p => block(p, /^Guests/)),
{
@@ -136,7 +109,7 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => modal(p).getByPlaceholder('Guest name').locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Add guest' }).click()
await expect(block(p, /^Guests/).getByText('Aunt Lise', { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
@@ -145,26 +118,13 @@ const SCRIPTS: Record<string, GuideScript> = {
},
'public-link': {
guide: guide('public-link'),
start: openSettings,
start: openShare,
steps: [
{
target: p => area(p, 'Sharing'),
act: p => pickArea(p, 'Sharing'),
},
{
// The first row of chips: Map & Plan, Bookings, Packing, Costs, Chat.
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
act: async p => {
await modal(p).getByRole('button', { name: 'Packing', exact: true }).click()
await expect(modal(p).getByRole('button', { name: 'Packing', exact: true })).toHaveAttribute('aria-pressed', 'true')
await settle(p)
},
},
{
// Options with its two chips; the pointer on the first shows what it leaves out.
target: p => modal(p).getByRole('button', { name: 'Travel & stays only' }).locator('xpath=../..'),
hover: async p => {
await modal(p).getByRole('button', { name: 'Travel & stays only' }).hover()
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
await beat(p, 300)
},
},
{
@@ -183,66 +143,17 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
],
// The chips live in the dialog until a link is saved, and the link is gone again.
cleanup: closeModal,
},
'transfer-ownership': {
guide: guide('transfer-ownership'),
start: openSettings,
start: openShare,
steps: [
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
],
cleanup: closeModal,
},
'link-journal': {
guide: guide('link-journal'),
start: async p => {
await unlinkJournal(p)
await openSettings(p)
},
steps: [
{
target: p => area(p, 'Journal'),
act: async p => {
await pickArea(p, 'Journal')
await expect(modal(p).getByRole('button', { name: 'Link a journal' })).toBeVisible({ timeout: 15_000 })
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link a journal' }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link a journal' }).click()
await modal(p).getByRole('button', { name: 'Choose a journal' }).click()
// The select's list is rendered outside the dialog.
await p.getByRole('button', { name: JOURNEY_TITLE, exact: true }).last().click()
await expect(modal(p).getByText('Access for trip members', { exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
// The heading row (Everyone can read / Nobody) and a row per person.
target: p => modal(p).getByText('Access for trip members', { exact: true }).locator('xpath=../..'),
act: async p => {
const edit = modal(p).getByRole('radio', { name: 'Edit' }).first()
await edit.click()
await expect(edit).toHaveAttribute('aria-checked', 'true')
await settle(p)
},
},
{
target: p => modal(p).getByRole('button', { name: 'Link journal', exact: true }),
act: async p => {
await modal(p).getByRole('button', { name: 'Link journal', exact: true }).click()
await expect(modal(p).getByText('Linked', { exact: true })).toBeVisible({ timeout: 15_000 })
await closeModal(p)
await expect(p.getByRole('button', { name: 'Journal', exact: true })).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
],
cleanup: unlinkJournal,
},
'collapse-columns': {
guide: guide('collapse-columns'),
start: p => openTrip(p),
@@ -265,29 +176,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
// The divider is invisible until hovered; the hover shows it.
target: leftDivider,
// Out to a wider column and back again, so the loop ends where it began.
clip: async (p, frame) => {
const box = await leftDivider(p).boundingBox()
if (!box) throw new Error('the days column has no divider')
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await p.mouse.move(x, y)
for (let i = 0; i < 3; i++) await frame()
await p.mouse.down()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + (140 * i) / 16, y, { steps: 2 })
await frame()
}
for (let i = 0; i < 4; i++) await frame()
for (let i = 1; i <= 16; i++) {
await p.mouse.move(x + 140 - (140 * i) / 16, y, { steps: 2 })
await frame()
}
await p.mouse.up()
await p.waitForTimeout(300)
for (let i = 0; i < 3; i++) await frame()
},
target: p => p.locator('div[style*="col-resize"]').first(),
},
],
},
@@ -316,7 +205,7 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => p.getByRole('button', { name: 'Undo' }),
act: async p => {
await p.getByRole('button', { name: 'Undo' }).click()
await expect(p.getByText(/^Undone: /).first()).toBeVisible({ timeout: 15_000 })
await expect(p.getByRole('button', { name: 'Undo' })).toBeDisabled({ timeout: 15_000 })
await settle(p)
},
},
+75 -248
View File
@@ -1,11 +1,7 @@
import { test, expect, type Page, type Locator } from '@playwright/test'
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 { monthName, monthsAhead, year } from '../dates'
import {
PARTNER, ensureFused, ensureSeparate, clearPartnerDays, requestFusionFromPartner, clearFusionRequest,
shareWithAdmin, unshareWithAdmin, ownColor, setOwnColor, ensurePublicCalendar, calendarColors,
restoreCalendarColors, companyHolidayFraction, setCompanyHoliday,
} from './fixtures-vacay'
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'
@@ -14,9 +10,7 @@ import type { HelpGuide } from '../../src/help/types'
* 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. The guides about planning together fuse the seeded
* member mira into the plan through the API (`fixtures-vacay.ts`) and split
* it again in their cleanup, so every other guide finds a plan of one.
* looks like anyway.
*/
const YEAR = year()
@@ -25,22 +19,6 @@ const NEXT_MONTH = monthName(1)
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
const ENTITLEMENT = '28'
/**
* Weekdays of the next month from the 10th on. A fixed day number fell on a
* weekend in some months, and Block Weekends (on by default) ignores the
* click there, so the picture showed an empty cell under a step that says it
* fills.
*/
const WORKDAYS: number[] = Array.from({ length: 19 }, (_, i) => i + 10).filter(d => {
const weekday = new Date(`${monthsAhead(1, d)}T12:00:00Z`).getUTCDay()
return weekday !== 0 && weekday !== 6
})
const [LOG_DAY, HALF_DAY, COMP_DAY, , PARTNER_DAY] = WORKDAYS
/** The `YYYY-MM-DD` of a day number in the next month. */
const nextMonthDate = (n: number): string => monthsAhead(1, n)
/** The day the company-holiday guides mark, whole and then half. */
const CHRISTMAS_EVE = `${YEAR}-12-24`
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
@@ -88,9 +66,6 @@ const day = (page: Page, month: string, n: number) => monthCard(page, month).get
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 window Dissolve opens before it splits the plan; it stacks on top of Settings. */
const dissolveConfirm = (page: Page) =>
page.locator('.trek-modal-enter').filter({ hasText: 'Separate calendars again' }).filter({ has: page.getByRole('button', { name: 'Cancel' }) }).last()
/** 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. */
@@ -192,6 +167,64 @@ const settingsCloseStep: StepAction = {
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
@@ -200,29 +233,13 @@ const settingsCloseStep: StepAction = {
*/
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
// The menu's filter field; its placeholder is the app's "Search".
const search = page.getByPlaceholder('Search', { exact: true }).last()
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()
}
/** A row of the Persons card (a fused person or a pending invite) by its name. */
const personRow = (page: Page, name: string): Locator => cardRow(page, 'Persons', name)
/** Your own row in Persons, the one with the "you" badge. */
const ownPersonRow = (page: Page): Locator =>
sideCard(page, 'Persons').locator('.group').filter({ has: page.getByText('you', { exact: true }) }).first()
/** The first holiday calendar row in Settings: colour square, label, country, region and bin. */
const calendarRow = (page: Page): Locator => settings(page).getByPlaceholder('Label (optional)').first().locator('xpath=../..')
/** The Fusion Request dialog, which opens by itself while an invite waits for an answer. */
const fusionDialog = (page: Page): Locator => page.getByRole('dialog', { name: 'Fusion Request' })
/** What a guide has to put back, read in its start and used in its cleanup. */
let savedColor = ''
let savedCalendarColors = new Map<number, string>()
let christmasEveBefore: number | null = null
const SCRIPTS: Record<string, GuideScript> = {
'log-day': {
guide: guide('log-day'),
@@ -230,16 +247,16 @@ const SCRIPTS: Record<string, GuideScript> = {
steps: [
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, LOG_DAY),
act: async p => { await day(p, NEXT_MONTH, LOG_DAY).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) },
},
{
target: p => day(p, NEXT_MONTH, LOG_DAY),
target: p => day(p, NEXT_MONTH, 14),
act: async p => {
await day(p, NEXT_MONTH, LOG_DAY).click()
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, LOG_DAY).click()
await day(p, NEXT_MONTH, 14).click()
await toTop(p)
},
},
@@ -255,8 +272,8 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
{
target: p => day(p, NEXT_MONTH, HALF_DAY),
act: async p => { await day(p, NEXT_MONTH, HALF_DAY).click(); await settle(p) },
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' }),
@@ -274,9 +291,9 @@ const SCRIPTS: Record<string, GuideScript> = {
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
},
{
target: p => day(p, NEXT_MONTH, COMP_DAY),
target: p => day(p, NEXT_MONTH, 16),
act: async p => {
await day(p, NEXT_MONTH, COMP_DAY).click()
await day(p, NEXT_MONTH, 16).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
await toTop(p)
@@ -387,42 +404,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'half-company-holiday': {
guide: guide('half-company-holiday'),
// A full run finds Christmas Eve a whole company holiday from the guide
// before; the click with Half day on then converts it in place.
start: async p => {
christmasEveBefore = await companyHolidayFraction(p, YEAR, CHRISTMAS_EVE)
await openVacay(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => {
await p.getByRole('button', { name: 'Half day' }).click()
await expect(p.getByRole('button', { name: 'Half day' })).toHaveAttribute('aria-pressed', 'true')
},
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await settle(p)
// Both switches back, so the next guide starts on plain vacation. The page
// stays where it is: the half-filled cell is what the result shows.
await p.getByRole('button', { name: 'Half day' }).click()
await toolbar(p).getByRole('button').first().click()
await settle(p)
},
},
],
cleanup: async p => { await setCompanyHoliday(p, YEAR, CHRISTMAS_EVE, christmasEveBefore) },
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
@@ -431,8 +412,7 @@ const SCRIPTS: Record<string, GuideScript> = {
// The whole row, as in company-holidays step 1: name and switch together.
prepare: openSettings,
target: p => settingRow(p, 'Public Holidays'),
// switchOn rather than a click: a guide run on its own may find it on already.
act: async p => { await switchOn(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
@@ -452,7 +432,7 @@ const SCRIPTS: Record<string, GuideScript> = {
{
prepare: openSettings,
target: p => settingRow(p, 'School Holidays'),
act: async p => { await switchOn(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.
@@ -464,40 +444,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'edit-holiday-calendar': {
guide: guide('edit-holiday-calendar'),
start: async p => {
await ensurePublicCalendar(p)
savedCalendarColors = await calendarColors(p)
await openVacay(p)
},
steps: [
{
prepare: openSettings,
target: calendarRow,
hover: parkPointer,
},
{
// The swatches open before the picture, so it shows what the click offers.
prepare: async p => {
await calendarRow(p).getByTitle('Color').click()
await expect(calendarRow(p).locator('div[style*="display: grid"] > button').first()).toBeVisible()
},
target: p => calendarRow(p).getByTitle('Color'),
act: async p => {
await calendarRow(p).locator('div[style*="display: grid"] > button').nth(1).click()
await settle(p)
},
},
{
// The bin is the row's last button, after the colour square and the two lists.
target: p => calendarRow(p).locator('button').last(),
act: closeSettings,
},
],
cleanup: async p => { await restoreCalendarColors(p, savedCalendarColors) },
},
weekends: {
guide: guide('weekends'),
start: openVacay,
@@ -548,30 +494,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'person-color': {
guide: guide('person-color'),
start: async p => {
savedColor = await ownColor(p)
await openVacay(p)
},
steps: [
{
target: p => ownPersonRow(p).getByTitle('Change color'),
act: async p => { await ownPersonRow(p).getByTitle('Change color').click(); await settle(p) },
},
{
target: p => dialog(p).getByRole('group', { name: 'Color' }),
act: async p => {
// One that is not the current colour, or the pick would change nothing.
await dialog(p).getByRole('group', { name: 'Color' }).getByRole('button', { pressed: false }).nth(4).click()
await expect(p.getByRole('dialog', { name: 'Change color' })).toBeHidden()
await toTop(p)
},
},
],
cleanup: async p => { await setOwnColor(p, savedColor) },
},
invite: {
guide: guide('invite'),
start: openVacay,
@@ -604,101 +526,6 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'answer-fusion-request': {
guide: guide('answer-fusion-request'),
start: async p => {
await requestFusionFromPartner(p)
await openVacay(p)
await expect(fusionDialog(p)).toBeVisible()
},
steps: [
{ target: p => fusionDialog(p).locator('header').first(), hover: parkPointer },
{ target: p => fusionDialog(p).locator('ul').first(), hover: parkPointer },
{
target: p => fusionDialog(p).locator('footer'),
hover: async p => { await fusionDialog(p).getByRole('button', { name: 'Accept & Fuse' }).hover() },
// Declined, so the admin's plan stays as the guides that follow expect it.
act: async p => {
await fusionDialog(p).getByRole('button', { name: 'Decline' }).click()
await expect(fusionDialog(p)).toBeHidden()
await settle(p)
},
},
],
cleanup: clearFusionRequest,
},
'log-for-partner': {
guide: guide('log-for-partner'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => personRow(p, PARTNER.username),
act: async p => {
await personRow(p, PARTNER.username).click()
await expect(toolbar(p).getByRole('button').first()).toContainText(PARTNER.username)
},
},
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, PARTNER_DAY),
act: async p => {
await day(p, NEXT_MONTH, PARTNER_DAY).click()
await settle(p)
await ownPersonRow(p).click()
await toTop(p)
},
},
],
cleanup: async p => {
await clearPartnerDays(p, YEAR, [nextMonthDate(PARTNER_DAY)])
await ensureSeparate(p)
},
},
'dissolve-fusion': {
guide: guide('dissolve-fusion'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
// The whole red section: its name, the people in the plan and the button.
target: p => settings(p).getByText('Dissolve Fusion', { exact: true }).locator('xpath=ancestor::div[contains(@class, "rounded-xl")][1]'),
hover: async p => { await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(dissolveConfirm(p)).toBeVisible()
await settle(p)
},
},
{
// The window asks first; its Dissolve splits the plan.
target: dissolveConfirm,
hover: async p => { await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(settings(p)).toBeHidden()
await expect(personRow(p, PARTNER.username)).toBeHidden()
await p.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await toTop(p)
},
},
],
// Already separate after the last step; this covers a run that failed before it.
cleanup: ensureSeparate,
},
'share-calendar': {
guide: guide('share-calendar'),
start: openVacay,
@@ -726,7 +553,7 @@ const SCRIPTS: Record<string, GuideScript> = {
// the socket; a reload is the fallback should that message not come.
prepare: async p => {
await shareWithAdmin(p)
const incoming = cardRow(p, 'Shared Calendars', PARTNER.username)
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()
},
-15
View File
@@ -1,15 +0,0 @@
import { devices, test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in WebKit. playwright.config.ts
// has no WebKit project; the engine comes from this file-level device, as in
// e2e/urlbar-scroll-1809.spec.ts. Safari is why the rasteriser is
// modern-screenshot: it decodes images in a foreignObject late, and a page
// drawn too early comes out blank. page.route does not see a request the
// service worker answers, so it is blocked.
test.use({ ...devices['Desktop Safari'], serviceWorkers: 'block' });
test('Studio page snapshot: WebKit draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
-13
View File
@@ -1,13 +0,0 @@
import { test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in Chromium. The same flow
// runs in WebKit from page-snapshot-webkit.spec.ts, the engine with the most
// to get wrong about drawing a page through an SVG foreignObject.
// page.route does not see a request the service worker answers, so it is blocked.
test.use({ serviceWorkers: 'block' });
test('Studio page snapshot: Chromium draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
-76
View File
@@ -1,76 +0,0 @@
import type { APIRequestContext } from '@playwright/test';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
/**
* Cover photos for the demo trips.
*
* A drawn cover reads as a placeholder at the size the dashboard shows it, and
* the help pictures are meant to look like a real traveller's TREK. So the
* covers are landscape photographs from Wikimedia Commons under free licences,
* downloaded at run time into `e2e/.tmp/photos/` and never committed: the
* repository carries no photographs. No person is recognisable in any of them.
*
* When Commons cannot be reached the trip simply keeps no cover, which the
* dashboard handles; the run does not fail over a decoration.
*/
export const COVER_PHOTOS = {
// Kiyomizu-dera, Kyoto, November 2016 -02.jpg, CC BY-SA 4.0
japan:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/Kiyomizu-dera%2C_Kyoto%2C_November_2016_-02.jpg/1920px-Kiyomizu-dera%2C_Kyoto%2C_November_2016_-02.jpg',
// Croda dei Toni and Tre Cime di Lavaredo from Monte Specie, 2025 October.jpg, CC BY-SA 4.0
dolomites:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/77/Croda_dei_Toni_and_Tre_Cime_di_Lavaredo_from_Monte_Specie%2C_Val_Pusteria%2C_Trentino-Alto_Adige%2C_Italy%2C_2025_October.jpg/1920px-Croda_dei_Toni_and_Tre_Cime_di_Lavaredo_from_Monte_Specie%2C_Val_Pusteria%2C_Trentino-Alto_Adige%2C_Italy%2C_2025_October.jpg',
// Sella Ronda Overview.jpg, CC0
ski: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/6/60/Sella_Ronda_Overview.jpg/1920px-Sella_Ronda_Overview.jpg',
// Geirangerfjord from Ørnesvingen, 2013 June.jpg, CC BY-SA 3.0
norway:
'https://thumb.wikimedia.org/wikipedia/commons/thumb/2/2b/Geirangerfjord_from_%C3%98rnesvingen%2C_2013_June.jpg/1920px-Geirangerfjord_from_%C3%98rnesvingen%2C_2013_June.jpg',
// RhB ABe 4-4 III mit Bernina-Express am Lago Bianco.jpg, CC BY-SA 3.0
alps: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/7/7c/RhB_ABe_4-4_III_mit_Bernina-Express_am_Lago_Bianco.jpg/1920px-RhB_ABe_4-4_III_mit_Bernina-Express_am_Lago_Bianco.jpg',
// Le tram 28.jpg, CC BY-SA 4.0
lisbon: 'https://thumb.wikimedia.org/wikipedia/commons/thumb/b/b5/Le_tram_28.jpg/1920px-Le_tram_28.jpg',
} as const;
export type CoverPhoto = keyof typeof COVER_PHOTOS;
const PHOTO_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'photos');
// Commons asks every client to name itself; anonymous bursts get a 429.
const USER_AGENT = 'TREK-help-media/1.0 (documentation screenshots; https://github.com/liketrek/TREK)';
/** The photo on disk, downloaded once; null when Commons cannot be reached. */
export async function coverPhoto(key: CoverPhoto): Promise<string | null> {
const file = path.join(PHOTO_DIR, `${key}.jpg`);
if (existsSync(file)) return file;
mkdirSync(PHOTO_DIR, { recursive: true });
for (let attempt = 1; attempt <= 4; attempt++) {
try {
const res = await fetch(COVER_PHOTOS[key], {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(30_000),
});
if (res.status === 429) {
await new Promise((resolve) => setTimeout(resolve, attempt * 5_000));
continue;
}
if (!res.ok) break;
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
return file;
} catch {
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
console.warn(`cover photo "${key}" could not be downloaded; the trip keeps no cover`);
return null;
}
/** Upload the photo as the trip's cover, the way the trip form does. */
export async function setTripCover(api: APIRequestContext, tripId: number, key: CoverPhoto): Promise<void> {
const file = await coverPhoto(key);
if (!file) return;
const { readFileSync } = await import('node:fs');
const res = await api.post(`/api/trips/${tripId}/cover`, {
multipart: { cover: { name: `${key}.jpg`, mimeType: 'image/jpeg', buffer: readFileSync(file) } },
});
if (!res.ok()) console.warn(`cover for trip ${tripId} was refused: ${res.status()} ${await res.text()}`);
}
-5
View File
@@ -2,7 +2,6 @@ import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
import { ensureShowcase } from '../help/showcase'
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
/**
@@ -30,10 +29,6 @@ setup('seed the demo trip', async ({ page, playwright }) => {
}),
)
// The other trips with covers, places and the bookings ahead, so the
// dashboard and the lists around the seeded trip look lived in.
await ensureShowcase(page.request)
expect(result.tripId, 'trip was created').toBeTruthy()
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
+34 -108
View File
@@ -1,7 +1,6 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
import { at, day, ordinal, short } from '../dates'
import { setTripCover } from './photos'
/**
* Demo data for the documentation screenshots.
@@ -40,62 +39,32 @@ const MEMBERS = [
const PLACES = [
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
notes: 'Early is best: the gate is still empty and the light is softer.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0, start: '09:30', end: '11:00', category: 'Attraction' },
notes: 'Go before 08:00 — the gate is empty and the light is better.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
description: 'Immersive digital art museum you walk through barefoot.',
notes: 'Timed entry, book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0, start: '14:00', end: '16:00', category: 'Activity' },
notes: 'Timed entry — book at least a week ahead.',
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
duration_minutes: 45, price: 0, currency: 'JPY', day: 1, start: '18:30', end: '19:15', category: 'Attraction' },
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
description: 'Forest shrine in the middle of the city.',
duration_minutes: 75, price: 0, currency: 'JPY', day: 1, start: '09:30', end: '10:45', category: 'Attraction' },
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
notes: 'The crowds thin out after the first 20 minutes of climbing.',
duration_minutes: 150, price: 0, currency: 'JPY', day: 4, start: '07:45', end: '10:15', category: 'Attraction' },
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
duration_minutes: 60, price: 0, currency: 'JPY', day: 5, start: '08:00', end: '09:00', category: 'Nature' },
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
description: "Five covered blocks of food stalls, known as Kyoto's kitchen.",
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
notes: 'Come hungry. Try the tamagoyaki.',
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5, start: '12:30', end: '14:00', category: 'Shopping' },
{ name: 'Lake Ashi', lat: 35.2009, lng: 139.0247, address: 'Motohakone, Hakone, Kanagawa',
description: 'Pirate-ship ferry across the crater lake, Fuji in view on a clear morning.',
duration_minutes: 60, price: 1200, currency: 'JPY', day: 2, start: '10:00', end: '11:00', category: 'Activity' },
{ name: 'Hakone Open-Air Museum', lat: 35.2446, lng: 139.0503, address: '1121 Ninotaira, Hakone, Kanagawa',
description: 'Sculpture park on a hillside, with a Picasso pavilion and a foot bath.',
duration_minutes: 150, price: 2000, currency: 'JPY', day: 2, start: '13:00', end: '15:30', category: 'Attraction' },
{ name: 'Owakudani', lat: 35.2440, lng: 139.0196, address: 'Sengokuhara, Hakone, Kanagawa',
description: 'Volcanic valley with sulphur vents and the famous black eggs.',
notes: 'Take the ropeway up, the bus back down.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 3, start: '09:00', end: '10:30', category: 'Nature' },
{ name: 'Todai-ji', lat: 34.6890, lng: 135.8398, address: '406-1 Zoshicho, Nara',
description: 'The Great Buddha Hall, still one of the largest wooden buildings in the world.',
duration_minutes: 90, price: 800, currency: 'JPY', day: 6, start: '09:30', end: '11:00', category: 'Attraction' },
{ name: 'Nara Park', lat: 34.6851, lng: 135.8430, address: 'Zoshicho, Nara',
description: 'Open park with over a thousand free-roaming deer.',
notes: 'Deer crackers are sold at the stands, 200 yen a bundle.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 6, start: '11:00', end: '12:30', category: 'Nature' },
{ name: 'Kinkaku-ji', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
description: 'The Golden Pavilion, mirrored in its pond.',
duration_minutes: 60, price: 500, currency: 'JPY', day: 7, start: '09:00', end: '10:00', category: 'Attraction' },
{ name: 'Ryoan-ji', lat: 35.0345, lng: 135.7185, address: '13 Ryoanji Goryonoshitacho, Ukyo Ward, Kyoto',
description: 'The rock garden: fifteen stones, never all visible at once.',
duration_minutes: 60, price: 600, currency: 'JPY', day: 7, start: '10:30', end: '11:30', category: 'Attraction' },
{ name: 'Hanamikoji Street', lat: 35.0027, lng: 135.7751, address: 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto',
description: 'Lantern-lit street of teahouses in Gion.',
notes: 'Respect the no-photo signs on the private side streets.',
duration_minutes: 90, price: 0, currency: 'JPY', day: 8, start: '18:00', end: '19:30', category: 'Attraction' },
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
]
/** Who paid, so the balances in Costs are not all one-sided. */
type Payer = 'admin' | 'mira' | 'jonas'
const EXPENSES: Array<{ name: string; category: string; total_price: number; currency: string; expense_date: string; note?: string; paidBy?: Payer }> = [
const EXPENSES = [
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
@@ -105,33 +74,13 @@ const EXPENSES: Array<{ name: string; category: string; total_price: number; cur
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: day(-4), paidBy: 'mira' },
{ name: 'Shinkansen seat reservations', category: 'transport', total_price: 4320, currency: 'JPY',
expense_date: day(-5), note: 'Covered by the JR Pass, only the reserved seats.', paidBy: 'jonas' },
{ name: 'Sushi breakfast at Tsukiji', category: 'food', total_price: 9600, currency: 'JPY',
expense_date: day(-8), paidBy: 'jonas' },
{ name: 'Suica cards, three top-ups', category: 'transport', total_price: 9000, currency: 'JPY',
expense_date: day(-8), paidBy: 'mira' },
{ name: 'Shibuya Sky tickets', category: 'activities', total_price: 6600, currency: 'JPY',
expense_date: day(-8) },
{ name: 'Ramen in Shinjuku', category: 'food', total_price: 3450, currency: 'JPY',
expense_date: day(-7), paidBy: 'jonas' },
{ name: 'Kaiseki dinner in Gion', category: 'food', total_price: 36000, currency: 'JPY',
expense_date: day(-3), paidBy: 'mira' },
{ name: 'Matcha and wagashi souvenirs', category: 'shopping', total_price: 5400, currency: 'JPY',
expense_date: day(-2), paidBy: 'jonas' },
{ name: 'Travel insurance', category: 'other', total_price: 74, currency: 'EUR',
expense_date: day(-30), note: 'Group policy, two weeks.' },
expense_date: day(-4) },
]
/** Most of it packed already: the trip is running, the list is nearly done. */
const PACKING: Array<{ category: string; items: Array<string | [string, 'open']> }> = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance', 'Printed hotel confirmations', 'Credit card and some yen'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers', 'Merino socks', 'Warm fleece for Hakone', ['Slip-on shoes for temples', 'open']] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera', 'Spare SD card', ['eSIM activation code', 'open']] },
{ category: 'Toiletries', items: ['Toothbrush and paste', 'Sunscreen', 'Small towel for the onsen', ['Travel-size shampoo', 'open']] },
{ category: 'Health', items: ['Plasters', 'Ibuprofen', 'Hand sanitiser', 'Face masks'] },
{ category: 'Day bag', items: ['Water bottle', 'Coin purse', 'Folding umbrella', ['Goshuin book', 'open']] },
const PACKING = [
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
]
/** Chat lines the collab guides look for by their text. */
@@ -139,14 +88,10 @@ export const SEED_CHAT = {
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
}
const TODOS: Array<{ name: string; category: string; due_date?: string; priority?: number; done?: boolean }> = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2, done: true },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1, done: true },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32), done: true },
{ name: 'Order yen at the bank', category: 'Before departure', due_date: day(-14), done: true },
{ name: 'Reserve Shinkansen seats on the Fuji side', category: 'On arrival', due_date: day(-6), priority: 2, done: true },
{ name: 'Send postcards home', category: 'During the trip', due_date: day(-1) },
{ name: 'Buy a goshuin book at Senso-ji', category: 'During the trip' },
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
]
export interface SeedResult {
@@ -205,7 +150,6 @@ export async function seedDemoData(
// 3. The trip, in JPY.
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
const tripId = trip.id
await setTripCover(api, tripId, 'japan')
for (const m of MEMBERS) {
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
@@ -217,25 +161,16 @@ export async function seedDemoData(
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
// 5. Places, then pin each onto its day.
const categories = await call<{ categories: { id: number; name: string }[] }>(api, 'get', '/api/categories')
.then(r => r.categories).catch(() => [] as { id: number; name: string }[])
const placeIds: number[] = []
for (const p of PLACES) {
const { day, start, end, category, ...payload } = p
const categoryId = categories.find(c => c.name === category)?.id
const { day, ...payload } = p
const { place } = await call<{ place: { id: number } }>(
api, 'post', `/api/trips/${tripId}/places`, categoryId ? { ...payload, category_id: categoryId } : payload)
api, 'post', `/api/trips/${tripId}/places`, payload)
placeIds.push(place.id)
const dayId = dayIds[day]
if (dayId) {
const created = await call<{ assignment: { id: number } }>(
api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`, { place_id: place.id }).catch(() => null)
// A day plan with times reads like a real one: the timeline, the gaps
// and the order all come from them.
if (created) {
await call(api, 'put', `/api/trips/${tripId}/assignments/${created.assignment.id}/time`,
{ place_time: start, end_time: end }).catch(() => {})
}
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
{ place_id: place.id }).catch(() => {})
}
}
@@ -251,12 +186,10 @@ export async function seedDemoData(
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
// and a hand-supplied one fights the settlement maths.
const allMembers = [1, ...memberIds]
const payerId = (who: Payer | undefined): number =>
who === 'mira' ? (memberIds[0] ?? 1) : who === 'jonas' ? (memberIds[1] ?? 1) : 1
for (const { paidBy, ...e } of EXPENSES) {
for (const e of EXPENSES) {
await call(api, 'post', `/api/trips/${tripId}/budget`, {
...e,
payers: [{ user_id: payerId(paidBy), amount: e.total_price }],
payers: [{ user_id: 1, amount: e.total_price }],
member_ids: allMembers,
}).catch(() => {})
}
@@ -270,22 +203,15 @@ export async function seedDemoData(
// 8. Packing — category is free text on the item, there is no category resource.
for (const group of PACKING) {
for (const entry of group.items) {
const [name, state] = typeof entry === 'string' ? [entry, 'packed'] : entry
const created = await call<{ item: { id: number } }>(api, 'post', `/api/trips/${tripId}/packing`, {
for (const name of group.items) {
await call(api, 'post', `/api/trips/${tripId}/packing`, {
name, category: group.category, visibility: 'common',
}).catch(() => null)
if (created && state === 'packed') {
await call(api, 'put', `/api/trips/${tripId}/packing/${created.item.id}`, { checked: true }).catch(() => {})
}
}).catch(() => {})
}
}
for (const { done, ...todo } of TODOS) {
const created = await call<{ item: { id: number } }>(api, 'post', `/api/trips/${tripId}/todo`, todo).catch(() => null)
if (created && done) {
await call(api, 'put', `/api/trips/${tripId}/todo/${created.item.id}`, { checked: true }).catch(() => {})
}
for (const t of TODOS) {
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
}
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
@@ -318,7 +244,7 @@ export async function seedDemoData(
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
color: '#ef4444', icon: 'MapPin' })
collectionId = 'id' in created ? created.id : created.collection.id
for (const placeId of placeIds.slice(4, 7)) {
for (const placeId of placeIds.slice(4)) {
await call(api, 'post', '/api/addons/collections/places/from-trip', {
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
}).catch(() => {})
@@ -362,9 +288,9 @@ export async function seedDemoData(
for (const n of [
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
content: 'The 14-day JR Pass covers the Tokyo to Kyoto legs. Activate it at the airport counter on arrival, not before.' },
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp, and being late is genuinely rude.' },
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
]) {
-93
View File
@@ -1,93 +0,0 @@
import type { APIRequestContext, Browser, Page } from '@playwright/test';
import { devices, expect, test } from '@playwright/test';
import { E2E_STATE_FILE } from '../playwright.config';
// A stay booked somewhere else shows in the open planner without a reload. The
// assistant books through its tools, and a stay it booked showed in the planner
// only after a reload: the server told every tab, the client dropped the news.
// Here a second session (a browser context of its own, so the open planner's
// socket is not the one that asked) books the stay over the API, and the planner
// has to show it on its own, on the desktop and on the phone.
/** A trip with three dated days, and their ids in order. */
async function seedTrip(api: APIRequestContext, title: string) {
const created = await api.post('/api/trips', {
data: { title, start_date: '2099-07-01', end_date: '2099-07-03' },
});
expect(created.ok(), 'seed trip').toBeTruthy();
const body = await created.json();
const tripId: number = body.trip?.id ?? body.id;
const days = await (await api.get(`/api/trips/${tripId}/days`)).json();
const dayIds: number[] = (days.days ?? days).map((day: { id: number }) => day.id);
expect(dayIds.length, 'the trip has its three days').toBe(3);
return { tripId, dayIds };
}
/** Opens the planner and waits until its socket joined the trip's room, so no event can slip past it. */
async function openPlanner(page: Page, tripId: number) {
const joined = new Promise<void>((resolve) => {
page.on('websocket', (ws) => {
ws.on('framereceived', (frame) => {
if (String(frame.payload).includes('"joined"')) resolve();
});
});
});
await page.goto(`/trips/${tripId}`);
await joined;
}
/** Books a hotel from the first to the second day, from a session of its own. */
async function bookElsewhere(browser: Browser, tripId: number, dayIds: number[], hotel: string) {
const other = await browser.newContext({ storageState: E2E_STATE_FILE });
try {
const place = await other.request.post(`/api/trips/${tripId}/places`, {
data: { name: hotel, lat: 52.5306, lng: 13.3846 },
});
expect(place.ok(), 'the hotel place').toBeTruthy();
const placeBody = await place.json();
const stay = await other.request.post(`/api/trips/${tripId}/accommodations`, {
data: { place_id: placeBody.place?.id ?? placeBody.id, start_day_id: dayIds[0], end_day_id: dayIds[1] },
});
expect(stay.ok(), 'the stay').toBeTruthy();
} finally {
await other.close();
}
}
test('desktop: a stay booked in another session shows in the day plan without a reload', async ({ page, browser }) => {
const hotel = `Mercure Hotel ${Date.now()}`;
const { tripId, dayIds } = await seedTrip(page.request, `Stay live ${Date.now()}`);
await openPlanner(page, tripId);
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 });
await bookElsewhere(browser, tripId, dayIds, hotel);
// The day head names the hotel of the night, read from the planner's stays.
await expect(page.locator('[data-dp="day-pill"]', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
});
test('phone: a stay booked in another session shows on the day plan without a reload', async ({ browser }) => {
const pixel = devices['Pixel 7'];
const phone = await browser.newContext({
viewport: pixel.viewport,
userAgent: pixel.userAgent,
deviceScaleFactor: pixel.deviceScaleFactor,
isMobile: pixel.isMobile,
hasTouch: pixel.hasTouch,
storageState: E2E_STATE_FILE,
});
try {
const page = await phone.newPage();
const hotel = `Mercure Hotel ${Date.now()}`;
const { tripId, dayIds } = await seedTrip(page.request, `Stay live phone ${Date.now()}`);
await openPlanner(page, tripId);
await expect(page.locator('button[aria-current="true"]')).toBeVisible({ timeout: 30_000 });
await bookElsewhere(browser, tripId, dayIds, hotel);
// The first day's plan carries a chip for the hotel checked into that night.
await expect(page.locator('button', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
} finally {
await phone.close();
}
});
-53
View File
@@ -1,53 +0,0 @@
import { expect, type Page } from '@playwright/test';
import type { SeededSnapshot } from './pageSnapshotHarness';
/** The map tile from another server that the harness puts on the sheet. */
const TILE = 'https://tile.openstreetmap.org/5/16/10.png?e2e=page-snapshot';
/**
* The page snapshot the Studio sends the assistant, taken in a real browser
* (journey-studio.md 3.1): the harness module is loaded into the running app
* through the dev server, draws a seeded spread with the editor's SpreadView
* and rasterises it with modern-screenshot. The image has to be a non-empty
* JPEG at the size the Studio promises the server, and the map tile from
* another server on the sheet must never have been fetched for it. Every
* request to OpenStreetMap's tile servers is answered here, which keeps the
* run off the network, but only the harness's own tile counts: the sheet's
* <img> asks for it as an image, anything else would be the rasteriser
* reaching for it. Its query string sets it apart from the tiles the app
* itself may prefetch from the same servers. The specs block the service
* worker, whose tile cache would answer before the route sees a request.
*/
export async function checkPageSnapshot(page: Page): Promise<void> {
const tileFetches: string[] = [];
await page.route(/^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\//, (route) => {
const request = route.request();
const kind = request.resourceType();
if (request.url().startsWith(TILE) && kind !== 'image') tileFetches.push(kind);
return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' } });
});
await page.goto('/dashboard');
const shot = await page.evaluate(
async ({ harness, tile }: { harness: string; tile: string }): Promise<SeededSnapshot | null> => {
const mod = (await import(/* @vite-ignore */ harness)) as {
snapshotSeededSpread: (tileUrl: string) => Promise<SeededSnapshot | null>;
};
return mod.snapshotSeededSpread(tile);
},
{ harness: '/e2e/support/pageSnapshotHarness.ts', tile: TILE }
);
expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull();
if (!shot) return;
expect(shot.type).toBe('image/jpeg');
expect(shot.head).toEqual([0xff, 0xd8, 0xff]);
expect(shot.bytes).toBeGreaterThan(1_000);
// A double spread at 1280 pixels on its long edge, and the file says so too.
expect(shot.width).toBe(1280);
expect(shot.height).toBe(640);
expect(shot.decodedWidth).toBe(1280);
expect(shot.decodedHeight).toBe(640);
expect(shot.drawnPhotoIds).toEqual([]);
expect(tileFetches, 'the tile from another server is left out before anything is fetched').toEqual([]);
}
-85
View File
@@ -1,85 +0,0 @@
import { bookPageSetupForPreset, bookShapeElementSchema, bookTextElementSchema, type BookSpread } from '@trek/shared';
import { createElement } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { SpreadView } from '../../src/components/Studio/SpreadView';
import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSnapshot';
/**
* Loaded by the page snapshot flows inside the running app, where the dev
* server serves it as a module: draws a seeded spread with the editor's own
* SpreadView and turns it into the JPEG the Studio sends the assistant, so a
* real Chromium and a real WebKit prove the rasteriser works end to end. A map
* tile from another server sits on the sheet and must be left out; the flow
* names the tile and watches its server.
*/
export interface SeededSnapshot {
type: string;
bytes: number;
/** The first bytes of the file: a JPEG starts FF D8 FF. */
head: number[];
width: number;
height: number;
/** The image decoded again, as the browser reads it. */
decodedWidth: number;
decodedHeight: number;
drawnPhotoIds: number[];
}
function seededSpread(): BookSpread {
return {
id: 'sp-seeded',
role: 'inner',
background: '#f4efe6',
elements: [
bookShapeElementSchema.parse({ id: 'shape', kind: 'shape', frame: { x: 20, y: 20, w: 160, h: 90 } }),
bookTextElementSchema.parse({
id: 'title',
kind: 'text',
frame: { x: 230, y: 30, w: 160, h: 30 },
text: 'Lisbon',
}),
],
parked: [],
entryId: null,
};
}
export async function snapshotSeededSpread(tileUrl: string): Promise<SeededSnapshot | null> {
const page = bookPageSetupForPreset('square-210');
const spread = seededSpread();
const size = sheetSizePx(spread, page);
// Off screen as the Studio's stage is; the sheet inside it is what is drawn.
const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:0;inset-inline-start:-20000px';
const sheet = document.createElement('div');
sheet.style.cssText = `position:relative;width:${size.width}px;height:${size.height}px`;
host.appendChild(sheet);
document.body.appendChild(host);
const root = createRoot(sheet);
flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true })));
const tile = document.createElement('img');
tile.src = tileUrl;
sheet.firstElementChild?.appendChild(tile);
try {
const shot = await snapshotSpread(sheet, size, new Set());
if (!shot) return null;
const head = [...new Uint8Array(await shot.image.slice(0, 3).arrayBuffer())];
const bitmap = await createImageBitmap(shot.image);
return {
type: shot.image.type,
bytes: shot.image.size,
head,
width: shot.width,
height: shot.height,
decodedWidth: bitmap.width,
decodedHeight: bitmap.height,
drawnPhotoIds: shot.drawnPhotoIds,
};
} finally {
root.unmount();
host.remove();
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.4",
"version": "4.3.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -46,8 +46,8 @@
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
@@ -68,7 +68,6 @@
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

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