Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31859651d7 | |||
| 98f7549ac5 | |||
| 8e420cc1f1 | |||
| 49afee161d | |||
| bb3c0151d5 | |||
| 3101b36f3d | |||
| ef4eac34c4 | |||
| a518b8dfc2 | |||
| 8523bbb724 | |||
| 464d4e006c | |||
| 1797e1c70d | |||
| 5dc945145f | |||
| a1180b2b19 | |||
| d62fc284e1 | |||
| 8f53d98d45 | |||
| 9b71948497 | |||
| bb10b94046 | |||
| 94c369b913 | |||
| 3f1d1a57cc | |||
| 7c1de6ed82 | |||
| fbf6cd2840 | |||
| 5116f7be2c | |||
| 4f60cc5c98 | |||
| 65bb2d95eb | |||
| cbca3b2851 | |||
| 8a75712041 | |||
| 6e13f7e655 | |||
| 41f3267503 | |||
| 2a4a5617dc | |||
| 43353b9394 | |||
| 4eaf5e6a11 | |||
| 1a722e63fb | |||
| 924e38472e | |||
| bf2efca8f0 | |||
| 2377a63a05 | |||
| 377619abd1 | |||
| 988890866a | |||
| 0dda5bc3e9 | |||
| 7fa8b3fe35 | |||
| 112017a9e8 | |||
| cb5c5ab4a1 | |||
| bed9f53615 | |||
| a8e2486cd3 | |||
| 5d5015066f | |||
| 3e8d192305 | |||
| bafb74f872 | |||
| b71e32cf12 | |||
| 786df612fd | |||
| e6002444a1 | |||
| e453816ded | |||
| b9ca822d51 | |||
| 20537193b6 | |||
| 60611aee65 | |||
| 6c66cff365 | |||
| d3f574f225 | |||
| e5c5ffbc00 | |||
| 562176bd83 | |||
| ae699187f7 | |||
| 1d72fbd25f | |||
| 2d0c0597b3 | |||
| a85a50e186 | |||
| c8eb510bd4 | |||
| beea4c762b | |||
| d924dcf99d | |||
| 371083ed47 | |||
| eaeaf68352 | |||
| 5156fa2c0a | |||
| 6c6e285c3d | |||
| 93e1156aa4 | |||
| dc19807126 | |||
| 5580ff71a9 | |||
| e42622d71d | |||
| 94674e7572 | |||
| 7f14dde362 | |||
| 9765cf3fee | |||
| c38eb7ebf0 | |||
| e653733390 | |||
| 2655e510fc | |||
| 904eb31e62 | |||
| 76ec302526 | |||
| 810feba413 | |||
| 4009588078 | |||
| ef583ede18 | |||
| 9bc543a387 | |||
| 7291555e95 | |||
| 57f0e2f263 | |||
| de043cfb58 | |||
| 69df85bfa2 | |||
| be6702f3ab | |||
| 164a3633ac | |||
| 204ee2ef86 | |||
| baba2de344 | |||
| 49dd2db040 | |||
| 49e306ea48 | |||
| 1d7c8a63e2 | |||
| fb208a88e2 | |||
| 5ae8ac8e70 | |||
| 20bd384feb | |||
| 58302dc9e4 | |||
| b460cfd9a7 | |||
| 2b8d2e790f | |||
| 2fc9b1991a | |||
| 4d81180fc1 | |||
| 0f600d04f5 | |||
| b53257deaf | |||
| b284095ee0 | |||
| e40e576e39 | |||
| 4d9b84f681 | |||
| 223b056de2 | |||
| 981c372640 | |||
| 839d736905 | |||
| b9391a9921 | |||
| 5e0f216b0c | |||
| a249c861e6 | |||
| 2a40b884a1 | |||
| f5be556c27 | |||
| 6b1488994f | |||
| 2ee4ea1e65 | |||
| a1d683d7bc | |||
| ae726ed370 | |||
| f4750352e6 | |||
| 65ee472d66 | |||
| 73fa7aeb19 | |||
| fbba4fd73b | |||
| 2e1dca9fe9 | |||
| aacc7f2c8d | |||
| f833a05461 | |||
| 65fe8cd6e5 | |||
| 3fe34061fd | |||
| 3659297af1 | |||
| f57a0dbd31 | |||
| 3748bdb815 | |||
| 8fa75b3c4e | |||
| 6ec06c90f4 | |||
| 2449616ee9 | |||
| acc294dd8e | |||
| bde2ad2ca8 | |||
| ae79ab8dad | |||
| 6a0c54cb2f | |||
| a8be1c5b45 | |||
| d0cc6fc2f4 | |||
| f016972a4e | |||
| c80152f41d | |||
| f745ca13c9 | |||
| 4f68029b2c | |||
| 64445a5204 | |||
| bff9bcde92 | |||
| 3572f4b8a3 | |||
| ea9e997338 | |||
| ad2d653e9a | |||
| b177daea3c | |||
| 63a7a4d397 | |||
| f4533fc69b | |||
| 1993ed4d28 | |||
| 1969ca57d9 | |||
| b826ecd425 | |||
| 19946039ad | |||
| 6577595058 | |||
| 78366a9024 | |||
| 0331d9950f | |||
| dfe11e9c4c | |||
| fcdb7e48cd | |||
| 008aaeb408 | |||
| 5ff7e54e09 | |||
| 0169be3d79 | |||
| 68907ec811 | |||
| 51bfb5abd1 | |||
| 1d9b9bf838 | |||
| c83eb42730 | |||
| cd407cf80c | |||
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e | |||
| e3268735f2 | |||
| 9a59703a34 | |||
| 200ce58e98 | |||
| a965b2c75e | |||
| 91a834010b | |||
| a8d06193bd | |||
| 97b748555f | |||
| 187c4e2fc8 | |||
| dfd02940b5 | |||
| da0f79e285 | |||
| e877d35e0b | |||
| f3a39d9452 | |||
| b374fdc989 | |||
| b8eb2d909e | |||
| e429b49ef6 | |||
| 73237ffedb | |||
| adcaca3c5f | |||
| c3229afcba | |||
| 12e7bdf6e8 | |||
| 9382fa6dc6 | |||
| 3332495b7a | |||
| 75d8351fec | |||
| ce07c4dc72 | |||
| c5c88691bc | |||
| 827e682684 | |||
| 11a8d1b4d1 | |||
| 692373c8b8 | |||
| f6fd2722d0 | |||
| 9cd227fd81 | |||
| e4b49e1148 | |||
| a87ce65899 | |||
| 06e6e25b25 | |||
| d322363e53 | |||
| a20b682087 | |||
| ad86713fdc | |||
| 66525c66b7 | |||
| 7a484a09d4 | |||
| d72352911e | |||
| 134ab64bce | |||
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 | |||
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 | |||
| e725d4f0a9 | |||
| 36f1b5b7d9 | |||
| acce063dbf | |||
| d229613e0e | |||
| 5622113348 | |||
| cd2ecf897b | |||
| f5af9d1259 | |||
| cecfdf2dcc | |||
| 23b28a1f3f | |||
| f7f6b83d33 | |||
| 097c1da0af | |||
| a74a2cf9b5 | |||
| dc1ff06573 | |||
| fc631d36b6 | |||
| 6463060f67 | |||
| 593f839592 | |||
| a9c5cf6d9a | |||
| f901982903 | |||
| 8f64431ff1 | |||
| c378722d63 | |||
| bfec874d04 | |||
| a36019b7e2 | |||
| bda24ec639 | |||
| 09caf4bc3f | |||
| 9272151c07 | |||
| 0105891355 | |||
| a7f5a114e6 | |||
| 2a4b71b3d9 | |||
| d46b6fb646 | |||
| 4a64fc9d21 | |||
| 5ee8d450a9 | |||
| de86e6383a | |||
| 14b917ee97 | |||
| 7fffd4300f | |||
| fa8272c522 | |||
| c40d00b830 | |||
| 86976aef10 | |||
| c246877f30 | |||
| 1e2ba0f842 | |||
| 64e413330e | |||
| 2e3fd4fc4c | |||
| 1c38d34699 | |||
| ccfb610aab | |||
| 03d6457bde | |||
| 89ad4ec56a | |||
| 2c3b20bb57 | |||
| d705bd9dd0 | |||
| 704621d0c2 | |||
| 9494521ac7 | |||
| 33b006014b |
@@ -26,5 +26,8 @@
|
||||
*.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
|
||||
|
||||
@@ -9,23 +9,73 @@ permissions:
|
||||
# analysis; pull requests get PR analysis with decoration on the PR itself
|
||||
# (the scanner picks the context up from the Actions environment).
|
||||
#
|
||||
# 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.)
|
||||
# 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.
|
||||
# - 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
|
||||
@@ -38,7 +88,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 preflight rather than after it.
|
||||
# compose files. Both run beside the gates rather than after them.
|
||||
# - 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,
|
||||
@@ -69,7 +119,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Changed Areas
|
||||
name: 'Repo: Changed Areas'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -144,36 +194,8 @@ 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: i18n Key Parity
|
||||
name: 'Shared: i18n Parity'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -189,8 +211,8 @@ jobs:
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
server-quality:
|
||||
name: Server Types & Lint
|
||||
server-types:
|
||||
name: 'Server: Types'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -239,8 +261,11 @@ 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.
|
||||
# catches it. src and tests together are past 570,000 lines and need
|
||||
# about 4.3 GB, over Node's default heap.
|
||||
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;
|
||||
@@ -257,6 +282,36 @@ 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
|
||||
@@ -264,8 +319,19 @@ 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
|
||||
@@ -359,12 +425,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-quality because it needs every workspace's
|
||||
# dependencies, which only this job installs.
|
||||
# rather than in client-types-checks because it needs every workspace's
|
||||
# dependencies, which the client jobs do not install.
|
||||
run: npm run lint:knip
|
||||
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
client-types-checks:
|
||||
name: 'Client: Types & Checks'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -380,7 +446,7 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -395,15 +461,6 @@ 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
|
||||
|
||||
@@ -467,36 +524,13 @@ jobs:
|
||||
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
|
||||
run: cd client && npm run theme:lint
|
||||
|
||||
plugin-sdk-quality:
|
||||
name: Plugin SDK Types
|
||||
client-lint:
|
||||
name: 'Client: Lint'
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
|
||||
# installs from its own directory.
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
client-bundle:
|
||||
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
|
||||
|
||||
@@ -507,7 +541,47 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- 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
|
||||
|
||||
client-bundle:
|
||||
name: 'Client: Bundle'
|
||||
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 shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -523,10 +597,11 @@ jobs:
|
||||
run: cd client && npm run check:gl-split
|
||||
|
||||
client-e2e:
|
||||
name: Client End-to-End
|
||||
name: 'Client: End-to-End'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
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
|
||||
|
||||
@@ -580,17 +655,17 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
s3-contract:
|
||||
name: S3 Driver Contract (MinIO)
|
||||
name: 'Server: S3 Contract (MinIO)'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
# 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. (The implicit success() on this
|
||||
# expression also keeps the job skipped when a preflight fails.)
|
||||
# only those two are filtered out.
|
||||
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)
|
||||
|
||||
@@ -606,14 +681,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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: 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: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -671,10 +746,10 @@ jobs:
|
||||
# artifact seeds the file and a change that fixes a statement can lower it
|
||||
# without a local Postgres.
|
||||
postgres-probe:
|
||||
name: Postgres Probe
|
||||
name: 'Server: Postgres Probe'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
services:
|
||||
postgres:
|
||||
@@ -727,11 +802,20 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
server-coverage:
|
||||
name: Server Coverage
|
||||
# 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'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -744,20 +828,63 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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: 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: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd server && npm run test:coverage
|
||||
- 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: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -766,11 +893,18 @@ jobs:
|
||||
path: server/coverage/
|
||||
retention-days: 1
|
||||
|
||||
client-coverage:
|
||||
name: Client Coverage
|
||||
# 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'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -785,13 +919,56 @@ 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 && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd client && npm run test:coverage
|
||||
- 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: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -801,10 +978,11 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
shared-coverage:
|
||||
name: Shared Coverage
|
||||
name: 'Shared: Coverage'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
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
|
||||
|
||||
@@ -827,11 +1005,14 @@ jobs:
|
||||
path: shared/coverage/
|
||||
retention-days: 1
|
||||
|
||||
plugin-sdk-coverage:
|
||||
name: Plugin SDK Coverage
|
||||
# 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'
|
||||
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
|
||||
@@ -850,6 +1031,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
@@ -861,7 +1045,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
scan:
|
||||
name: Scan
|
||||
name: 'Repo: Sonar Scan'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
|
||||
@@ -871,9 +1055,18 @@ 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 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]
|
||||
# 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
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -934,7 +1127,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: Docker Image
|
||||
name: 'Repo: Docker Image'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.image == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1011,7 +1204,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: Helm Chart & Compose
|
||||
name: 'Repo: Helm Chart & Compose'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deploy == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1082,19 +1275,21 @@ jobs:
|
||||
ci-ok:
|
||||
needs:
|
||||
- changes
|
||||
- plugin-facts
|
||||
- i18n-parity
|
||||
- server-quality
|
||||
- client-quality
|
||||
- plugin-sdk-quality
|
||||
- server-types
|
||||
- server-lint-checks
|
||||
- client-types-checks
|
||||
- client-lint
|
||||
- client-bundle
|
||||
- client-e2e
|
||||
- s3-contract
|
||||
- postgres-probe
|
||||
- server-tests
|
||||
- server-coverage
|
||||
- client-tests
|
||||
- client-coverage
|
||||
- shared-coverage
|
||||
- plugin-sdk-coverage
|
||||
- plugin-sdk
|
||||
- docker-image
|
||||
- deploy-manifests
|
||||
# always(): without it a failed dependency would skip this job, and the
|
||||
|
||||
@@ -55,6 +55,8 @@ coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
# Blob reports of a sharded vitest run (test.yml)
|
||||
.vitest-reports
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
|
||||
@@ -134,8 +134,10 @@ 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-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.
|
||||
`.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.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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)
|
||||
# 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,
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
@@ -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` |
|
||||
| No videos | | stills only, by decision |
|
||||
| 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 |
|
||||
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -44,6 +45,12 @@ 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]')
|
||||
|
||||
@@ -97,6 +104,8 @@ 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'),
|
||||
@@ -181,11 +190,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
|
||||
// ── User Defaults ────────────────────────────────────────────────────────
|
||||
'default-map': {
|
||||
guide: guide('default-map'),
|
||||
'default-user-settings': {
|
||||
guide: guide('default-user-settings'),
|
||||
start: opener('admin-defaults'),
|
||||
steps: [
|
||||
only(p => section(p, /^Map$/)),
|
||||
only(p => section(p, 'Default User Settings')),
|
||||
{
|
||||
// The reset link only exists next to a field an admin has set.
|
||||
prepare: async p => {
|
||||
@@ -197,10 +206,72 @@ 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 ──────────────────────────────────────────────────────
|
||||
@@ -209,15 +280,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }),
|
||||
act: async p => {
|
||||
// The composer with the name typed and the tick beside it.
|
||||
prepare: async p => {
|
||||
await section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }).click()
|
||||
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
|
||||
await expect(input).toBeVisible()
|
||||
await typeInto(p, input, 'City Break')
|
||||
await beat(p, 300)
|
||||
// Leave the seed as it is: the second icon next to the field discards the draft.
|
||||
await input.locator('xpath=..').locator('button').nth(1).click()
|
||||
},
|
||||
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()
|
||||
await expect(input).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
@@ -250,10 +325,12 @@ 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().hover()
|
||||
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first()
|
||||
.locator('xpath=ancestor::div[contains(@class,"group")][1]').getByRole('button', { name: 'Edit' }).hover()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -301,6 +378,15 @@ 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'),
|
||||
@@ -313,14 +399,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('instance-keys'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => block(p, 'Google Maps API Key')),
|
||||
only(p => trekApiCard(p)),
|
||||
{
|
||||
prepare: async p => {
|
||||
const details = p.locator('details').filter({ hasText: 'What the key may be used for' })
|
||||
if (!(await details.evaluate(el => (el as HTMLDetailsElement).open))) await details.locator('summary').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
|
||||
// The whole Google block: key, Test and the fold opened under it.
|
||||
target: p => block(p, 'Google Maps API Key'),
|
||||
},
|
||||
only(p => block(p, 'Unsplash API Key')),
|
||||
],
|
||||
@@ -329,8 +416,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('places-transit'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => block(p, 'Place search provider')),
|
||||
only(p => block(p, 'Transit Provider')),
|
||||
only(p => block(p, 'Place search provider')),
|
||||
],
|
||||
},
|
||||
'file-types': {
|
||||
@@ -340,6 +427,29 @@ 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': {
|
||||
@@ -452,6 +562,8 @@ 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$/)),
|
||||
],
|
||||
},
|
||||
@@ -472,6 +584,17 @@ 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()),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -480,8 +603,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: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
|
||||
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]')),
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
|
||||
* They run in the order the context lists them and leave their marks on the
|
||||
* map on purpose: Greece is marked and removed again, a region of Austria and
|
||||
* Bavaria stay, Iceland and Petra land on the bucket list. The countries
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@@ -208,6 +209,33 @@ 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'),
|
||||
@@ -283,6 +311,40 @@ 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,
|
||||
@@ -482,6 +544,103 @@ 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 => {
|
||||
@@ -609,6 +768,10 @@ 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)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, nudgeClock, VIEWPORT, OUT_DIR, 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'
|
||||
|
||||
@@ -13,12 +18,17 @@ 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.
|
||||
* 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".
|
||||
*/
|
||||
|
||||
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
|
||||
@@ -59,9 +69,21 @@ const importTrip = (page: Page, title: string) => modal(page).getByRole('button'
|
||||
* is on screen.
|
||||
*/
|
||||
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
|
||||
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
|
||||
const placeRows = (page: Page) => page.locator('.col-lrow')
|
||||
/**
|
||||
* 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 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. */
|
||||
@@ -94,6 +116,9 @@ 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. */
|
||||
@@ -116,14 +141,12 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
async function finishMapMotion(page: Page): Promise<void> {
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
|
||||
await nudgeClock(page, 10_000)
|
||||
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +286,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('...')
|
||||
const search = page.getByPlaceholder(/^(\.\.\.|Search)$/)
|
||||
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()
|
||||
@@ -568,6 +591,53 @@ 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 => {
|
||||
@@ -649,6 +719,37 @@ 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),
|
||||
@@ -760,6 +861,118 @@ 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. */
|
||||
|
||||
@@ -3,8 +3,10 @@ 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
|
||||
@@ -54,7 +56,7 @@ async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, mont
|
||||
await page.getByRole('button', { name: dayLabel, exact: true }).click()
|
||||
}
|
||||
|
||||
/** Hover-revealed action on a card. */
|
||||
/** One of the four actions in the top-right corner of a card's photo. */
|
||||
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. */
|
||||
@@ -64,6 +66,35 @@ 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'),
|
||||
@@ -129,6 +160,43 @@ 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,
|
||||
@@ -156,7 +224,13 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
|
||||
'cover-image': {
|
||||
guide: guide('cover-image'),
|
||||
start: openDashboard,
|
||||
// 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)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, LISBON, 'Edit'),
|
||||
@@ -165,7 +239,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 coverFixture())
|
||||
await modal(p).locator('input[type="file"]').setInputFiles((await coverPhoto('lisbon')) ?? (await coverFixture()))
|
||||
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
@@ -181,6 +255,48 @@ 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,
|
||||
@@ -293,22 +409,63 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Subscribe to all trips calendar' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Subscribe to all trips calendar' }).click() },
|
||||
target: p => p.getByRole('button', { name: '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: 'Enable calendar subscription' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Enable calendar subscription' }).click()
|
||||
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(p.getByText('Add to Google Calendar')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
// The subscription dialog sits in the shared dialog frame.
|
||||
target: p => p.locator('.trek-modal-backdrop').last().locator('> [role="presentation"], > div').first(),
|
||||
// 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 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.keyboard.press('Escape') },
|
||||
},
|
||||
|
||||
widgets: {
|
||||
@@ -378,21 +535,36 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add timezone' }),
|
||||
act: async p => {
|
||||
// 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 beat(p, 600)
|
||||
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'),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Europe/Lisbon', exact: true }).click()
|
||||
await expect(clockRow(p, 'Lisbon')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Close the picker again so the row's remove button is what the picture shows.
|
||||
await p.getByRole('button', { name: 'Add timezone' }).click()
|
||||
await p.locator('.tz-row').first().hover()
|
||||
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)
|
||||
},
|
||||
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() }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -402,6 +574,7 @@ 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 () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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] }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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()}`);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
@@ -0,0 +1,154 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
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();
|
||||
@@ -0,0 +1,226 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { PICTURE_DAY_ISO } from '../dates'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
@@ -44,9 +44,36 @@ export const VIEWPORT = { width: 1920, height: 1080 }
|
||||
*/
|
||||
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
|
||||
|
||||
/** Put the page on the day the pictures are taken on. Call before navigating. */
|
||||
/** 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.
|
||||
*/
|
||||
async function pinClock(page: Page): Promise<void> {
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
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))
|
||||
}
|
||||
|
||||
export interface StepAction {
|
||||
@@ -67,6 +94,17 @@ 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 {
|
||||
@@ -146,19 +184,30 @@ 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)
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
|
||||
shot = frameFor(union(box, to))
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
|
||||
} else {
|
||||
const frame = frameFor(box)
|
||||
await ring(page, box, n, frame)
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frame })
|
||||
shot = frameFor(box)
|
||||
await ring(page, box, n, shot)
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -399,6 +448,96 @@ 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())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
|
||||
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
|
||||
import { connectJournalDawarich, releaseJournalDawarich, STAY_DAY, writtenEntryIds } from './fixtures-journey'
|
||||
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
@@ -344,6 +345,109 @@ 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')
|
||||
@@ -560,6 +664,45 @@ 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,
|
||||
@@ -671,11 +814,32 @@ 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: /^(Hide|Show) suggestions$/ }).first(),
|
||||
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)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -691,16 +855,268 @@ 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,
|
||||
@@ -718,8 +1134,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.
|
||||
'journey-status': {
|
||||
guide: guide('journey-status'),
|
||||
start: openJournal,
|
||||
@@ -744,6 +1158,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.
|
||||
'link-trip': {
|
||||
guide: guide('link-trip'),
|
||||
start: async p => {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,30 @@ 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)) {
|
||||
@@ -51,6 +75,11 @@ 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)
|
||||
|
||||
@@ -2,15 +2,30 @@ 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 section, 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 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
|
||||
* 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> = {
|
||||
@@ -19,8 +34,10 @@ 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 => {
|
||||
@@ -31,14 +48,31 @@ 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) =>
|
||||
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 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 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')
|
||||
@@ -68,24 +102,20 @@ 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'),
|
||||
@@ -99,6 +129,25 @@ 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': {
|
||||
@@ -106,20 +155,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Theme').getByRole('button', { name: 'Dark', exact: true }).locator('xpath=..'),
|
||||
target: p => section(p, 'Theme').getByRole('group', { name: 'Color Mode' }),
|
||||
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Light', exact: true }).click(); await beat(p, 400) },
|
||||
},
|
||||
{
|
||||
target: p => section(p, 'Theme').getByText('Color scheme', { exact: true }).locator('xpath=..'),
|
||||
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click(); await settle(p) },
|
||||
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) },
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
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) },
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
@@ -131,15 +178,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('readability'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => section(p, 'Readability').getByText('Transparency', { exact: true }).first().locator('xpath=../..')),
|
||||
only(p => section(p, 'Readability').getByText('Text size', { exact: true }).first().locator('xpath=../..')),
|
||||
only(p => rows(p, 'Readability', 'Transparency')),
|
||||
only(p => row(p, 'Readability', 'Text size')),
|
||||
],
|
||||
},
|
||||
'dashboard-widgets': {
|
||||
guide: guide('dashboard-widgets'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => section(p, 'Dashboard widgets')),
|
||||
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 => p.getByRole('button', { name: 'Reset to defaults' })),
|
||||
],
|
||||
},
|
||||
@@ -149,10 +197,15 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('map-provider'),
|
||||
start: opener('settings-map'),
|
||||
steps: [
|
||||
only(p => section(p, 'Map').getByText('Map Provider', { exact: true }).locator('xpath=..')),
|
||||
only(p => section(p, 'Map').getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
|
||||
only(p => section(p, 'Map').getByRole('button', { name: 'Save Map' }).locator('xpath=..')),
|
||||
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'),
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.reload(); await settle(p) },
|
||||
},
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
@@ -160,9 +213,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('notification-channels'),
|
||||
start: opener('settings-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Ntfy')),
|
||||
only(p => section(p, 'Webhook URL')),
|
||||
only(p => page_rows(p)),
|
||||
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$/)),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -172,29 +234,143 @@ 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=..')),
|
||||
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]')),
|
||||
],
|
||||
},
|
||||
'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: [
|
||||
{
|
||||
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
|
||||
act: async p => {
|
||||
prepare: async p => {
|
||||
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Dawarich'), 'Home Assistant')
|
||||
await modal(p).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(modal(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
|
||||
await 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),
|
||||
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 settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
target: p => dialog(p),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dialog(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(p.getByRole('dialog')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -204,15 +380,36 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('mcp-oauth'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
only(p => section(p, 'MCP Configuration').getByText('MCP Endpoint', { exact: true }).locator('xpath=..')),
|
||||
only(p => field(p, 'MCP Configuration', 'MCP Endpoint')),
|
||||
{
|
||||
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=../..'),
|
||||
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)
|
||||
},
|
||||
},
|
||||
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')),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -223,17 +420,25 @@ 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 => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
|
||||
only(p => row(p, 'Offline mode', 'Force offline mode')),
|
||||
],
|
||||
},
|
||||
'offline-conflicts': {
|
||||
guide: guide('offline-conflicts'),
|
||||
start: opener('settings-offline'),
|
||||
// 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)
|
||||
},
|
||||
steps: [
|
||||
// The conflicts section only exists while a conflict is open; the cache card shows their count.
|
||||
only(p => section(p, 'Offline cache')),
|
||||
only(p => p.getByRole('button', { name: 'Re-sync now' })),
|
||||
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=../..')),
|
||||
],
|
||||
cleanup: async p => { await clearParkedChanges(p); await p.reload(); await settle(p) },
|
||||
},
|
||||
|
||||
// ── Account ──────────────────────────────────────────────────────────────
|
||||
@@ -241,7 +446,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('profile'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
|
||||
// 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').getByRole('button', { name: 'Save Profile' })),
|
||||
],
|
||||
},
|
||||
@@ -249,9 +456,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('password'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => section(p, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
|
||||
{
|
||||
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').getByRole('button', { name: 'Update password' })),
|
||||
],
|
||||
cleanup: async p => { await p.reload(); await settle(p) },
|
||||
},
|
||||
mfa: {
|
||||
guide: guide('mfa'),
|
||||
@@ -277,7 +492,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
|
||||
// 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=../..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -285,7 +501,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('passkeys'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
|
||||
// A passkey needs a device prompt the run cannot answer, so this guide has no pictures.
|
||||
{},
|
||||
{},
|
||||
],
|
||||
@@ -299,25 +515,28 @@ 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]
|
||||
|
||||
// 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.
|
||||
/** 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.
|
||||
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' } })
|
||||
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook,push' } })
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 { dotted } from '../dates'
|
||||
import { day } 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,6 +54,29 @@ 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. */
|
||||
@@ -128,12 +151,14 @@ 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 DD.MM.YYYY box, which is one fill instead of
|
||||
* a month of grid cells that move with the trip's dates.
|
||||
* 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.
|
||||
*/
|
||||
async function setDate(page: Page, nth: number, value: string): Promise<void> {
|
||||
await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click()
|
||||
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
|
||||
const box = modal(page).getByRole('textbox', { name: 'Enter date manually' })
|
||||
await box.fill(value)
|
||||
await box.press('Enter')
|
||||
await expect(box).toHaveCount(0)
|
||||
@@ -248,6 +273,53 @@ 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,
|
||||
@@ -277,9 +349,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await setDate(p, 0, dotted(-2))
|
||||
await setDate(p, 0, day(-2))
|
||||
await timeBox(p).nth(0).fill('16:30')
|
||||
await setDate(p, 1, dotted(-2))
|
||||
await setDate(p, 1, day(-2))
|
||||
await timeBox(p).nth(1).fill('20:00')
|
||||
await settle(p)
|
||||
},
|
||||
@@ -403,6 +475,35 @@ 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,
|
||||
@@ -631,6 +732,41 @@ 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
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
|
||||
@@ -15,7 +16,10 @@ 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.
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
@@ -105,6 +109,31 @@ 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. */
|
||||
@@ -131,6 +160,13 @@ 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>
|
||||
@@ -221,6 +257,158 @@ 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
|
||||
@@ -83,15 +84,14 @@ 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' })
|
||||
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
|
||||
/** 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: / })
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const settleRow = (page: Page, initial: string) =>
|
||||
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
|
||||
const settleJonas = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: jonas → / })
|
||||
|
||||
// ── 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,6 +422,72 @@ 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,
|
||||
@@ -502,9 +568,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
only(p => card(p, 'Settle up')),
|
||||
{
|
||||
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
|
||||
target: settleJonas,
|
||||
act: async p => {
|
||||
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
|
||||
await settleJonas(p).click()
|
||||
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
|
||||
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
@@ -524,6 +590,7 @@ 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' }),
|
||||
|
||||
@@ -22,7 +22,10 @@ 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: the only `fixed z-50` in the client. */
|
||||
/**
|
||||
* The panel itself. PageSidebar's drawer is `fixed z-50` too, but it is
|
||||
* `lg:hidden` and never on screen at the pictures' width.
|
||||
*/
|
||||
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} `) })
|
||||
@@ -58,6 +61,13 @@ 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
|
||||
@@ -116,6 +126,17 @@ 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.
|
||||
@@ -281,10 +302,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
only(p => stayCard(p, TOKYO_HOTEL)),
|
||||
{
|
||||
// Neither the pencil nor the × carries a label; the icon is the handle.
|
||||
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'),
|
||||
// The pencil and the X are RoundActions named by their tooltips.
|
||||
target: p => stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Edit accommodation', exact: true }),
|
||||
act: async p => {
|
||||
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
|
||||
await stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Edit accommodation', exact: true }).click()
|
||||
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -311,11 +332,49 @@ 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).locator('button:has(svg.lucide-x)'),
|
||||
target: p => stayCard(p, TOKYO_HOTEL).getByRole('button', { name: 'Remove', exact: true }),
|
||||
},
|
||||
],
|
||||
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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, 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,8 +21,9 @@ 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
|
||||
@@ -33,7 +34,10 @@ const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of t
|
||||
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
|
||||
@@ -44,13 +48,16 @@ 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 phase (Departure / Arrival) and title make its name. */
|
||||
/** A booking row of a day: its title, its time and its phase (Departure / Arrival) 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 "+ Day" at the end of a places row: it puts the place on the open day. */
|
||||
/** The round + at the end of a places row (named "+ Day"): 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' })
|
||||
@@ -60,14 +67,8 @@ 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 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.
|
||||
*/
|
||||
/** 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()
|
||||
/**
|
||||
* 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
|
||||
@@ -81,6 +82,31 @@ 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()
|
||||
@@ -98,10 +124,58 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
async function openWithRoute(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, ROUTED_DAY)
|
||||
@@ -117,52 +191,36 @@ 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),
|
||||
@@ -173,23 +231,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('place-onto-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
// Day 1 stays open: the "+" of a place row and "To day" exist only while
|
||||
// Day 1 stays open: the round + of a place row and To day exist only while
|
||||
// a day is selected, and steps 2 and 4 are about those two.
|
||||
await openTripOnDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => placeRow(p, SPARE.name),
|
||||
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await placeRow(p, SPARE.name).dragTo(dayHeader(p, 2))
|
||||
dropTo: p => dayHeader(p, 2),
|
||||
clip: async (p, frame) => {
|
||||
await dragLoop(p, placeRow(p, SPARE.name), dayHeader(p, 2), frame)
|
||||
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
await holdResult(p, frame)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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),
|
||||
},
|
||||
@@ -200,26 +258,47 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
'reorder-stops': {
|
||||
guide: guide('reorder-stops'),
|
||||
start: p => openTrip(p),
|
||||
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)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
|
||||
// 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'),
|
||||
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('button:has(svg.lucide-chevron-up)'),
|
||||
target: p => stop(p, 'teamLab Planets').locator('.reorder-buttons'),
|
||||
hover: async p => { await stop(p, 'teamLab Planets').hover() },
|
||||
},
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets'),
|
||||
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Only a move that breaks the day's chronology asks, and the arrows
|
||||
// never do here: the timed stop is the first row of the day. Dropping
|
||||
// it below the 13:05 departure does, and Cancel leaves the day alone.
|
||||
const booking = bookingRow(p, /Departure.*LH716/)
|
||||
const booking = bookingRow(p, /^LH716 FRA → HND .*departure/i)
|
||||
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 } })
|
||||
@@ -234,17 +313,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: async p => {
|
||||
await stop(p, 'teamLab Planets').click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
prepare: p => stopMenu(p, 'teamLab Planets', 'Edit'),
|
||||
target: menu,
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
||||
@@ -275,11 +354,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`)
|
||||
const body = (await res.json()) as { assignments?: { id: number; place?: { name: string }; place_name?: string }[] }
|
||||
for (const a of body.assignments ?? []) {
|
||||
if ((a.place?.name ?? a.place_name) === 'teamLab Planets') {
|
||||
const { tripId } = seededTrip()
|
||||
const teamLab = await placeIdOf(p, 'teamLab Planets')
|
||||
for (const a of await assignmentsOf(p, 0)) {
|
||||
if (a.place_id === teamLab) {
|
||||
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
|
||||
}
|
||||
}
|
||||
@@ -289,7 +367,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()
|
||||
@@ -298,11 +376,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await stop(p, SPARE.name).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Remove from day' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
prepare: p => stopMenu(p, SPARE.name, 'Remove from day'),
|
||||
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
|
||||
@@ -314,6 +388,41 @@ 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),
|
||||
@@ -340,7 +449,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()
|
||||
@@ -372,17 +481,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await noteRow(p).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: noteRow,
|
||||
act: closeMenu,
|
||||
},
|
||||
// Hovered by the capture: the grip, the arrows and the three dots come out.
|
||||
only(noteRow),
|
||||
],
|
||||
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 }[]
|
||||
@@ -404,6 +507,7 @@ 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 => {
|
||||
@@ -414,10 +518,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: menu,
|
||||
act: closeMenu,
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Optimize' })),
|
||||
only(p => routeBar(p).getByRole('button', { name: 'Optimize' })),
|
||||
// The two hand-offs are siblings with no wrapper of their own: their
|
||||
// parent is the whole route bar, which is already read-day-plan's step 5.
|
||||
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
|
||||
only(p => routeBar(p).getByRole('button', { name: 'Open in Google Maps' })),
|
||||
],
|
||||
},
|
||||
'manage-days': {
|
||||
@@ -428,11 +532,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Reorder days' }).click()
|
||||
await expect(p.getByText('Reorder days').first()).toBeVisible()
|
||||
await expect(reorderPopup(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The whole popup: the rows with their arrows and bins, and the two ways
|
||||
// of adding a day in its footer.
|
||||
// The whole popup: the rows with their grips, arrows and bins, and the
|
||||
// two ways of adding a day in its footer.
|
||||
target: reorderPopup,
|
||||
},
|
||||
{
|
||||
@@ -471,7 +575,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': {
|
||||
@@ -481,8 +585,9 @@ 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.
|
||||
only(p => bookingRow(p, /^LH716 FRA → HND Departure/)),
|
||||
// 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)),
|
||||
{
|
||||
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
|
||||
target: p => stop(p, 'Nishiki Market'),
|
||||
@@ -496,13 +601,45 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
||||
act: closeDayDetails,
|
||||
},
|
||||
only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })),
|
||||
only(p => p.getByRole('button', { name: /^(Show|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),
|
||||
@@ -521,6 +658,39 @@ 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 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
|
||||
@@ -118,6 +119,17 @@ 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
|
||||
@@ -174,6 +186,15 @@ 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'),
|
||||
@@ -570,13 +591,67 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
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 => {
|
||||
const { tripId } = seededTrip()
|
||||
await resetDocSync(p.request, tripId)
|
||||
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
|
||||
// The run pushed the trip's own documents into the folder; back to its two.
|
||||
await ensureNextcloudFolder(drawPdf)
|
||||
await ensureNextcloudBinding(p.request, tripId)
|
||||
await openFiles(p)
|
||||
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: syncButton,
|
||||
act: async p => {
|
||||
await syncButton(p).click()
|
||||
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
|
||||
// The 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -588,7 +663,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 all seven guides show the same bar.
|
||||
// the whole file, so the hero and every guide show the same bar.
|
||||
await toggleAddon(request, 'nextcloud', true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, beat, dragLoop, 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 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
@@ -21,10 +27,10 @@ const guide = (id: string): HelpGuide => {
|
||||
return g
|
||||
}
|
||||
|
||||
/** What the guides make and unmake. Nothing here is in the seed. */
|
||||
const NEW_LIST = 'Toiletries'
|
||||
const FIRST_ITEM = 'Toothbrush'
|
||||
const RENAMED_ITEM = 'Toothbrush and paste'
|
||||
/** 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'
|
||||
const MEMBER = 'mira'
|
||||
const SHARED_ITEM = 'Camera'
|
||||
const BAG_ITEM = 'Rain jacket'
|
||||
@@ -34,36 +40,60 @@ const NEW_TASK = 'Pick up the pocket wifi'
|
||||
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
|
||||
const TASK_LIST = 'On arrival'
|
||||
const OTHER_TASK_LIST = 'Before departure'
|
||||
const EDITED_TASK = 'Activate JR Pass'
|
||||
/** 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 ASSIGNEE = 'jonas'
|
||||
/** The date picker labels a day cell with the whole date, in the run's English. */
|
||||
const DUE_DAY = pickerLabel(4)
|
||||
/** The fixtures' template, and the lists it brings with it. */
|
||||
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] }
|
||||
const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30'
|
||||
const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters']
|
||||
/** The seed's packing items, in the order it created them. */
|
||||
const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera']
|
||||
const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance']
|
||||
/** The 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 count in the label moves with what is ticked, so it is matched, not spelled. */
|
||||
const CLEAR_CHECKED = /^Remove \d+ checked$/
|
||||
|
||||
// ── Locators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A category card: the list's name is a span in the card's header row. */
|
||||
/** A category card: the list's name is a span in the card's head band. */
|
||||
const cat = (page: Page, name: string): Locator =>
|
||||
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
|
||||
/**
|
||||
* The header row of a card. The ⋯ has to be taken from here: every item row
|
||||
* The head band of a card. The ⋯ has to be taken from here: every item row
|
||||
* carries a ⋯ of its own, so the card as a whole holds several of them.
|
||||
*/
|
||||
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
|
||||
const catMenu = (page: Page, name: string): Locator =>
|
||||
catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)')
|
||||
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' })
|
||||
/** 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. */
|
||||
@@ -71,6 +101,18 @@ 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. */
|
||||
@@ -83,7 +125,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 now, so they are found by their caption rather than inside the row.
|
||||
* body, 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()
|
||||
@@ -93,7 +135,6 @@ 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')
|
||||
@@ -107,6 +148,12 @@ 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 })
|
||||
|
||||
@@ -132,10 +179,25 @@ 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[]> {
|
||||
@@ -145,31 +207,6 @@ 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 }[] }
|
||||
@@ -178,6 +215,16 @@ 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`)
|
||||
@@ -200,7 +247,7 @@ async function unassignTodo(page: Page, name: string): Promise<void> {
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'packing-categories': {
|
||||
guide: guide('packing-categories'),
|
||||
start: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add list' }),
|
||||
@@ -277,36 +324,78 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await deleteItems(p, row => row.category === NEW_LIST)
|
||||
await resetPacking(p)
|
||||
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: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
// The grip is a div, so the tick box is the row's first button.
|
||||
target: p => item(p, 'Passport').getByRole('button').first(),
|
||||
target: p => item(p, OPEN_ITEM).getByRole('button').first(),
|
||||
act: async p => {
|
||||
await item(p, 'Passport').getByRole('button').first().click()
|
||||
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await 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 settle(p)
|
||||
},
|
||||
},
|
||||
// The count and the percentage, with the bar beside them: the whole card is
|
||||
// wider than any frame and would lose both at its edges.
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
only(progressCount),
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, 'Documents').click()
|
||||
await catMenu(p, CHECK_ALL_LIST).click()
|
||||
await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Check All', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Check All', exact: true }).click()
|
||||
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(item(p, CHECK_ALL_OPEN).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -314,28 +403,74 @@ 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(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(item(p, PACKED_ITEM)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: CLEAR_CHECKED }),
|
||||
act: async p => {
|
||||
// A native window.confirm, which Playwright dismisses unless something
|
||||
// accepts it first. The browser's own dialog cannot be pictured, so
|
||||
// the step shows the button and the state it leaves behind.
|
||||
p.once('dialog', d => { void d.accept() })
|
||||
// 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() })
|
||||
await p.getByRole('button', { name: CLEAR_CHECKED }).click()
|
||||
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: restoreDocuments,
|
||||
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,
|
||||
},
|
||||
'apply-packing-template': {
|
||||
guide: guide('apply-packing-template'),
|
||||
start: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Apply template' }),
|
||||
@@ -367,13 +502,13 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
|
||||
await resetPacking(p)
|
||||
await deleteTemplate(p, SAVED_TEMPLATE)
|
||||
},
|
||||
},
|
||||
'import-packing-list': {
|
||||
guide: guide('import-packing-list'),
|
||||
start: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
// An icon at the right end of the bar, named Import by its label and tooltip.
|
||||
@@ -407,16 +542,17 @@ 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, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(item(p, IMPORTED_LAST)).toBeVisible({ timeout: 30_000 })
|
||||
await item(p, IMPORTED_LAST).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
|
||||
cleanup: resetPacking,
|
||||
},
|
||||
'export-packing-list': {
|
||||
guide: guide('export-packing-list'),
|
||||
start: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Export', exact: true }),
|
||||
@@ -449,7 +585,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
'share-packing-item': {
|
||||
guide: guide('share-packing-item'),
|
||||
start: openLists,
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^My list/ }),
|
||||
@@ -518,18 +654,23 @@ 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: openLists,
|
||||
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)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// The weight field's placeholder really is a dash: it is the app's own
|
||||
// string, not prose, so it is matched exactly as rendered.
|
||||
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
|
||||
target: p => item(p, BAG_ITEM).getByPlaceholder('—'),
|
||||
act: async p => {
|
||||
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
|
||||
const field = item(p, BAG_ITEM).getByPlaceholder('—')
|
||||
await typeInto(p, field, '620')
|
||||
await expect(field).toHaveValue('620', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
@@ -557,6 +698,8 @@ 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' }),
|
||||
@@ -581,20 +724,15 @@ 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 => {
|
||||
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 } })
|
||||
await deleteBag(p, BAG)
|
||||
await resetPacking(p)
|
||||
},
|
||||
},
|
||||
'create-todo': {
|
||||
@@ -625,7 +763,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 p.getByRole('button', { name: TASK_LIST, exact: true }).click()
|
||||
await selectList(p, 'No list').getByRole('button', { name: TASK_LIST, exact: true }).click()
|
||||
await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -657,7 +795,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 p.getByRole('button', { name: ASSIGNEE }).click()
|
||||
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
|
||||
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
@@ -702,7 +840,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(EDITED_TASK, { timeout: 20_000 })
|
||||
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(PRIORITY_TASK, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -717,7 +855,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: async p => {
|
||||
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await p.getByRole('button', { name: ASSIGNEE }).click()
|
||||
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
|
||||
@@ -739,6 +877,7 @@ 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 () => {
|
||||
@@ -747,7 +886,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, openLists)
|
||||
await captureHero(page, tripListsContext.id, startLists)
|
||||
})
|
||||
|
||||
for (const id of tripListsContext.guides) {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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'
|
||||
|
||||
@@ -10,7 +12,8 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
* Actions for the map in the middle of a trip's plan, keyed by the ids in
|
||||
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
|
||||
* toggle has a second route to draw inside the frame; what a guide changes it
|
||||
* 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
|
||||
* puts back in `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
@@ -91,6 +94,65 @@ 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()
|
||||
/**
|
||||
@@ -303,6 +365,10 @@ 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'
|
||||
|
||||
@@ -708,6 +774,80 @@ 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'),
|
||||
@@ -748,6 +888,38 @@ 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,
|
||||
@@ -775,6 +947,9 @@ 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)
|
||||
@@ -790,6 +965,8 @@ 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 () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, beat, settle, typeInto, 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'
|
||||
@@ -35,6 +36,13 @@ 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=..')
|
||||
@@ -55,7 +63,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. */
|
||||
@@ -77,10 +85,20 @@ 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. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function closeCard(page: Page): Promise<void> {
|
||||
await head(page).locator('button:has(svg.lucide-x)').click()
|
||||
await head(page).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(card(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
@@ -291,7 +309,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
|
||||
},
|
||||
{
|
||||
target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }),
|
||||
target: avatar,
|
||||
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 })
|
||||
@@ -303,6 +321,111 @@ 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 }),
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, gpxFixture, ensureTrack } from './fixtures'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, gpxFixture } 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 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
@@ -38,15 +43,27 @@ 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 import button's menu: Import file and the list import. */
|
||||
/** 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. */
|
||||
const importMenu = (page: Page) =>
|
||||
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last()
|
||||
/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */
|
||||
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. */
|
||||
const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true })
|
||||
const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..')
|
||||
/** The category filter: a tag button whose list sits right after it. */
|
||||
/**
|
||||
* 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]')
|
||||
const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true })
|
||||
const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]')
|
||||
const 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 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=..')
|
||||
@@ -62,10 +79,34 @@ 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`)
|
||||
@@ -86,7 +127,13 @@ 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) => sortTrigger(page).locator('xpath=following-sibling::div[1]')
|
||||
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()
|
||||
/** 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' }
|
||||
|
||||
@@ -234,9 +281,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
|
||||
target: p => dayHeader(p, 1),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
|
||||
await dayHeader(p, 1).click()
|
||||
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -270,11 +317,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, SPARE.name),
|
||||
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await row(p, SPARE.name).dragTo(p.getByRole('button', { name: /^2 .*Day 2 / }))
|
||||
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)
|
||||
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()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -284,6 +334,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
guide: guide('filter-places'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await resolveRegions(p)
|
||||
await openTrip(p)
|
||||
},
|
||||
steps: [
|
||||
@@ -325,9 +376,33 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
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() },
|
||||
// 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)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
@@ -421,7 +496,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
'import-places-file': {
|
||||
guide: guide('import-places-file'),
|
||||
start: p => openTrip(p),
|
||||
// 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)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -455,7 +534,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
|
||||
cleanup: async p => {
|
||||
await deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa')
|
||||
await setTours(p.request, true)
|
||||
},
|
||||
},
|
||||
'import-places-list': {
|
||||
guide: guide('import-places-list'),
|
||||
@@ -467,10 +549,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await expect(importMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
// Google List while Google is the only list provider, List Import once Naver is on too.
|
||||
target: p => importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }),
|
||||
// With Tours on, the list import is the menu's only entry.
|
||||
target: p => importMenu(p).getByRole('button', { name: LIST_IMPORT }),
|
||||
act: async p => {
|
||||
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
|
||||
await importMenu(p).getByRole('button', { name: LIST_IMPORT }).click()
|
||||
await expect(listImportDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -492,14 +574,69 @@ 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.beforeAll(async ({ request }) => {
|
||||
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
|
||||
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('every registered places guide has a script, and only those', async () => {
|
||||
@@ -513,6 +650,8 @@ 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])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, settle, dragLoop, nudgeClock, 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'
|
||||
|
||||
@@ -9,7 +13,9 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
* Actions for road trip mode, keyed by the ids in
|
||||
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
|
||||
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
|
||||
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
|
||||
* 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
|
||||
* 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.
|
||||
@@ -24,11 +30,15 @@ const guide = (id: string): HelpGuide => {
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
|
||||
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const dayCard = (page: Page, day: number) =>
|
||||
rail(page)
|
||||
.locator('> section')
|
||||
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
|
||||
.filter({ has: page.locator(`header[aria-label="Day ${day}"]`) })
|
||||
.first()
|
||||
/**
|
||||
* One stop's row and everything hanging off it. The kind disc sits inside the
|
||||
@@ -49,8 +59,27 @@ 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$/ }).first()
|
||||
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)
|
||||
}
|
||||
/** 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. */
|
||||
@@ -58,6 +87,31 @@ 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,14 +154,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 (`PICTURE_DAY`), so
|
||||
* `+new Date()`, and the clock is pinned to the picture day (guide.ts), so
|
||||
* the pan starts and never gets anywhere: the map stays where it was. Moving the
|
||||
* pinned clock a few seconds on ends it on the next frame, as a second passing
|
||||
* would; nothing in these pictures reads the time to the second.
|
||||
*/
|
||||
async function finishMapPan(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400)
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
|
||||
await nudgeClock(page)
|
||||
await page.waitForTimeout(1200)
|
||||
await settle(page)
|
||||
}
|
||||
@@ -126,6 +180,13 @@ 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(() => {
|
||||
@@ -137,13 +198,16 @@ 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
|
||||
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
|
||||
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 })
|
||||
}
|
||||
})
|
||||
return found
|
||||
@@ -158,11 +222,16 @@ 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) throw new Error('no drawn leg is reachable on the map')
|
||||
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)`)
|
||||
}
|
||||
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]
|
||||
@@ -178,6 +247,86 @@ 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 }
|
||||
@@ -321,9 +470,13 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
|
||||
// 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)/ }),
|
||||
act: async p => {
|
||||
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
|
||||
await stopItem(p, 'Hakone Shrine').getByRole('button', { name: /^(Time at this stop|Add a stay)/ }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -335,16 +488,66 @@ 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, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
|
||||
await expect(stopItem(p, 'Hakone Shrine')).toContainText('1 h', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
|
||||
// Zero, not null: the update folds a null into "leave it alone".
|
||||
await restorePlace(p, 'Hakone Shrine', { duration_minutes: 0 })
|
||||
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 => {
|
||||
@@ -419,6 +622,74 @@ 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,
|
||||
@@ -494,13 +765,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
|
||||
// Shibuya the router knows one sensible way, and the bar says only that.
|
||||
// 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,
|
||||
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)
|
||||
},
|
||||
@@ -681,6 +962,74 @@ 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,
|
||||
@@ -726,6 +1075,110 @@ 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 ───────────────────────────────────────────────────────────────────────
|
||||
@@ -734,6 +1187,7 @@ test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureRoadtripFixtures(request)
|
||||
await overnightFlight(request)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
@@ -741,6 +1195,7 @@ 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 () => {
|
||||
|
||||
@@ -27,14 +27,22 @@ 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)
|
||||
// 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 tripReady(page)
|
||||
await settle(page)
|
||||
await untilTranslated(page)
|
||||
if (!tab && day) {
|
||||
@@ -62,7 +70,7 @@ async function untilTranslated(page: Page): Promise<void> {
|
||||
await page.reload()
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await tripReady(page)
|
||||
await settle(page)
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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]);
|
||||
});
|
||||
}
|
||||
@@ -20,8 +20,25 @@ const guide = (id: string): HelpGuide => {
|
||||
return g
|
||||
}
|
||||
|
||||
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
|
||||
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
|
||||
/**
|
||||
* The 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 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.
|
||||
@@ -56,6 +73,13 @@ 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. */
|
||||
@@ -100,6 +124,25 @@ 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()
|
||||
@@ -303,6 +346,88 @@ 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
|
||||
@@ -509,6 +634,55 @@ 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),
|
||||
@@ -599,6 +773,34 @@ 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
|
||||
@@ -610,7 +812,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: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(p.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ensureUsers, seededTrip, ADMIN_USERS } from './fixtures'
|
||||
import { ensureUsers, seededTrip, ADMIN_USERS, JOURNEY_TITLE } from './fixtures'
|
||||
import { openTrip, openTripOnDay, modal } from './trip-shared'
|
||||
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
|
||||
* the 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`.
|
||||
* the trip's Settings (people, sharing, the linked journal), the columns and
|
||||
* the undo. They run on the seeded "Autumn in Japan"; what a guide creates (a
|
||||
* member, a guest, a link, a linked journal) it leaves for the next one or
|
||||
* takes away again in `cleanup`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
@@ -18,9 +19,18 @@ const guide = (id: string): HelpGuide => {
|
||||
return g
|
||||
}
|
||||
|
||||
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
|
||||
/**
|
||||
* A part of the Settings dialog by the words it opens with: a DialogSection
|
||||
* (its label) or a LinkCard (its title). The areas around them are sections
|
||||
* too, but they open with the area's own name, so they never match.
|
||||
*/
|
||||
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
|
||||
const inviteBlock = (page: Page) => block(page, /^Invite User/)
|
||||
const accessBlock = (page: Page) => block(page, /^Access\s*\d/)
|
||||
/** An area in the dialog's list on the left; each carries the area's name as its label. */
|
||||
const area = (page: Page, name: 'People' | 'Sharing' | 'Journal') => modal(page).getByRole('button', { name, exact: true })
|
||||
/** The Trip settings pill beside the trip's name. */
|
||||
const settingsPill = (page: Page) => page.getByRole('button', { name: 'Trip settings', exact: true })
|
||||
/**
|
||||
* The days column's fold tab: the one button straight inside the column's
|
||||
* frame, which sits 10px in from the left. Its name is Collapse while the
|
||||
@@ -28,12 +38,23 @@ const inviteBlock = (page: Page) => block(page, /^Invite User/)
|
||||
* 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 openShare(page: Page): Promise<void> {
|
||||
await openTrip(page)
|
||||
await page.getByRole('button', { name: 'Share', exact: true }).click()
|
||||
async function clickSettings(page: Page): Promise<void> {
|
||||
await settingsPill(page).click()
|
||||
await expect(modal(page)).toBeVisible()
|
||||
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
|
||||
await expect(modal(page).getByRole('heading', { name: 'Trip settings' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
await openTrip(page)
|
||||
await clickSettings(page)
|
||||
}
|
||||
|
||||
async function pickArea(page: Page, name: 'Sharing' | 'Journal'): Promise<void> {
|
||||
await area(page, name).click()
|
||||
await expect(area(page, name)).toHaveAttribute('aria-current', 'page')
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
@@ -42,6 +63,12 @@ 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> = {
|
||||
@@ -50,12 +77,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Share', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Share', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: settingsPill,
|
||||
act: clickSettings,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -67,17 +90,20 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: inviteBlock,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
|
||||
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(accessBlock(p).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => block(p, /^Access\s*\d/)),
|
||||
only(accessBlock),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'trip-invite-link': {
|
||||
guide: guide('trip-invite-link'),
|
||||
start: openShare,
|
||||
start: async p => {
|
||||
await openSettings(p)
|
||||
await pickArea(p, 'Sharing')
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
|
||||
@@ -88,6 +114,7 @@ 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 => {
|
||||
@@ -98,7 +125,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
'add-guest': {
|
||||
guide: guide('add-guest'),
|
||||
start: openShare,
|
||||
start: openSettings,
|
||||
steps: [
|
||||
only(p => block(p, /^Guests/)),
|
||||
{
|
||||
@@ -109,7 +136,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(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(block(p, /^Guests/).getByText('Aunt Lise', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -118,13 +145,26 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
'public-link': {
|
||||
guide: guide('public-link'),
|
||||
start: openShare,
|
||||
start: openSettings,
|
||||
steps: [
|
||||
{
|
||||
target: p => area(p, 'Sharing'),
|
||||
act: p => pickArea(p, 'Sharing'),
|
||||
},
|
||||
{
|
||||
// The first row of chips: Map & Plan, Bookings, Packing, Costs, Chat.
|
||||
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
|
||||
await beat(p, 300)
|
||||
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()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -143,17 +183,66 @@ 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: openShare,
|
||||
start: openSettings,
|
||||
steps: [
|
||||
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
|
||||
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'link-journal': {
|
||||
guide: guide('link-journal'),
|
||||
start: async p => {
|
||||
await unlinkJournal(p)
|
||||
await openSettings(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => area(p, 'Journal'),
|
||||
act: async p => {
|
||||
await pickArea(p, 'Journal')
|
||||
await expect(modal(p).getByRole('button', { name: 'Link a journal' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Link a journal' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Link a journal' }).click()
|
||||
await modal(p).getByRole('button', { name: 'Choose a journal' }).click()
|
||||
// The select's list is rendered outside the dialog.
|
||||
await p.getByRole('button', { name: JOURNEY_TITLE, exact: true }).last().click()
|
||||
await expect(modal(p).getByText('Access for trip members', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The heading row (Everyone can read / Nobody) and a row per person.
|
||||
target: p => modal(p).getByText('Access for trip members', { exact: true }).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
const edit = modal(p).getByRole('radio', { name: 'Edit' }).first()
|
||||
await edit.click()
|
||||
await expect(edit).toHaveAttribute('aria-checked', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Link journal', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Link journal', exact: true }).click()
|
||||
await expect(modal(p).getByText('Linked', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await closeModal(p)
|
||||
await expect(p.getByRole('button', { name: 'Journal', exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: unlinkJournal,
|
||||
},
|
||||
'collapse-columns': {
|
||||
guide: guide('collapse-columns'),
|
||||
start: p => openTrip(p),
|
||||
@@ -176,7 +265,29 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
// The divider is invisible until hovered; the hover shows it.
|
||||
target: p => p.locator('div[style*="col-resize"]').first(),
|
||||
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()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -205,7 +316,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.getByRole('button', { name: 'Undo' })).toBeDisabled({ timeout: 15_000 })
|
||||
await expect(p.getByText(/^Undone: /).first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
|
||||
import { E2E_BASE_URL } from '../../playwright.config'
|
||||
import { monthName, year } from '../dates'
|
||||
import { 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 { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
@@ -10,7 +14,9 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const YEAR = year()
|
||||
@@ -19,6 +25,22 @@ 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}"`)
|
||||
@@ -66,6 +88,9 @@ 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. */
|
||||
@@ -167,64 +192,6 @@ 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
|
||||
@@ -233,13 +200,29 @@ async function unshareWithAdmin(page: Page): Promise<void> {
|
||||
*/
|
||||
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
|
||||
await trigger.click()
|
||||
const search = page.getByPlaceholder('...')
|
||||
// The menu's filter field; its placeholder is the app's "Search".
|
||||
const search = page.getByPlaceholder('Search', { exact: true }).last()
|
||||
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'),
|
||||
@@ -247,16 +230,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{ target: p => toolbar(p).getByRole('button').first() },
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 14),
|
||||
act: async p => { await day(p, NEXT_MONTH, 14).click(); await beat(p, 600) },
|
||||
target: p => day(p, NEXT_MONTH, LOG_DAY),
|
||||
act: async p => { await day(p, NEXT_MONTH, LOG_DAY).click(); await beat(p, 600) },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 14),
|
||||
target: p => day(p, NEXT_MONTH, LOG_DAY),
|
||||
act: async p => {
|
||||
await day(p, NEXT_MONTH, 14).click()
|
||||
await day(p, NEXT_MONTH, LOG_DAY).click()
|
||||
await beat(p, 600)
|
||||
// Leave the day logged for the after-picture and the guides that follow.
|
||||
await day(p, NEXT_MONTH, 14).click()
|
||||
await day(p, NEXT_MONTH, LOG_DAY).click()
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
@@ -272,8 +255,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 15),
|
||||
act: async p => { await day(p, NEXT_MONTH, 15).click(); await settle(p) },
|
||||
target: p => day(p, NEXT_MONTH, HALF_DAY),
|
||||
act: async p => { await day(p, NEXT_MONTH, HALF_DAY).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Half day' }),
|
||||
@@ -291,9 +274,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 16),
|
||||
target: p => day(p, NEXT_MONTH, COMP_DAY),
|
||||
act: async p => {
|
||||
await day(p, NEXT_MONTH, 16).click()
|
||||
await day(p, NEXT_MONTH, COMP_DAY).click()
|
||||
await settle(p)
|
||||
await p.getByRole('button', { name: 'Comp / Flex' }).click()
|
||||
await toTop(p)
|
||||
@@ -404,6 +387,42 @@ 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,
|
||||
@@ -412,7 +431,8 @@ 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'),
|
||||
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
|
||||
// switchOn rather than a click: a guide run on its own may find it on already.
|
||||
act: async p => { await switchOn(p, 'Public Holidays') },
|
||||
},
|
||||
{
|
||||
// The step is about the country and the region, so the picture shows
|
||||
@@ -432,7 +452,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
prepare: openSettings,
|
||||
target: p => settingRow(p, 'School Holidays'),
|
||||
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
|
||||
act: async p => { await switchOn(p, 'School Holidays') },
|
||||
},
|
||||
{
|
||||
// The last Add calendar: the public-holiday one above it is the first.
|
||||
@@ -444,6 +464,40 @@ 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,
|
||||
@@ -494,6 +548,30 @@ 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,
|
||||
@@ -526,6 +604,101 @@ 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,
|
||||
@@ -553,7 +726,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', SHARER.username)
|
||||
const incoming = cardRow(p, 'Shared Calendars', PARTNER.username)
|
||||
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
|
||||
await expect(incoming).toBeVisible()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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()}`);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { test as setup, expect } from '@playwright/test'
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { seedDemoData } from './seed'
|
||||
import { ensureShowcase } from '../help/showcase'
|
||||
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,10 @@ 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)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.
|
||||
@@ -39,32 +40,62 @@ 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: 'Go before 08:00 — the gate is empty and the light is better.',
|
||||
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
|
||||
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' },
|
||||
{ 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 },
|
||||
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' },
|
||||
{ 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 },
|
||||
duration_minutes: 45, price: 0, currency: 'JPY', day: 1, start: '18:30', end: '19:15', category: 'Attraction' },
|
||||
{ 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 },
|
||||
duration_minutes: 75, price: 0, currency: 'JPY', day: 1, start: '09:30', end: '10:45', category: 'Attraction' },
|
||||
{ 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 },
|
||||
duration_minutes: 150, price: 0, currency: 'JPY', day: 4, start: '07:45', end: '10:15', category: 'Attraction' },
|
||||
{ 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 },
|
||||
duration_minutes: 60, price: 0, currency: 'JPY', day: 5, start: '08:00', end: '09:00', category: 'Nature' },
|
||||
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
|
||||
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
|
||||
description: "Five covered blocks of food stalls, known as Kyoto's kitchen.",
|
||||
notes: 'Come hungry. Try the tamagoyaki.',
|
||||
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
|
||||
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' },
|
||||
]
|
||||
|
||||
const EXPENSES = [
|
||||
/** 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 }> = [
|
||||
{ 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',
|
||||
@@ -74,13 +105,33 @@ const EXPENSES = [
|
||||
{ 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) },
|
||||
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.' },
|
||||
]
|
||||
|
||||
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'] },
|
||||
/** 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']] },
|
||||
]
|
||||
|
||||
/** Chat lines the collab guides look for by their text. */
|
||||
@@ -88,10 +139,14 @@ export const SEED_CHAT = {
|
||||
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
|
||||
}
|
||||
|
||||
const TODOS = [
|
||||
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
|
||||
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
|
||||
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
|
||||
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' },
|
||||
]
|
||||
|
||||
export interface SeedResult {
|
||||
@@ -150,6 +205,7 @@ 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(() => {})
|
||||
@@ -161,16 +217,25 @@ 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, ...payload } = p
|
||||
const { day, start, end, category, ...payload } = p
|
||||
const categoryId = categories.find(c => c.name === category)?.id
|
||||
const { place } = await call<{ place: { id: number } }>(
|
||||
api, 'post', `/api/trips/${tripId}/places`, payload)
|
||||
api, 'post', `/api/trips/${tripId}/places`, categoryId ? { ...payload, category_id: categoryId } : payload)
|
||||
placeIds.push(place.id)
|
||||
const dayId = dayIds[day]
|
||||
if (dayId) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
|
||||
{ place_id: place.id }).catch(() => {})
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +251,12 @@ 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]
|
||||
for (const e of EXPENSES) {
|
||||
const payerId = (who: Payer | undefined): number =>
|
||||
who === 'mira' ? (memberIds[0] ?? 1) : who === 'jonas' ? (memberIds[1] ?? 1) : 1
|
||||
for (const { paidBy, ...e } of EXPENSES) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/budget`, {
|
||||
...e,
|
||||
payers: [{ user_id: 1, amount: e.total_price }],
|
||||
payers: [{ user_id: payerId(paidBy), amount: e.total_price }],
|
||||
member_ids: allMembers,
|
||||
}).catch(() => {})
|
||||
}
|
||||
@@ -203,15 +270,22 @@ 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 name of group.items) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/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`, {
|
||||
name, category: group.category, visibility: 'common',
|
||||
}).catch(() => {})
|
||||
}).catch(() => null)
|
||||
if (created && state === 'packed') {
|
||||
await call(api, 'put', `/api/trips/${tripId}/packing/${created.item.id}`, { checked: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of TODOS) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/todo`, t).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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
|
||||
@@ -244,7 +318,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)) {
|
||||
for (const placeId of placeIds.slice(4, 7)) {
|
||||
await call(api, 'post', '/api/addons/collections/places/from-trip', {
|
||||
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
|
||||
}).catch(() => {})
|
||||
@@ -288,9 +362,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–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
|
||||
content: 'The 14-day JR Pass covers the Tokyo to Kyoto legs. Activate it at the airport counter on arrival, not before.' },
|
||||
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
|
||||
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
|
||||
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp, and 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.' },
|
||||
]) {
|
||||
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 22 KiB |