mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc442f75b8 | |||
| 989a019cb3 | |||
| 994a438cfd | |||
| 4ad8146dbf | |||
| 30d09a019c | |||
| 6584696894 | |||
| 512fc51f3e | |||
| ffa1af61b7 | |||
| 60413578fa | |||
| b98f2a9afd | |||
| 2ca16caf7c | |||
| 8a0bd61fc4 | |||
| ac201deb35 | |||
| a4972f8f13 | |||
| c9627af239 | |||
| 2899465dac | |||
| a87b656b43 |
+1
-2
@@ -15,8 +15,6 @@ uploads
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
docker-compose.minio-test.yml
|
||||
minio-licence.key
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
@@ -34,5 +32,6 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Commits that only reformat code. git blame skips them with
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
# (GitHub reads this file on its own).
|
||||
|
||||
# style: format server and shared with prettier
|
||||
724c88c201b2c16d31317eaea5f59f43d314b833
|
||||
@@ -15,7 +15,6 @@
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
@@ -26,8 +25,3 @@
|
||||
*.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
|
||||
|
||||
@@ -8,11 +8,11 @@ body:
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/liketrek/TREK/wiki
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/liketrek/TREK/discussions
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -13,8 +13,8 @@
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Version updates, weekly, into dev. Minor and patch bumps of one ecosystem
|
||||
# arrive as a single grouped PR; a major bump gets a PR of its own so its
|
||||
# breaking notes are read on their own.
|
||||
#
|
||||
# Security updates are a repository setting, not part of this file, and
|
||||
# GitHub opens them against the default branch whatever target-branch says.
|
||||
# enforce-target-branch.yml moves those onto dev.
|
||||
#
|
||||
# Every PR runs test.yml like any other, so ci-ok decides whether it can
|
||||
# merge. Dependabot runs get no Actions secrets, so s3-contract and the Sonar
|
||||
# scan skip themselves on these PRs and ci-ok counts the skip as passed.
|
||||
version: 2
|
||||
updates:
|
||||
# The root lockfile covers the client, server and shared workspaces.
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
prefix-development: chore(deps-dev)
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# plugin-sdk is standalone with a lockfile of its own and ships to npm.
|
||||
- package-ecosystem: npm
|
||||
directory: /plugin-sdk
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: chore(plugin-sdk)
|
||||
prefix-development: chore(plugin-sdk)
|
||||
groups:
|
||||
plugin-sdk-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# Keeps the SHA pins of third-party actions current, comment included.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: ci
|
||||
groups:
|
||||
actions-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# The Dockerfile's base images. The Node major follows .nvmrc and the
|
||||
# engines ranges, so it moves by hand, together with them.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: chore(docker)
|
||||
ignore:
|
||||
- dependency-name: node
|
||||
update-types: [version-update:semver-major]
|
||||
@@ -9,12 +9,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
|
||||
@@ -10,12 +10,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
|
||||
@@ -9,12 +9,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
@@ -78,7 +77,7 @@ jobs:
|
||||
body: [
|
||||
'## Wrong place for feature requests',
|
||||
'',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'',
|
||||
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||
].join('\n'),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
name: Build & Push Docker Image (Prerelease)
|
||||
|
||||
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
|
||||
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
|
||||
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
|
||||
# release it refuses a commit whose ci-ok is not green. docker.yml with
|
||||
# bump=auto later finalizes the line once main carries it.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -17,7 +11,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: prerelease-build
|
||||
@@ -25,42 +18,24 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
# each, refuses while one is still going, and requires the one that
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being built
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
runs="$RUNNER_TEMP/ci-ok-runs.json"
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
- name: Determine prerelease version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
run: |
|
||||
git fetch --tags
|
||||
|
||||
# Capture the exact commit we're building so build/merge jobs are pinned to it
|
||||
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Get latest stable tag (exclude prerelease tags)
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
@@ -68,12 +43,14 @@ jobs:
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
echo "Latest stable: $STABLE"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
|
||||
NEXT_MAJOR="$((MAJOR + 1)).0.0"
|
||||
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
|
||||
TARGET="$NEXT_MAJOR"
|
||||
else
|
||||
@@ -86,47 +63,38 @@ jobs:
|
||||
N=$(( ${LAST_N:-0} + 1 ))
|
||||
|
||||
NEW_VERSION="${TARGET}-pre.${N}"
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$STABLE → $NEW_VERSION"
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Prepare platform tag-safe name
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -134,9 +102,6 @@ jobs:
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
||||
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
@@ -145,7 +110,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -153,27 +118,25 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -181,30 +144,10 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
# The labels live on each platform image; the index gets the ones a
|
||||
# client reads before it picks a platform (#1498).
|
||||
docker buildx imagetools create \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
|
||||
--annotation "index:org.opencontainers.image.version=$VERSION" \
|
||||
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
@@ -227,7 +170,7 @@ jobs:
|
||||
run: |
|
||||
KEEP=20
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
BASE_VERSION="${VERSION%%-pre.*}"
|
||||
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
|
||||
git fetch --tags
|
||||
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
|
||||
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
|
||||
|
||||
+65
-150
@@ -1,24 +1,21 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
# The stable release. Dispatched by hand on main, it:
|
||||
# 1. refuses to run unless ci-ok passed on the main commit it releases,
|
||||
# 2. works out the version from the tags and the bump input, commits the
|
||||
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
|
||||
# 3. builds both platforms from exactly that bump commit (never from
|
||||
# whatever main points to by then) and publishes :VERSION,
|
||||
# :MAJOR.MINOR, :MAJOR and :latest,
|
||||
# 4. publishes the Helm chart from the same commit.
|
||||
#
|
||||
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
|
||||
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
|
||||
# Without a prerelease in flight there is no default: the run stops until
|
||||
# someone picks patch, minor or major.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
- 'plugin-sdk/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
|
||||
description: 'Force bump line (auto = patch/finalize as today)'
|
||||
type: choice
|
||||
options: [auto, patch, minor, major]
|
||||
default: auto
|
||||
@@ -29,7 +26,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: stable-build
|
||||
@@ -37,66 +33,34 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
steps:
|
||||
- name: Release from main only
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: |
|
||||
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
|
||||
exit 1
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
# each, refuses while one is still going, and requires the one that
|
||||
# finished last to be green, so a later green run supersedes a flaky or
|
||||
# cancelled one. It prints the link of every run it counted.
|
||||
- name: Require a green ci-ok on the commit being released
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
runs="$RUNNER_TEMP/ci-ok-runs.json"
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --tags
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
# Derive version from git tags — no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ]; then
|
||||
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
|
||||
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
|
||||
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
||||
exit 1
|
||||
fi
|
||||
@@ -109,96 +73,79 @@ jobs:
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
BUMP="patch"
|
||||
else
|
||||
# auto finalizes the in-flight prerelease and nothing else. It used
|
||||
# to fall back to a patch, which would have shipped any change,
|
||||
# however big, as a patch nobody chose.
|
||||
PRE_BASE=""
|
||||
# auto: finalize in-flight prerelease if one exists, else patch
|
||||
if [ -n "$PRE_TAG" ]; then
|
||||
PRE_BASE="${PRE_TAG#v}"
|
||||
PRE_BASE="${PRE_BASE%-pre.*}"
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump — it bypasses confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
# If prerelease base is strictly greater than stable, finalize it
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
|
||||
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
|
||||
exit 1
|
||||
fi
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump, it would bypass confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
fi
|
||||
|
||||
# Patch releases have carried migrations before, so this only warns:
|
||||
# whoever pinned :MAJOR.MINOR gets this schema change too.
|
||||
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
|
||||
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
|
||||
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION ($BUMP)"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||
|
||||
# Commit and tag. The push fast-forwards main from the commit ci-ok
|
||||
# was checked on, so it fails rather than release past anything that
|
||||
# landed on main in the meantime.
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin HEAD:main --follow-tags
|
||||
|
||||
# build, merge and release-helm check out exactly this commit.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Prepare platform tag-safe name
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -206,9 +153,6 @@ jobs:
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
||||
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
@@ -217,7 +161,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -225,25 +169,23 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -251,37 +193,12 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
|
||||
# the conservative pin for an install that wants to wait out a
|
||||
# release that migrates.
|
||||
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
# The labels live on each platform image; the index gets the ones a
|
||||
# client reads before it picks a platform (#1498).
|
||||
docker buildx imagetools create \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
|
||||
--annotation "index:org.opencontainers.image.version=$VERSION" \
|
||||
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$MINOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
@@ -289,18 +206,16 @@ jobs:
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
needs: version-bump
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
ref: main
|
||||
|
||||
- name: Publish Helm chart
|
||||
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
|
||||
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
charts_url: https://chart.liketrek.com
|
||||
|
||||
@@ -6,9 +6,8 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
@@ -16,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
@@ -69,38 +68,6 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Dependabot's security updates cannot be pointed at dev. `target-branch`
|
||||
// in .github/dependabot.yml applies to version updates only; security
|
||||
// updates always open against the default branch. Labelling them meant
|
||||
// close-stale-wrong-branch shut them 24 hours later, which is how 13 of
|
||||
// them died. Retarget instead: a bot cannot click "Edit" the way the
|
||||
// comment below asks a human to.
|
||||
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
base: 'dev',
|
||||
});
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
console.log('Retargeted the Dependabot PR onto dev.');
|
||||
return;
|
||||
} catch (err) {
|
||||
// Retargeting can fail when the branch was cut from main and dev has
|
||||
// moved on. Fall through to the normal path so a human sees it rather
|
||||
// than the check passing on a PR that is still aimed at main.
|
||||
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Base is main — check if this user is a maintainer
|
||||
let permission = 'none';
|
||||
try {
|
||||
|
||||
@@ -2,41 +2,31 @@ name: Lint & Prettier
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run lint & format check
|
||||
# Both are check-only: lint used to run eslint --fix, which rewrote the
|
||||
# files before format:check looked at them, so neither could fail on
|
||||
# formatting. lint:format holds every file outside
|
||||
# scripts/format-baseline.json to Prettier; the list only shrinks.
|
||||
id: checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd shared
|
||||
npm run lint
|
||||
npm run lint:format
|
||||
npm run format:check
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
@@ -50,9 +40,8 @@ jobs:
|
||||
'',
|
||||
'```bash',
|
||||
'cd shared',
|
||||
'npm run lint:fix',
|
||||
'npx prettier --write <file> # each file lint:format names',
|
||||
'npm run lint:format -- --update # when a listed file is formatted now',
|
||||
'npm run lint',
|
||||
'npm run format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
|
||||
@@ -14,19 +14,18 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# The Node the TREK image runs the plugins on. It has the node:sqlite
|
||||
# the dev-server tests exercise.
|
||||
node-version-file: .nvmrc
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
|
||||
@@ -1,85 +1,38 @@
|
||||
name: Security Scan
|
||||
|
||||
# Neither job is a required check. What they report depends on the advisory
|
||||
# databases as much as on the change: a CVE published overnight would turn
|
||||
# every open PR red at once, including the ones that cannot fix it. The
|
||||
# security floors that hold the fixes live in the root package.json's
|
||||
# overrides, which a regenerated lockfile keeps.
|
||||
#
|
||||
# - npm-audit reads the lockfile only, so it runs in seconds on every PR
|
||||
# and push into dev and main. It audits what the image installs (the
|
||||
# server and shared workspaces without dev dependencies) and the
|
||||
# standalone plugin-sdk.
|
||||
# - scout scans the built image, base image packages included. It needs
|
||||
# the Docker Hub login, so it runs on PRs and pushes into main only and
|
||||
# skips fork PRs. Whether the image builds at all is test.yml's
|
||||
# docker-image job, which a red scan cannot hide.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
npm-audit:
|
||||
name: npm audit
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
# The client's dependencies end up in a browser bundle, not in the
|
||||
# image, and are audited by hand until maplibre-gl's fix (a major) lands.
|
||||
- name: Audit the image's runtime dependencies
|
||||
run: npm audit --package-lock-only --omit=dev --audit-level=high --workspace=server --workspace=shared
|
||||
|
||||
- name: Audit trek-plugin-sdk
|
||||
working-directory: plugin-sdk
|
||||
run: npm audit --package-lock-only --omit=dev --audit-level=high
|
||||
|
||||
scout:
|
||||
# Docker Hub secrets are not exposed to pull requests from forks, so the
|
||||
# Scout login can never succeed there.
|
||||
if: >-
|
||||
github.repository == 'liketrek/TREK'
|
||||
&& github.event.pull_request.head.repo.fork != true
|
||||
&& (github.base_ref == 'main' || github.ref == 'refs/heads/main')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
|
||||
+68
-1242
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,11 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: clone
|
||||
|
||||
+2
-7
@@ -4,7 +4,6 @@ node_modules/
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
server/VERSION
|
||||
shared/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
@@ -52,11 +51,8 @@ yarn-error.log*
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
# Blob reports of a sharded vitest run (test.yml)
|
||||
.vitest-reports
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
@@ -68,8 +64,7 @@ coverage-*/
|
||||
.scannerwork
|
||||
test-data
|
||||
|
||||
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
|
||||
minio-licence.key
|
||||
|
||||
.run
|
||||
.full-review
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
|
||||
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
|
||||
|
||||
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
|
||||
|
||||
```bash
|
||||
npm run dev # build shared, then watch shared + server + client concurrently
|
||||
npm run build # build shared → server → client (order matters)
|
||||
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
|
||||
npm run format # prettier --write all three
|
||||
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
|
||||
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
|
||||
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
|
||||
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
|
||||
cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate)
|
||||
cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate)
|
||||
cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate)
|
||||
cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml)
|
||||
cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate)
|
||||
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
|
||||
cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate)
|
||||
cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate)
|
||||
cd client && npm run lint:skips # no .only; skipped tests may only shrink (CI gate)
|
||||
cd client && npm run lint:i18n-keys # every key the client names exists in en; unused en keys may only shrink (CI gate)
|
||||
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
|
||||
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
|
||||
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
|
||||
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
|
||||
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
|
||||
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
|
||||
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
|
||||
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
|
||||
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
|
||||
cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate)
|
||||
cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
Run a single test file or test:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
```
|
||||
|
||||
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
|
||||
|
||||
```bash
|
||||
npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). `.nvmrc` pins the Node major the Docker image runs and every CI job reads; `engines` in each package.json states the range that installs (npm only warns outside it). `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server invariants
|
||||
|
||||
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `server/src/db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
|
||||
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
|
||||
|
||||
## Client invariants
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too.
|
||||
- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. The Kysely table types the repositories query through are generated the same way (`gen:db-types`/`check:db-types`, `server/scripts/generate-db-types.ts`).
|
||||
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
|
||||
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
|
||||
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-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.
|
||||
- **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current.
|
||||
- **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there.
|
||||
- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:<major>-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:<major>`, `:<major>.<minor>` and `:<version>`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:<major>.<minor>` can wait it out.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
| Condition (new code) | Must be |
|
||||
|---|---|
|
||||
| Coverage | ≥ 80% |
|
||||
| Duplicated lines | ≤ 3% |
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
|
||||
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
|
||||
+3
-3
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
|
||||
|
||||
## Development Environment
|
||||
|
||||
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
|
||||
+29
-87
@@ -2,10 +2,8 @@
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.26-alpine AS gosu-build
|
||||
# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever
|
||||
# was pushed last, and the tag itself is not a semver version go install accepts.
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
@@ -22,18 +20,13 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
# A prerelease build does not bump client/package.json, so the bundle takes its
|
||||
# version from the same build argument the server's VERSION file comes from.
|
||||
ARG APP_VERSION=dev
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
|
||||
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
|
||||
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -42,9 +35,7 @@ COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY server/ ./server/
|
||||
# tsc needs more than the 2 GB heap Node picks on a host with about 12 GB of RAM;
|
||||
# on a bigger build machine the default is larger, so only small hosts failed.
|
||||
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build --workspace=server
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||
FROM node:24-trixie-slim
|
||||
@@ -55,32 +46,16 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# The trailing chown runs in this layer on purpose: it covers the manifests and
|
||||
# the freshly installed node_modules while they are already part of this layer's
|
||||
# changeset, so it costs nothing. Everything copied after this point carries
|
||||
# --chown=node:node for the same reason — a recursive chown in a later layer
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
#
|
||||
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
|
||||
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
|
||||
# python3 + build-essential fallback compiled it against whatever Node headers
|
||||
# the base image had that day, and 24.19+ headers give an addon that aborts with
|
||||
# "Assertion failed: (env) != nullptr" the first time a statement is collected
|
||||
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
|
||||
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
{ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
|
||||
# it to drop to node.
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
@@ -90,75 +65,42 @@ ENV QT_QPA_PLATFORM=offscreen
|
||||
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
|
||||
# The in-app help pages (/help) read this straight from disk at runtime, so the
|
||||
# docs always match the version running. Without it, wikiService falls back to
|
||||
# fetching the GitHub wiki, which tracks main and needs network access.
|
||||
COPY --chown=node:node wiki ./wiki
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY --chown=node:node server/tsconfig.json ./server/
|
||||
COPY server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
|
||||
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
|
||||
# a dir created lazily on first upload needs write permission on the uploads
|
||||
# mount point itself, which fails with EACCES when the bind-mounted host dir
|
||||
# isn't writable by node. Only paths this layer creates are chowned; anything
|
||||
# already in the image arrived node-owned via --chown above.
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
|
||||
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
ln -s /app/uploads /app/server/uploads && \
|
||||
ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
chown -h node:node /app/server/uploads /app/server/data
|
||||
chown -R node:node /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_USE_ENV_PROXY=1
|
||||
ENV PORT=3000
|
||||
# The version goes into a file, not into ENV. A container keeps its environment
|
||||
# when it is recreated on a newer image (Portainer's recreate, a copied run
|
||||
# config), so an APP_VERSION env var would keep announcing the old release and
|
||||
# the clients would never pick up the new bundle. The server reads this file
|
||||
# first (server/src/app-config/image-version.ts).
|
||||
ARG APP_VERSION=dev
|
||||
RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s\n' "$APP_VERSION" > /app/server/VERSION; fi
|
||||
|
||||
# OCI metadata: Renovate, Watchtower and the registries read the source label to
|
||||
# link an image update to its release notes (#1498). The release workflows add
|
||||
# the commit and the build time; a local build carries everything else.
|
||||
LABEL org.opencontainers.image.title="TREK" \
|
||||
org.opencontainers.image.description="Self-hosted collaborative travel planner" \
|
||||
org.opencontainers.image.url="https://github.com/liketrek/TREK" \
|
||||
org.opencontainers.image.source="https://github.com/liketrek/TREK" \
|
||||
org.opencontainers.image.documentation="https://github.com/liketrek/TREK/wiki" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-only" \
|
||||
org.opencontainers.image.version="${APP_VERSION}"
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1
|
||||
|
||||
# Start-up lives in a script, not an inline `sh -c` string: container management
|
||||
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
|
||||
# carrying quotes does not survive that round-trip (the container then dies with
|
||||
# a shell syntax error before Node starts). A CMD of one bare token does.
|
||||
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
|
||||
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
|
||||
# node. chmod is explicit because a checkout on Windows carries no exec bit.
|
||||
RUN chmod 0755 /usr/local/bin/trek-entrypoint
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["/usr/local/bin/trek-entrypoint"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -1,3 +1,577 @@
|
||||
# MCP Integration
|
||||
|
||||
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
|
||||
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||
structured API.
|
||||
|
||||
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
|
||||
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
|
||||
- [Authentication](#authentication)
|
||||
- [OAuth Scopes](#oauth-scopes)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Compound Tools](#compound-tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Connect your MCP client
|
||||
|
||||
#### Option A: OAuth 2.1 (recommended)
|
||||
|
||||
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
|
||||
management required — just provide the server URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
|
||||
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
|
||||
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
|
||||
(`trekcs_` prefix) and fixed scopes that you define up front.
|
||||
|
||||
#### Option B: Static API Token (deprecated)
|
||||
|
||||
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
|
||||
|
||||
1. Go to **Settings > Integrations > MCP** and create an API token.
|
||||
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
|
||||
3. Add it to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer trek_your_token_here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
|
||||
static token will receive deprecation warnings in the AI client via server instructions and tool results.
|
||||
|
||||
Each user can create up to **10 static tokens**.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
|
||||
|
||||
| Method | Token prefix | Access level | TTL | Notes |
|
||||
|--------|-------------|-------------|-----|-------|
|
||||
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
|
||||
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
|
||||
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
|
||||
|
||||
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
|
||||
|
||||
---
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
|
||||
that match your granted scopes for that session.
|
||||
|
||||
| Scope | Permission | Group |
|
||||
|-------|-----------|-------|
|
||||
| `trips:read` | View trips & itineraries | Trips |
|
||||
| `trips:write` | Edit trips & itineraries | Trips |
|
||||
| `trips:delete` | Delete trips (irreversible) | Trips |
|
||||
| `trips:share` | Manage share links | Trips |
|
||||
| `places:read` | View places & map data | Places |
|
||||
| `places:write` | Manage places | Places |
|
||||
| `atlas:read` | View Atlas | Atlas |
|
||||
| `atlas:write` | Manage Atlas | Atlas |
|
||||
| `packing:read` | View packing lists | Packing |
|
||||
| `packing:write` | Manage packing lists | Packing |
|
||||
| `todos:read` | View to-do lists | To-dos |
|
||||
| `todos:write` | Manage to-do lists | To-dos |
|
||||
| `budget:read` | View budget | Budget |
|
||||
| `budget:write` | Manage budget | Budget |
|
||||
| `reservations:read` | View reservations | Reservations |
|
||||
| `reservations:write` | Manage reservations | Reservations |
|
||||
| `collab:read` | View collaboration | Collaboration |
|
||||
| `collab:write` | Manage collaboration | Collaboration |
|
||||
| `notifications:read` | View notifications | Notifications |
|
||||
| `notifications:write` | Manage notifications | Notifications |
|
||||
| `vacay:read` | View vacation plans | Vacation |
|
||||
| `vacay:write` | Manage vacation plans | Vacation |
|
||||
| `geo:read` | Maps & geocoding | Geo |
|
||||
| `weather:read` | Weather forecasts | Weather |
|
||||
| `journey:read` | View journeys | Journey |
|
||||
| `journey:write` | Manage journeys | Journey |
|
||||
| `journey:share` | Manage journey share links | Journey |
|
||||
|
||||
**Scope rules:**
|
||||
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||
|
||||
---
|
||||
|
||||
## Limitations & Important Notes
|
||||
|
||||
| Limitation | Details |
|
||||
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
|
||||
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
|
||||
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
|
||||
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
|
||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||
|
||||
---
|
||||
|
||||
## Resources (read-only)
|
||||
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
### Core Resources
|
||||
|
||||
| Resource | URI | Description |
|
||||
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
|
||||
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
|
||||
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
|
||||
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
|
||||
|
||||
### Addon-Gated Resources
|
||||
|
||||
These resources are only available when the corresponding addon is enabled by an admin.
|
||||
|
||||
| Resource | URI | Addon | Description |
|
||||
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
|
||||
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
|
||||
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
|
||||
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
|
||||
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
|
||||
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||
|
||||
---
|
||||
|
||||
## Tools (read-write)
|
||||
|
||||
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
|
||||
trip in a single call.
|
||||
|
||||
### Trip Summary
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||
|
||||
### Compound Tools
|
||||
|
||||
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||
|
||||
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||
|
||||
| Tool | Wraps | Description |
|
||||
|---|---|---|
|
||||
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||
|
||||
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||
|
||||
---
|
||||
|
||||
### Trips
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------------------|
|
||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||
| `list_trip_members` | List the owner and all collaborators of a trip. |
|
||||
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
|
||||
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
|
||||
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
|
||||
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
|
||||
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
|
||||
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
|
||||
| `delete_share_link` | Revoke the public share link for a trip. |
|
||||
|
||||
### Places
|
||||
|
||||
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------|--------------------------------------------------------------------------------------|
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
| `create_day` | Add a new day to a trip with optional date and notes. |
|
||||
| `delete_day` | Delete a day from a trip. |
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||
| `unassign_place` | Remove a place assignment from a day. |
|
||||
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
|
||||
| `move_assignment` | Move a place assignment to a different day. |
|
||||
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
|
||||
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
|
||||
|
||||
### Accommodations
|
||||
|
||||
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|------------------------------------------------------------------------------------------|
|
||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||
|
||||
### Transport
|
||||
|
||||
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||
| `delete_transport` | Delete a transport booking from a trip. |
|
||||
|
||||
### Reservations
|
||||
|
||||
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||
| `delete_budget_item` | Remove a budget item. |
|
||||
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
|
||||
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
|
||||
|
||||
### Packing
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item or change its category. |
|
||||
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||
| `delete_packing_item` | Remove a packing item. |
|
||||
| `reorder_packing_items` | Set the display order of packing items within a trip. |
|
||||
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
|
||||
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
|
||||
| `save_packing_template` | Save the current packing list as a reusable template. |
|
||||
| `list_packing_bags` | List all packing bags for a trip. |
|
||||
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
|
||||
| `update_packing_bag` | Rename or recolor a packing bag. |
|
||||
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
|
||||
| `set_bag_members` | Assign trip members to a packing bag. |
|
||||
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
|
||||
| `set_packing_category_assignees` | Assign trip members to a packing category. |
|
||||
|
||||
### Day Notes
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|------------------------------------------------------------------------|
|
||||
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||
| `delete_day_note` | Remove a note from a day. |
|
||||
|
||||
### To-Dos
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------------|
|
||||
| `list_todos` | List all to-do items for a trip, ordered by position. |
|
||||
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
|
||||
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
|
||||
| `toggle_todo` | Mark a to-do item as done or undone. |
|
||||
| `delete_todo` | Delete a to-do item. |
|
||||
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
|
||||
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
|
||||
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
|
||||
|
||||
### Tags
|
||||
|
||||
| Tool | Description |
|
||||
|--------------|--------------------------------------------------------------------------|
|
||||
| `list_tags` | List all tags belonging to the current user. |
|
||||
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
|
||||
| `update_tag` | Update the name or color of an existing tag. |
|
||||
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------------|------------------------------------------------------|
|
||||
| `list_notifications` | List in-app notifications with pagination and unread filter. |
|
||||
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
|
||||
| `mark_notification_read` | Mark a single notification as read. |
|
||||
| `mark_notification_unread` | Mark a single notification as unread. |
|
||||
| `mark_all_notifications_read` | Mark all notifications as read. |
|
||||
|
||||
### Maps & Weather
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|-----------------------------------------------------------------------------------------------------|
|
||||
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
|
||||
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
|
||||
| `reverse_geocode` | Get a human-readable address for given coordinates. |
|
||||
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
|
||||
| `get_weather` | Get weather forecast for a location and date. |
|
||||
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||
|
||||
### Airports
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||
|
||||
### Collab Notes _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||
| `delete_collab_note` | Delete a collab note. |
|
||||
|
||||
### Collab Polls & Chat _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|------------------------------------------------------------------------------------------|
|
||||
| `list_collab_polls` | List all polls for a trip. |
|
||||
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
|
||||
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
|
||||
| `close_collab_poll` | Close a poll so no more votes can be cast. |
|
||||
| `delete_collab_poll` | Delete a poll and all its votes. |
|
||||
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
|
||||
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
|
||||
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||
|
||||
### Bucket List _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
|
||||
### Atlas Extended _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------|
|
||||
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
|
||||
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
|
||||
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
|
||||
| `unmark_region_visited` | Remove a region from the visited list. |
|
||||
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
|
||||
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
|
||||
|
||||
### Vacay _(Vacay addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
|
||||
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
|
||||
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
|
||||
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
|
||||
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
|
||||
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
|
||||
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
|
||||
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
|
||||
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
|
||||
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
|
||||
| `add_vacay_year` | Add a calendar year to the vacation plan. |
|
||||
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
|
||||
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
|
||||
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
|
||||
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
|
||||
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
|
||||
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
|
||||
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
|
||||
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
|
||||
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
|
||||
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||
| `list_holidays` | List public holidays for a country and year. |
|
||||
|
||||
### Journey _(Journey addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||
| `delete_journey` | Delete a journey. |
|
||||
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||
|
||||
---
|
||||
|
||||
## Prompts
|
||||
|
||||
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
|
||||
|
||||
| Prompt | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------|
|
||||
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
|
||||
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
|
||||
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
|
||||
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||
|
||||
Initial prompt (1st message):
|
||||
|
||||
```
|
||||
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||
in mind when picking spots.
|
||||
|
||||
Before writing anything to TREK, do some research: look up what's worth
|
||||
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||
neighbourhood, and think about what kind of food and restaurant experiences
|
||||
are worth including.
|
||||
|
||||
Once you have a solid plan, write the whole thing to TREK:
|
||||
- Create the trip
|
||||
- Add all the places you've researched with their real coordinates
|
||||
- Build out the daily itinerary with sensible visiting times
|
||||
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||
- Add any notable restaurant reservations
|
||||
- Put together a realistic budget in EUR
|
||||
- Build a packing list suited to April in Kyoto
|
||||
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||
- Add a day note for each day with any important heads-up (early start, crowd
|
||||
tips, booking requirements, etc.)
|
||||
- Mark Japan as visited in my Atlas
|
||||
|
||||
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||
of everything that was added.
|
||||
```
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||
|
||||
@@ -21,34 +21,12 @@ property names TREK consumes. Country borders (`admin0`) derive from the geoBoun
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
|
||||
## OpenStreetMap — geocoding, place details & descriptions
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
|
||||
details and descriptions come from Nominatim and **Overpass**. Data is
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||
https://www.openstreetmap.org/copyright
|
||||
|
||||
## Wikimedia Commons & Wikipedia — place pictures & descriptions
|
||||
|
||||
Pictures offered for a place come from **Wikimedia Commons** (geosearch by
|
||||
coordinate) and are cached locally rather than hotlinked. Descriptions can come
|
||||
from **Wikipedia**, resolved from the place's OpenStreetMap `wikipedia` tag.
|
||||
|
||||
Commons files carry their own licence, most often **CC BY** or **CC BY-SA**. TREK
|
||||
shows the author, the licence and a link to the file description page next to
|
||||
every picture while it is being chosen, and keeps the author and licence with the
|
||||
cached file so the credit stays visible afterwards. Wikipedia article text is
|
||||
**CC BY-SA 4.0**. https://commons.wikimedia.org/wiki/Commons:Licensing
|
||||
|
||||
## Google Places — optional place data
|
||||
|
||||
When an administrator configures a Google Maps API key, place search, details,
|
||||
pictures and editorial summaries can also come from the **Google Places API**,
|
||||
subject to Google's terms. Photos carry the author attribution Google supplies
|
||||
and no reusable licence; TREK displays that attribution and claims nothing
|
||||
further. This path is off unless a key is configured, and each part of it has its
|
||||
own switch in the admin panel. https://cloud.google.com/maps-platform/terms
|
||||
|
||||
## OurAirports — airport reference data
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
@@ -21,84 +22,80 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
|
||||
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||
|
||||
<br />
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK2.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Planning
|
||||
#### 🧭 Trip planning
|
||||
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -107,71 +104,64 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
#### 📱 Mobile & PWA
|
||||
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -181,26 +171,6 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## AI usage
|
||||
|
||||
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
|
||||
maintainer has not read and understood: every change goes through a pull request, is
|
||||
reviewed and tested, and has a human who can answer for it. "The AI wrote that" is not
|
||||
an answer any of us would accept from ourselves.
|
||||
|
||||
See [**How we use AI in TREK**](https://github.com/liketrek/TREK/discussions/1851) for
|
||||
the details.
|
||||
|
||||
<br />
|
||||
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
@@ -213,7 +183,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
</div>
|
||||
|
||||
@@ -223,7 +193,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -241,29 +211,76 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
|
||||
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||
|
||||
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
|
||||
with secure defaults and every option documented inline. Download it, then:
|
||||
<details>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||
# - TRUST_PROXY=1
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME=SSO
|
||||
# - OIDC_ADMIN_CLAIM=groups
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
|
||||
for the full walkthrough.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<br />
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
@@ -279,17 +296,41 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
## Updating
|
||||
|
||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
||||
**Docker Compose:**
|
||||
|
||||
<br />
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
@@ -327,19 +368,6 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
|
||||
# so buffering must be off or tool results arrive late.
|
||||
location /mcp {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -362,18 +390,61 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Every variable, its default and what it does: see
|
||||
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
## Star History
|
||||
<br />
|
||||
|
||||
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
</picture>
|
||||
</a>
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
@@ -382,8 +453,7 @@ The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/liketrek/TREK</WebPage>
|
||||
<Forum>https://github.com/liketrek/TREK/issues</Forum>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
|
||||
+2
-7
@@ -8,20 +8,18 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
|
||||
|
||||
## Usage
|
||||
|
||||
Or install directly from the local chart:
|
||||
@@ -46,12 +44,9 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.4
|
||||
version: 3.3.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.4"
|
||||
appVersion: "3.3.0"
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
|
||||
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
|
||||
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
|
||||
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
|
||||
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
|
||||
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
|
||||
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
|
||||
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
|
||||
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
|
||||
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||
auto-generate a key persisted to the data PVC.
|
||||
|
||||
@@ -13,15 +13,6 @@ data:
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_WIKI_DIR }}
|
||||
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.DEFAULT_LANGUAGE }}
|
||||
DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.MCP_MAX_SESSION_PER_USER }}
|
||||
MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
@@ -49,9 +40,6 @@ data:
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
|
||||
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
@@ -70,21 +58,9 @@ data:
|
||||
{{- if .Values.env.OIDC_ADMIN_VALUE }}
|
||||
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_USERNAME_CLAIM }}
|
||||
OIDC_USERNAME_CLAIM: {{ .Values.env.OIDC_USERNAME_CLAIM | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_SCOPE }}
|
||||
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.HTTP_PROXY }}
|
||||
HTTP_PROXY: {{ .Values.env.HTTP_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.HTTPS_PROXY }}
|
||||
HTTPS_PROXY: {{ .Values.env.HTTPS_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.NO_PROXY }}
|
||||
NO_PROXY: {{ .Values.env.NO_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISCOVERY_URL }}
|
||||
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
|
||||
{{- end }}
|
||||
@@ -94,30 +70,9 @@ data:
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.NOMINATIM_URL }}
|
||||
NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_URL }}
|
||||
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
|
||||
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
|
||||
{{- end }}
|
||||
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
|
||||
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
|
||||
otherwise drop the key and leave the index on. */}}
|
||||
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
|
||||
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PUBLIC_KEY: {{ .Values.env.VAPID_PUBLIC_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.VAPID_SUBJECT }}
|
||||
VAPID_SUBJECT: {{ .Values.env.VAPID_SUBJECT | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -9,7 +9,7 @@ spec:
|
||||
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
|
||||
# RollingUpdate would start a second pod holding the same volume before the old one
|
||||
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
|
||||
# the old pod down first. Keep it, see updateStrategy in values.yaml.
|
||||
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
|
||||
strategy:
|
||||
type: {{ .Values.updateStrategy | default "Recreate" }}
|
||||
selector:
|
||||
@@ -40,14 +40,10 @@ spec:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.env.PORT | default 3000 }}
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
@@ -79,74 +75,23 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
- name: PLACES_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: PLACES_API_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_SECRET
|
||||
optional: true
|
||||
- name: VAPID_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: VAPID_PRIVATE_KEY
|
||||
optional: true
|
||||
- name: SMTP_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: SMTP_PASS
|
||||
optional: true
|
||||
- name: MAPBOX_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: MAPBOX_ACCESS_TOKEN
|
||||
optional: true
|
||||
- name: CARTO_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: CARTO_API_KEY
|
||||
optional: true
|
||||
- name: DEMO_ADMIN_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: DEMO_ADMIN_PASS
|
||||
optional: true
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -20,30 +20,6 @@ data:
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.SMTP_PASS }}
|
||||
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
|
||||
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.CARTO_API_KEY }}
|
||||
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
|
||||
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -58,44 +34,20 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- if and $existingSecret $existingSecret.data }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec | quote }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
|
||||
{{- else }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 | quote }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | quote }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | quote }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | quote }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
|
||||
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.SMTP_PASS }}
|
||||
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
|
||||
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.CARTO_API_KEY }}
|
||||
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
|
||||
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -8,7 +8,7 @@ spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
|
||||
+8
-121
@@ -4,10 +4,9 @@ image:
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and
|
||||
# during a RollingUpdate the new pod migrates the database while the old one still
|
||||
# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both
|
||||
# processes on one host, which network filesystems do not provide.
|
||||
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
|
||||
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
|
||||
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
|
||||
updateStrategy: Recreate
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
@@ -25,16 +24,10 @@ env:
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# TREK_WIKI_DIR: "/app/wiki"
|
||||
# Where the in-app Help pages (/help) read their content from. Leave unset: the
|
||||
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
|
||||
# the version you are running. Only set this to serve your own docs from a mounted
|
||||
# volume. If the path does not exist, Help falls back to fetching the public GitHub
|
||||
# wiki, which needs egress and tracks the latest release rather than your version.
|
||||
# DEFAULT_LANGUAGE: "en"
|
||||
# Default language on the login page for users with no saved preference.
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
|
||||
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
@@ -55,9 +48,6 @@ env:
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# ALLOW_LINK_LOCAL_IPS: ""
|
||||
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
|
||||
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
@@ -72,58 +62,22 @@ env:
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
# Value of the OIDC claim that grants admin role.
|
||||
# OIDC_USERNAME_CLAIM: ""
|
||||
# Claim a new account's username comes from, e.g. "preferred_username". Unset: name, then preferred_username.
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# HTTP_PROXY: ""
|
||||
# HTTPS_PROXY: ""
|
||||
# NO_PROXY: "localhost,127.0.0.1"
|
||||
# Outbound proxy for TREK's requests to the internet; NO_PROXY lists hosts that bypass it.
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "300"
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
# NOMINATIM_URL: ""
|
||||
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
|
||||
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
|
||||
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
|
||||
# way. Defaults to https://nominatim.openstreetmap.org.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||
# OVERPASS_TIMEOUT_MS: "25000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
|
||||
# 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.
|
||||
# TREK_PLACES_URL: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
|
||||
# VAPID_PUBLIC_KEY: ""
|
||||
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
|
||||
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
|
||||
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
|
||||
# means every device has to turn push on again.
|
||||
# VAPID_SUBJECT: ""
|
||||
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
|
||||
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
|
||||
# address is sent unless you set one here.
|
||||
# OVERPASS_TIMEOUT_MS: "12000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -149,39 +103,11 @@ secretEnv:
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
# Optional Google Maps API key (Places API (New) enabled) for place search, photos,
|
||||
# details and Google transit. Overrides the key set in Admin > Settings > API Keys,
|
||||
# whose field then shows this variable read-only. Leave empty to use the key from the
|
||||
# admin panel, or none.
|
||||
PLACES_API_KEY: ""
|
||||
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
|
||||
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
|
||||
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
|
||||
# key from the admin panel, or none.
|
||||
AMAP_API_KEY: ""
|
||||
# The private secret (数字签名) of that key. Required exactly when the key was created
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
|
||||
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
|
||||
# push off with an error in the log until it is fixed, and every device stays registered.
|
||||
# Leave both empty to let TREK keep a generated pair in its database.
|
||||
VAPID_PRIVATE_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades.
|
||||
# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux
|
||||
# the chart cannot read the existing secret and renders a new key every time. TREK then
|
||||
# refuses to start rather than lose its secrets; use existingSecret in those setups.
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
# If set, the chart renders no Secret of its own and instead reads every secret env
|
||||
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
|
||||
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
|
||||
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
|
||||
# Missing keys are simply left unset (they are optional), so only provide the ones your
|
||||
# setup needs.
|
||||
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||
existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
@@ -202,45 +128,6 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
#
|
||||
# The startup probe gives a first boot, a long migration or a restore at boot up to ten
|
||||
# minutes before liveness takes over; liveness alone killed such a boot after about
|
||||
# 105 s, and the next start began it again. Readiness asks the database, so a pod whose
|
||||
# database does not answer stops getting traffic without being restarted.
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health/ready
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
|
||||
# Further environment variables, for any setting the env block above does not list.
|
||||
# extraEnv:
|
||||
# - name: SESSION_DURATION
|
||||
# value: 7d
|
||||
extraEnv: []
|
||||
# Whole ConfigMaps or Secrets:
|
||||
# extraEnvFrom:
|
||||
# - secretRef:
|
||||
# name: trek-smtp
|
||||
extraEnvFrom: []
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
@@ -3,7 +3,3 @@ e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
e2e/help/media.env
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
||||
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
||||
npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run lint:rtl # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
|
||||
npm run lint:size # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
|
||||
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
|
||||
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
|
||||
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
|
||||
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
|
||||
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
|
||||
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
|
||||
npm run lint:i18n-keys # every translation key src/ names exists in shared en, and en keys nothing in src/, server/src or plugin-sdk/src reaches may only shrink per en file against scripts/i18n-unused-baseline.json (CI gate; --unused lists them, --update lowers the baseline)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
|
||||
```
|
||||
|
||||
Every baseline ratchet above also fails on an entry that allows more than the tree holds: a count above what its file or rule has now, or a file that is gone. Run the check with `--update` in the change that made it smaller, so a fixed file cannot grow back to its old entry and a new file at a deleted path does not inherit one.
|
||||
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
|
||||
|
||||
## Page pattern (enforced — `lint:pages` fails otherwise)
|
||||
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
|
||||
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. Versions are append-only: a new `this.version(N)` adds its stores to `HISTORY` and a case to `UPGRADE_CASES` in `tests/unit/db/offlineDb.upgrade.test.ts`, which opens the previous version with seeded rows in fake-indexeddb and upgrades it to the current one; a guard there fails on a declared version without both. Never edit a shipped version's stores or `upgrade()`.
|
||||
- **`src/sync/`**: in `mutationQueue.ts`, offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Every row `enqueue()` writes carries `schemaVersion` (`MUTATION_SCHEMA_VERSION` in `offlineDb.ts`) and `buildVersion`; bump the constant when a row's fields or the way the replay reads them change incompatibly. A flush replays its own format and older ones (an unstamped row is format 1) and skips a row from a newer format without sending, marking or dropping it, holding back the later writes to its entity, so a tab still on an older bundle never replays a shape it does not know. The guard exists only from the release that introduced `schemaVersion` on; a bundle older than that replays every row, so the first bump of `MUTATION_SCHEMA_VERSION` must not ship while a rollback target older than the guard is still in play. A skipped row stays `pending` with no retry or discard path, so on a rolled-back install it holds its entity until the user clears the cache. Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask `mutationQueue.mustQueue()` before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send `X-Base-Updated-At`, so the server refuses the stale replay with a 409 instead. `flush()` takes the Web Lock `trek-mutation-flush:<db name>` so only one tab replays at a time; a tab that finds it taken waits for it, and a `flush()` called while this tab's flush runs or waits for the lock gets that same flush back (with one more pass asked for), so `flush()` never resolves while a tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flush alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Rules for new code
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal, and especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in `src/` is the layer of its name, so `src/types.ts` follows the `types/` rules. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`.
|
||||
|
||||
## Big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop and phone views
|
||||
|
||||
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
|
||||
|
||||
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
|
||||
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
|
||||
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
|
||||
|
||||
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
|
||||
@@ -1,10 +1,8 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
import { E2E_STATE_FILE } from '../playwright.config'
|
||||
|
||||
// Relative to the config dir (client/), matching `storageState` in
|
||||
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||
const stateFile = E2E_STATE_FILE
|
||||
const stateFile = 'e2e/.tmp/state.json'
|
||||
|
||||
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
|
||||
// seeded admin is created with must_change_password=1, so the first login goes
|
||||
@@ -29,12 +27,16 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
|
||||
|
||||
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||
|
||||
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
|
||||
// support modal, which has NO "OK" button (only CTAs + the X). The shared
|
||||
// helper handles both notice shapes; dismissal is recorded server-side
|
||||
// against this user, so clearing it here keeps it cleared for every
|
||||
// authenticated flow in the run (shared test DB).
|
||||
await dismissSystemNotices(page, 10_000)
|
||||
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||
// Dismissal is recorded server-side against this user, so clearing it here
|
||||
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||
await ok.click()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: stateFile })
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||
@@ -8,23 +7,18 @@ import { dismissSystemNotices } from './helpers'
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
|
||||
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
|
||||
// primary action lives in the footer), so click it explicitly rather than pressing
|
||||
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||
// button (the primary action lives in the footer), so click it explicitly
|
||||
// rather than pressing Enter. The Create button is the slate primary button;
|
||||
// Cancel is the bordered one.
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* The run's calendar.
|
||||
*
|
||||
* The seeded trip ends on the day the pictures are taken, and every other
|
||||
* date the seed, the fixtures and the guides use is an offset from that day.
|
||||
* So a run on any date meets a trip that is running, with a last day that is
|
||||
* today: the boarding pass, What's Next and the forecast all have something
|
||||
* to show, and nobody has to bump dates in three files every few weeks.
|
||||
*
|
||||
* The day is `E2E_PICTURE_DAY` (YYYY-MM-DD), set once by `e2e/help/run.mjs`
|
||||
* and `playwright.config.ts` so the seed project and every worker agree even
|
||||
* when the run crosses midnight; without it, today in the machine's zone.
|
||||
*/
|
||||
|
||||
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function localToday(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const fromEnv = process.env.E2E_PICTURE_DAY
|
||||
if (fromEnv && !ISO_DAY.test(fromEnv)) throw new Error(`E2E_PICTURE_DAY must be YYYY-MM-DD, got "${fromEnv}"`)
|
||||
|
||||
/** The picture day as YYYY-MM-DD: the trip's last day. */
|
||||
export const PICTURE_DAY_ISO: string = fromEnv || localToday()
|
||||
|
||||
/** Noon UTC on the picture day, a safe anchor for calendar arithmetic. */
|
||||
function anchor(offset: number): Date {
|
||||
const d = new Date(`${PICTURE_DAY_ISO}T12:00:00Z`)
|
||||
d.setUTCDate(d.getUTCDate() + offset)
|
||||
return d
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` of the picture day plus `offset` days. */
|
||||
export function day(offset: number): string {
|
||||
return anchor(offset).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DDTHH:mm:00`, the shape reservations and expenses take. */
|
||||
export function at(offset: number, hhmm: string): string {
|
||||
return `${day(offset)}T${hhmm}:00`
|
||||
}
|
||||
|
||||
/** The calendar year of the picture day plus `years`. */
|
||||
export function year(years = 0): number {
|
||||
return anchor(0).getUTCFullYear() + years
|
||||
}
|
||||
|
||||
/** A day `months` months after the picture day, on the given day of the month. */
|
||||
export function monthsAhead(months: number, dayOfMonth: number): string {
|
||||
const d = anchor(0)
|
||||
d.setUTCDate(1)
|
||||
d.setUTCMonth(d.getUTCMonth() + months)
|
||||
d.setUTCDate(dayOfMonth)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const en = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts })
|
||||
|
||||
// The labels the English UI prints for a date, for the guides that have to
|
||||
// find a button by its calendar text.
|
||||
|
||||
/** `Sat, Sep 12`, the day filter's chip. */
|
||||
export function short(offset: number): string {
|
||||
return en({ weekday: 'short', month: 'short', day: 'numeric' }).format(anchor(offset))
|
||||
}
|
||||
|
||||
/** `Saturday, September 12`, the day filter's long form. */
|
||||
export function long(offset: number): string {
|
||||
return en({ weekday: 'long', month: 'long', day: 'numeric' }).format(anchor(offset))
|
||||
}
|
||||
|
||||
/** `September 25, 2026`, a date picker's cell label. */
|
||||
export function pickerLabel(offset: number): string {
|
||||
return pickerLabelOn(day(offset))
|
||||
}
|
||||
|
||||
/** The same label for any `YYYY-MM-DD`, e.g. one from `monthsAhead`. */
|
||||
export function pickerLabelOn(iso: string): string {
|
||||
return en({ month: 'long', day: 'numeric', year: 'numeric' }).format(new Date(`${iso}T12:00:00Z`))
|
||||
}
|
||||
|
||||
/** `October`: the English name of the month `months` months after the picture day's. */
|
||||
export function monthName(months: number): string {
|
||||
return en({ month: 'long' }).format(new Date(`${monthsAhead(months, 1)}T12:00:00Z`))
|
||||
}
|
||||
|
||||
/** `19.09.2026`, what a date input takes when typed. */
|
||||
export function dotted(offset: number): string {
|
||||
const [y, m, d] = day(offset).split('-')
|
||||
return `${d}.${m}.${y}`
|
||||
}
|
||||
|
||||
/** `13th`, for prose that names a day of the month. */
|
||||
export function ordinal(offset: number): string {
|
||||
const n = anchor(offset).getUTCDate()
|
||||
const suffix = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'
|
||||
return `${n}${suffix}`
|
||||
}
|
||||
|
||||
/** `Wednesday 16 September 2026`, the date line of a booking mail. */
|
||||
export function emlDate(offset: number): string {
|
||||
return en({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(anchor(offset)).replace(/,/g, '')
|
||||
}
|
||||
|
||||
/** `Tue, 1 Sep 2026 09:12:00 +0900`, an RFC 2822 mail header for a fixed offset. */
|
||||
export function mailHeaderDate(offset: number, hhmm: string, tz: string): string {
|
||||
const d = anchor(offset)
|
||||
const weekday = en({ weekday: 'short' }).format(d)
|
||||
const month = en({ month: 'short' }).format(d)
|
||||
return `${weekday}, ${d.getUTCDate()} ${month} ${d.getUTCFullYear()} ${hhmm}:00 ${tz}`
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
# Help-center media
|
||||
|
||||
Every picture the help center shows is made here, by Playwright, against the real
|
||||
app. Nothing is drawn or cropped by hand, so a new screen gets pictures in exactly
|
||||
the same style by following the same path.
|
||||
|
||||
## The style, in one place
|
||||
|
||||
Everything visual is fixed in `guide.ts` and `promote.mjs`; change it there, never
|
||||
in a script.
|
||||
|
||||
| What | Where | Value |
|
||||
|---|---|---|
|
||||
| Viewport | `guide.ts` `VIEWPORT` | 1920 × 1080, device scale 2 (`playwright.config.ts`, project `help-media`) |
|
||||
| Step picture frame | `guide.ts` `frameFor` | 16:10, at least 960 px wide, 64 px padding around the target, centred on it, never larger than the largest 16:10 frame the viewport holds (1728 × 1080). A target wider than that, a full-width row for instance, is therefore cut at the sides, and that is accepted: the narrow frame is the format of the whole library, and a picture that widens to fit one row is at a different magnification from the one beside it. A target taller than the frame is anchored at its top, so a long section shows its heading |
|
||||
| Ring around the target | `guide.ts` `ring` | 3 px in the app's `--accent`, 12 px radius, 6 px outside the element, the rest dimmed 30 %. A white hairline goes outside the ring and the badge only where the surface measured behind them has too little contrast against the accent, which is the lightbox and the other near-black overlays |
|
||||
| The step's number | `guide.ts` `ring` | a 28 px disc in the accent on the ring's top-left corner, outside it. Where that spot is not inside the frame, which is every target wider than the frame, it moves inside far enough to clear the frame's edge. The number is part of the picture; the panel numbers the step again beside it and puts nothing on top of the picture |
|
||||
| Raster tiles | `guide.ts` `settle` → `showLoadedTiles` | loaded tiles are forced visible before every shot. Leaflet fades a tile in over 200 ms and takes the progress from `+new Date()`, and the clock is pinned, so without this the satellite imagery and every raster basemap are a grey rectangle although each tile came back 200 |
|
||||
| Drag & drop | `guide.ts` `ringDrag` (a step with `dropTo`) | source ringed, destination ringed dashed, a bowed arrow from the one to the other, both undimmed |
|
||||
| Hover | `captureGuide` | the target is hovered before the shot (tooltips, hover-only buttons show); `hover` overrides where the centre is wrong |
|
||||
| Result picture | `guide.media.result` | the full frame after the last step, pointer parked in the corner |
|
||||
| Hero (screen overview) | `captureHero` | the full frame of the screen as the reader finds it |
|
||||
| Output | `promote.mjs` | PNG → WebP quality 84; steps 1440 px wide, heroes and results 1920 px; into `public/help-media/<guide-id>/step-<n>.webp`, `result.webp`, `ctx/<screen-id>.webp` |
|
||||
| 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
|
||||
change the theme, the language or the zoom for a picture.
|
||||
|
||||
## How a screen gets its pictures
|
||||
|
||||
1. Register the screen and its guides in `src/help/contexts/<screen>.ts` and
|
||||
`src/help/registry.ts`; write the English texts as a block in
|
||||
`shared/src/i18n/en/help.ts` (`// ── Screen: <id>`). Guide ids are global: one id,
|
||||
one picture directory, so never reuse an id from another screen
|
||||
(`registry.test.ts` refuses duplicates).
|
||||
2. Write `e2e/help/<screen>.guide.ts`: one `GuideScript` per guide, exactly one
|
||||
`StepAction` per registered step, `start` that opens the screen, `cleanup` that
|
||||
puts the seed back. A step's `target` is the element the text tells the reader
|
||||
to use; `prepare` gets there, `act` does what the text says.
|
||||
3. Run `npm run help:media -- <screen>.guide.ts` (own ports 5183/3011, fresh
|
||||
database, the seed, then the file). Fix until every guide passes; a failing
|
||||
step is a text that no longer matches the UI.
|
||||
4. Look at the pictures in `e2e/.tmp/help-media/`, then `node e2e/help/promote.mjs`.
|
||||
5. Translate the block into every other locale (`i18n:parity:strict` is the gate) and
|
||||
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
|
||||
|
||||
## What the fixtures may look like
|
||||
|
||||
The documents and pictures the guides upload are drawn in `fixtures.ts`, because
|
||||
no real person's paperwork and no photograph may live in this repository. They
|
||||
still have to look like something a traveller would really put into a trip, or
|
||||
the picture teaches the reader that the feature is a toy.
|
||||
|
||||
- **A document is a document.** `drawPdf` takes an issuer band, a title, a
|
||||
reference line, a table of labelled fields and a footer, which is the shape of
|
||||
every confirmation, ticket and voucher. Four lines of Helvetica on white is not
|
||||
a document; it was the first attempt and it was thrown out.
|
||||
- **A picture is a graphic, not a painting of a photograph.** A hand-drawn
|
||||
market or landscape reads as clip art at any size, however it is shaded, and it
|
||||
was tried three times. What works is the kind of image people actually upload:
|
||||
a map screenshot with a pin and a search field, a booking confirmation in a
|
||||
browser window, a printed network map. Those are drawings in reality too, so a
|
||||
drawing of them is honest.
|
||||
- **Every date comes from `e2e/dates.ts`**, never from a calendar typed into a
|
||||
fixture. A confirmation whose check-in is two days before the trip's last day
|
||||
keeps making sense next month.
|
||||
- The names of the files are part of the seed: guides find their row by name, so
|
||||
renaming a fixture means renaming it in the guide too.
|
||||
|
||||
## Changing the style
|
||||
|
||||
`guide.ts` is shared by every picture, so anything changed there is a property of
|
||||
the whole library, not of the guide in front of you. Before changing it, work out
|
||||
which pictures actually come out different and re-render exactly those, one
|
||||
`--grep` away:
|
||||
|
||||
```bash
|
||||
npm run help:media -- --grep "pictures: (files-trash|files-preview)$|hero: trip-files"
|
||||
```
|
||||
|
||||
The staging directory answers most of those questions without a browser, because
|
||||
the frame is in the file:
|
||||
|
||||
```bash
|
||||
# which pictures were taken at the frame's maximum, i.e. cut at the sides
|
||||
python -c "import struct,os;[print(r,f,struct.unpack('>II',open(os.path.join(r,f),'rb').read(24)[16:24])) for r,_,fs in os.walk('e2e/.tmp/help-media') for f in fs if f.endswith('.png')]"
|
||||
```
|
||||
|
||||
A change that alters every picture (the frame, the ring's geometry, the viewport)
|
||||
is a full run of about four hours on one worker. It is not a thing to do twice,
|
||||
so look at three or four pictures from a subset run before starting it.
|
||||
|
||||
## Rules learned the hard way
|
||||
|
||||
- Never run `npx prettier` over `client/` sources: `client/.prettierrc` says
|
||||
`semi: true` and the sources are written without semicolons, so it rewrites
|
||||
whole files. Nothing in CI formats the client.
|
||||
- A killed run leaves its server holding 3011 and 5183, and the next run dies
|
||||
with "port is already used". Free them first:
|
||||
`Get-NetTCPConnection -LocalPort 3011 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`.
|
||||
- The browser's clock is pinned to the picture day (`PICTURE_DAY`). Anything in
|
||||
the app or a library that animates from the wall clock therefore never
|
||||
finishes: Leaflet's tile fade is the one that bit, and the fix is in `settle`.
|
||||
A new picture that comes out empty or grey is worth suspecting of the same.
|
||||
- `promote.mjs` converts the whole staging directory, so a guide that was not
|
||||
re-run keeps the picture it had; staging is the source of truth for what gets
|
||||
published, not the last run.
|
||||
- Never edit `shared/` while a media run is going: the watcher rebuilds `dist`
|
||||
mid-run and the pictures show raw i18n keys.
|
||||
- Never `npm run build --workspace=shared` while `npm run dev` is running; restart
|
||||
the dev server if it happened.
|
||||
- Each run boots a fresh server (~2 min); batch fixes, and dump the accessibility
|
||||
tree once (`page.locator('body').ariaSnapshot()`) instead of guessing locators.
|
||||
- Several runs at once, each on its own `E2E_WEB_PORT`/`E2E_API_PORT`, share
|
||||
`server/dist`: build it once and set `E2E_SERVER_PREBUILT=1` on every run, or
|
||||
each run rebuilds it under the servers of the others. Give each run its own
|
||||
`TREK_PLUGINS_DIR` and `TREK_PLUGINS_DATA_DIR` as well (the seed installs a
|
||||
plugin), and its own Vite `cacheDir` when it starts a Vite of its own.
|
||||
- A plugin installed by a run lives in `server/data/plugins/`; the dev server
|
||||
imports it on its next restart. Guides that install one uninstall it in `cleanup`.
|
||||
- The trip's last day is the run day by construction (`e2e/dates.ts`: every
|
||||
seeded date is an offset from `E2E_PICTURE_DAY`, which `run.mjs` fixes to today
|
||||
once for the whole run), so the plan opens on today, the trip's last and empty
|
||||
day; `openTrip` in `trip-shared.ts` selects day 1 and closes its details panel.
|
||||
A guide that has to find a date by its label builds it with the same helpers
|
||||
(`short`, `long`, `pickerLabel`, `dotted`) instead of writing a calendar date.
|
||||
|
||||
## How the panel shows them
|
||||
|
||||
`src/components/Help/HelpGuideView.tsx` and `HelpHome.tsx` hold the other half of
|
||||
the style, and the two have to agree:
|
||||
|
||||
- A step picture goes into a 16:10 box, a hero and a result picture into a 16:9
|
||||
one, and all three with `object-contain`. Never `object-cover`: the pictures
|
||||
are already framed, and covering crops them a second time, which took the
|
||||
sides off every wide one.
|
||||
- Nothing is drawn over a picture in the panel. The step is numbered beside the
|
||||
text and the number is in the picture itself.
|
||||
- A click opens the lightbox, which shows the picture at its own size.
|
||||
|
||||
## Before you promote
|
||||
|
||||
Look at three or four of the new pictures, not the list of passing tests:
|
||||
|
||||
- The ring is in the picture and visible against what is behind it.
|
||||
- The number is in the picture.
|
||||
- What the step's text names is in the frame.
|
||||
- A map shows a map, not a grey rectangle.
|
||||
- An uploaded document looks like a document someone would upload.
|
||||
|
||||
## The guides that show another service
|
||||
|
||||
A few guides picture TREK talking to something outside it: AirTrail (flights
|
||||
into the Transports tab), Dawarich (the recorded route on the map, countries
|
||||
and wishes in the Atlas), a document store (Nextcloud on the Files tab) and the
|
||||
booking extractor (confirmations into Bookings and Transports). Their fixtures
|
||||
in `external.ts` fill each service themselves through its API, so the pictures
|
||||
do not depend on what happens to be in it; they need its address and key from
|
||||
`e2e/help/media.env` (copy `media.env.example`, not committed). Without the
|
||||
file those guides fail loudly at their first step and every other guide runs
|
||||
as before.
|
||||
|
||||
- Every address has to be the machine's LAN address: the server's SSRF guard
|
||||
refuses loopback whatever `ALLOW_INTERNAL_NETWORK` says, and that flag has to
|
||||
be `true` in the same file for a private network to be reachable at all.
|
||||
- AirTrail: any instance with one user and an API key from Settings > Security.
|
||||
The fixture puts four flights into that account.
|
||||
- Dawarich: any 1.15 instance with a user and their API key; add the LAN address
|
||||
to its `APPLICATION_HOSTS`, and give it a reverse geocoder (Nominatim works)
|
||||
or the Atlas dialog finds no countries. The fixture uploads a synthetic
|
||||
recording of the trip and of the year before it (`dawarich-track.ts`).
|
||||
- Nextcloud: an app password. The fixture keeps a folder under `/Reisen` with
|
||||
two documents, and the guide binds the trip to it.
|
||||
- The extractor: `KITINERARY_EXTRACTOR_PATH` must point at a program the
|
||||
server can run with one file argument. On Linux that is the package's binary
|
||||
(`libkitinerary-bin`); on Windows, a small executable that runs it inside the
|
||||
TREK Docker image (`docker run --rm -v <dir>:/in:ro --entrypoint
|
||||
/usr/local/bin/kitinerary-extractor <image> /in/<file>`) and answers
|
||||
`--version` by itself.
|
||||
@@ -1,724 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ensureAdminFixtures, pluginPackage } from './fixtures'
|
||||
import { 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'
|
||||
|
||||
/**
|
||||
* Actions for the admin screens, keyed by the ids in `src/help/contexts/admin.ts`.
|
||||
* One route, one tab per screen. The guides that create something (a user, an
|
||||
* invite link, a backup, an installed plugin) either leave a harmless row behind
|
||||
* or take it away again in `cleanup`, so the tabs after them look like the seed.
|
||||
*/
|
||||
|
||||
const TAB_OF: Record<string, string> = {
|
||||
'admin-users': 'Users',
|
||||
'admin-defaults': 'User Defaults',
|
||||
'admin-config': 'Personalization',
|
||||
'admin-settings': 'Settings',
|
||||
'admin-addons': 'Addons',
|
||||
'admin-plugins': 'Plugins',
|
||||
'admin-storage': 'Storage',
|
||||
'admin-notifications': 'Notifications',
|
||||
'admin-mcp-tokens': 'MCP Access',
|
||||
'admin-github': 'GitHub',
|
||||
'admin-backup': 'Backup',
|
||||
'admin-audit': 'Audit',
|
||||
}
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = adminGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'User Defaults' }) }).first()
|
||||
/** The nearest card around an element: the admin cards are rounded-xl or rounded-2xl boxes, or a <section>. */
|
||||
const cardOf = (el: Locator) => el.locator('xpath=ancestor::*[contains(@class,"rounded-xl") or contains(@class,"rounded-2xl") or self::section][1]')
|
||||
/** An admin card by the text of its h2. */
|
||||
const section = (page: Page, title: string | RegExp) => cardOf(page.locator('h2').filter({ hasText: title }).first())
|
||||
/** A provider block under Settings, a <section> headed by an h3. */
|
||||
const block = (page: Page, title: string) => page.locator('h3').filter({ hasText: new RegExp(`^${title}$`) }).first().locator('xpath=ancestor::section[1]')
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
/** A row of the user table by its username. */
|
||||
const userRow = (page: Page, username: string) => page.locator('table tbody tr').filter({ hasText: username }).first()
|
||||
/** 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]')
|
||||
|
||||
async function openAdmin(page: Page, tab: string): Promise<void> {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
|
||||
// The run's server is in development mode, which adds a developer-only tab
|
||||
// nobody has in production; the pictures show the sidebar as shipped.
|
||||
await sidebar(page).getByRole('button', { name: 'Dev: Notifications' }).evaluateAll(els => {
|
||||
for (const el of els) (el as HTMLElement).style.display = 'none'
|
||||
})
|
||||
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const opener = (contextId: string) => (page: Page) => openAdmin(page, TAB_OF[contextId])
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
// ── Users ────────────────────────────────────────────────────────────────
|
||||
'create-user': {
|
||||
guide: guide('create-user'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, /^Users$/).getByRole('button', { name: 'Create User' }),
|
||||
act: async p => {
|
||||
await section(p, /^Users$/).getByRole('button', { name: 'Create User' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Username'), 'lena')
|
||||
await typeInto(p, modal(p).getByPlaceholder('Email'), 'lena.k@example.com')
|
||||
await typeInto(p, modal(p).getByPlaceholder('Password'), 'Lena12345!')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create User' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create User' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(userRow(p, 'lena')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// 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'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => userRow(p, 'mara').getByRole('button', { name: 'Edit User' }),
|
||||
act: async p => {
|
||||
await userRow(p, 'mara').getByRole('button', { name: 'Edit User' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'invite-links': {
|
||||
guide: guide('invite-links'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }),
|
||||
act: async p => {
|
||||
await section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: '3×', exact: true }).click()
|
||||
await modal(p).getByRole('button', { name: '14d', exact: true }).click()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create & Copy' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Invite Links')),
|
||||
],
|
||||
},
|
||||
'delete-user': {
|
||||
guide: guide('delete-user'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
only(p => userRow(p, 'jonas').getByRole('button', { name: 'Delete user' })),
|
||||
],
|
||||
},
|
||||
permissions: {
|
||||
guide: guide('permissions'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
const row = section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..')
|
||||
// The select's trigger is the row's only button; its options are buttons too.
|
||||
await row.locator('button').first().click()
|
||||
await p.getByRole('button', { name: 'Admin only', exact: true }).last().click()
|
||||
await expect(row.getByText('customized')).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
target: p => section(p, 'Permission Settings').getByRole('button', { name: 'Save', exact: true }),
|
||||
// Not saved: the seed keeps its defaults for the other screens.
|
||||
act: async p => {
|
||||
await section(p, 'Permission Settings').getByRole('button', { name: 'Reset to defaults' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── User Defaults ────────────────────────────────────────────────────────
|
||||
'default-user-settings': {
|
||||
guide: guide('default-user-settings'),
|
||||
start: opener('admin-defaults'),
|
||||
steps: [
|
||||
only(p => section(p, 'Default User Settings')),
|
||||
{
|
||||
// The reset link only exists next to a field an admin has set.
|
||||
prepare: async p => {
|
||||
await section(p, 'Default User Settings').getByRole('button', { name: 'Dark', exact: true }).click()
|
||||
// The link sits inside the field's label, so its accessible name is the label's, not "reset".
|
||||
await expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'Default User Settings').locator('label').filter({ hasText: 'Color Mode' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ }).first().click()
|
||||
await 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 ──────────────────────────────────────────────────────
|
||||
'packing-templates': {
|
||||
guide: guide('packing-templates'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
// 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)
|
||||
},
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Beach Holiday', exact: true }).click()
|
||||
await expect(p.getByRole('button', { name: 'Add category' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=../..').locator('> div').nth(1),
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
categories: {
|
||||
guide: guide('categories'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, /^Categories$/).getByRole('button', { name: 'New Category' }).click()
|
||||
await expect(p.getByPlaceholder('Category name')).toBeVisible()
|
||||
await typeInto(p, p.getByPlaceholder('Category name'), 'Viewpoints')
|
||||
await settle(p)
|
||||
},
|
||||
// The new category opens in a dialog, its name in the dialog's head.
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
// Edit and Delete are always on the row; the pointer rests on Edit.
|
||||
{
|
||||
target: p => section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"group")][1]'),
|
||||
hover: async p => {
|
||||
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first()
|
||||
.locator('xpath=ancestor::div[contains(@class,"group")][1]').getByRole('button', { name: 'Edit' }).hover()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'school-holiday-catalog': {
|
||||
guide: guide('school-holiday-catalog'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Add country' }).click()
|
||||
await expect(section(p, 'School holidays').getByPlaceholder('US')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'School holidays'),
|
||||
act: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(section(p, 'School holidays').getByPlaceholder('US')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Bavaria', exact: true }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await expect(modal(p).getByRole('textbox', { name: 'Holiday name' }).first()).toHaveValue('Autumn break', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
'auth-methods': {
|
||||
guide: guide('auth-methods'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => section(p, 'Authentication Methods').getByText('Password Login', { exact: true }).locator('xpath=../..')),
|
||||
// The SSO rows exist only once OIDC is configured; the run shows where that happens.
|
||||
only(p => section(p, 'Single Sign-On (OIDC)')),
|
||||
only(p => section(p, 'Require two-factor authentication (2FA)')),
|
||||
],
|
||||
},
|
||||
'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'),
|
||||
steps: [
|
||||
only(p => section(p, 'Single Sign-On (OIDC)')),
|
||||
only(p => section(p, 'Authentication Methods')),
|
||||
],
|
||||
},
|
||||
'instance-keys': {
|
||||
guide: guide('instance-keys'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => 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)
|
||||
},
|
||||
// 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')),
|
||||
],
|
||||
},
|
||||
'places-transit': {
|
||||
guide: guide('places-transit'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => block(p, 'Transit Provider')),
|
||||
only(p => block(p, 'Place search provider')),
|
||||
],
|
||||
},
|
||||
'file-types': {
|
||||
guide: guide('file-types'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
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': {
|
||||
guide: guide('toggle-addon'),
|
||||
start: opener('admin-addons'),
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Atlas'),
|
||||
act: async p => {
|
||||
const toggle = tile(p, 'Atlas').getByRole('button', { name: 'Atlas' })
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false', { timeout: 10_000 })
|
||||
await beat(p, 600)
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => tile(p, 'Lists')),
|
||||
],
|
||||
},
|
||||
'document-providers': {
|
||||
guide: guide('document-providers'),
|
||||
start: opener('admin-addons'),
|
||||
steps: [
|
||||
// The tile with its shelf: Documents is on in the seed, so the five rows are drawn.
|
||||
only(p => tile(p, 'Documents')),
|
||||
{
|
||||
// The row's switch is a ToggleSwitch whose aria-label is the row's title.
|
||||
target: p => tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true }),
|
||||
act: async p => {
|
||||
const toggle = tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true })
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// Back to the seed, where every store is off; the files run switches this one on for itself.
|
||||
cleanup: p => toggleAddon(p.request, 'nextcloud', false),
|
||||
},
|
||||
|
||||
// ── Plugins ──────────────────────────────────────────────────────────────
|
||||
'install-plugin': {
|
||||
guide: guide('install-plugin'),
|
||||
start: async p => {
|
||||
const upload = await p.request.post('/api/admin/plugins/upload', {
|
||||
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
|
||||
})
|
||||
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`)
|
||||
await openAdmin(p, 'Plugins')
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('tab', { name: /Discover/ }).click()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
target: p => section(p, /^Plugins$/),
|
||||
act: async p => {
|
||||
await p.getByRole('tab', { name: /Installed/ }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]'),
|
||||
act: async p => {
|
||||
const row = p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]')
|
||||
await row.getByRole('button', { name: 'Enable plugin' }).click()
|
||||
await expect(row.getByText('Active', { exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByTestId('plugin-row-menu-btn-trip-doctor').click()
|
||||
await expect(p.getByTestId('plugin-row-menu-trip-doctor')).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByTestId('plugin-row-menu-trip-doctor'),
|
||||
// Allowed hosts only shows for plugins that call out; Trip Doctor does not.
|
||||
hover: async p => { await p.getByTestId('plugin-row-menu-trip-doctor').getByText('View error log').hover() },
|
||||
act: async p => { await p.keyboard.press('Escape') },
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const res = await p.request.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } })
|
||||
if (!res.ok()) throw new Error(`could not uninstall the plugin: ${res.status()} ${await res.text()}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────────
|
||||
'storage-backends': {
|
||||
guide: guide('storage-backends'),
|
||||
start: opener('admin-storage'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, /^Backends$/).getByRole('button', { name: 'Add backend' }).click()
|
||||
await expect(modal(p).getByText('Add backend', { exact: true })).toBeVisible()
|
||||
await typeInto(p, modal(p).locator('input').first(), 'archive')
|
||||
await settle(p)
|
||||
},
|
||||
// The new backend opens in a dialog.
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p).getByText('Add backend', { exact: true })).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
only(p => section(p, /^Categories$/)),
|
||||
// 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$/)),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
'channels-instance': {
|
||||
guide: guide('channels-instance'),
|
||||
start: opener('admin-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Email (SMTP)')),
|
||||
only(p => section(p, /^Ntfy$/)),
|
||||
only(p => section(p, 'Trip Reminders')),
|
||||
],
|
||||
},
|
||||
'admin-channels': {
|
||||
guide: guide('admin-channels'),
|
||||
start: opener('admin-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Admin Ntfy')),
|
||||
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
|
||||
// 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()),
|
||||
],
|
||||
},
|
||||
|
||||
// ── MCP Access ───────────────────────────────────────────────────────────
|
||||
'mcp-tokens-admin': {
|
||||
guide: guide('mcp-tokens-admin'),
|
||||
start: opener('admin-mcp-tokens'),
|
||||
steps: [
|
||||
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
|
||||
only(p => p.locator('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
|
||||
],
|
||||
},
|
||||
|
||||
// ── GitHub ───────────────────────────────────────────────────────────────
|
||||
'release-history': {
|
||||
guide: guide('release-history'),
|
||||
start: opener('admin-github'),
|
||||
steps: [
|
||||
{
|
||||
// The banner exists only while a newer release is out; otherwise the card's head.
|
||||
target: p => {
|
||||
const banner = p.getByText('Update available', { exact: true })
|
||||
return banner.or(section(p, 'Release History').locator('> div').first()).first()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(section(p, 'Release History').getByRole('button', { name: 'Show details' }).first()).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
target: p => section(p, 'Release History'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Backup ───────────────────────────────────────────────────────────────
|
||||
'create-backup': {
|
||||
guide: guide('create-backup'),
|
||||
start: opener('admin-backup'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }),
|
||||
act: async p => {
|
||||
await section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }).click()
|
||||
await expect(section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first()).toBeVisible({ timeout: 120_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first().locator('xpath=ancestor::div[contains(@class,"py-3")][1]')),
|
||||
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Restore' }).first()),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const list = await p.request.get('/api/backup/list')
|
||||
const body = (await list.json()) as { backups?: { filename: string }[] } | { filename: string }[]
|
||||
const backups = Array.isArray(body) ? body : (body.backups ?? [])
|
||||
for (const b of backups) await p.request.delete(`/api/backup/${encodeURIComponent(b.filename)}`)
|
||||
},
|
||||
},
|
||||
'auto-backup': {
|
||||
guide: guide('auto-backup'),
|
||||
start: opener('admin-backup'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).locator('xpath=ancestor::label[1]'),
|
||||
act: async p => {
|
||||
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
|
||||
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Auto-Backup').getByText('Delete old backups after', { exact: true }).locator('xpath=..')),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
|
||||
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Audit ────────────────────────────────────────────────────────────────
|
||||
'audit-log': {
|
||||
guide: guide('audit-log'),
|
||||
start: opener('admin-audit'),
|
||||
steps: [
|
||||
only(p => cardOf(p.locator('table').first())),
|
||||
only(p => p.getByRole('button', { name: 'Refresh' })),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const ALL_CONTEXTS = [adminContext, ...adminTabContexts]
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureAdminFixtures(request)
|
||||
})
|
||||
|
||||
// The invite guide copies the new link; without the permission the write rejects
|
||||
// and the page logs an unhandled promise, which is noise the pictures do not need.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
})
|
||||
|
||||
test('every registered admin guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
|
||||
})
|
||||
|
||||
test('hero: admin', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, adminContext.id, p => openAdmin(p, 'Users'))
|
||||
})
|
||||
|
||||
for (const ctx of adminTabContexts) {
|
||||
test(`hero: ${ctx.id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, ctx.id, opener(ctx.id))
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,805 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { year } from '../dates'
|
||||
import { ensureAtlasFixtures } from './fixtures'
|
||||
import { ensureDawarichConnection, disconnectDawarich } from './external'
|
||||
import { modal } from './trip-shared'
|
||||
import { atlasGuides, atlasContext } from '../../src/help/contexts/atlas'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
|
||||
* They run in the order the context lists them and leave their marks on the
|
||||
* map on purpose: Greece is marked and removed again, a region of Austria and
|
||||
* Bavaria stay, Iceland and Petra land on the bucket list; the 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.
|
||||
*/
|
||||
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = atlasGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
async function openAtlas(page: Page): Promise<void> {
|
||||
await page.goto('/atlas')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.getByRole('button', { name: 'Stats' })).toBeVisible({ timeout: 20_000 })
|
||||
// The country layer is a canvas; it exists once the borders have arrived.
|
||||
await expect(page.locator('.leaflet-overlay-pane canvas').first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
/** The search box over the map and its dropdown. */
|
||||
const searchInput = (page: Page) => page.getByPlaceholder('Search a country...')
|
||||
const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div[2]')
|
||||
/** A country in the dropdown (its accessible name starts with the flag's alt text). */
|
||||
const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) })
|
||||
/** A geocoded place in the dropdown, under the Places heading. */
|
||||
const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first()
|
||||
/**
|
||||
* The country / region popup: the panel of the shared dialog frame, inside
|
||||
* the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the
|
||||
* two are never open at once.
|
||||
*/
|
||||
const popup = (page: Page) => modal(page).getByRole('dialog')
|
||||
/** The glass panel at the bottom. */
|
||||
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
|
||||
/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */
|
||||
const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first()
|
||||
const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first()
|
||||
/** The detail card the panel grows for a country picked in the search. */
|
||||
const detailCard = (page: Page, country: string) =>
|
||||
panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..')
|
||||
/**
|
||||
* Dawarich's own glass panel, left of the statistics: the one panel with its
|
||||
* name in it. Scoped this way because its two tiles, Wishlist and Countries,
|
||||
* have namesakes in the dialog's tabs once it is open.
|
||||
*/
|
||||
const dawarichPanel = (page: Page) =>
|
||||
page.locator('div.hidden.md\\:flex').filter({ has: page.getByText('Dawarich', { exact: true }) }).first()
|
||||
const tile = (page: Page, label: 'Wishlist' | 'Countries') =>
|
||||
dawarichPanel(page).getByRole('button', { name: label, exact: true })
|
||||
/** The answers in the dialog: every row is one checkbox named after the country or the wish. */
|
||||
const rows = (page: Page) => modal(page).getByRole('checkbox')
|
||||
|
||||
/** The countries the Atlas paints as visited right now, by code. */
|
||||
async function visitedCodes(page: Page): Promise<Set<string>> {
|
||||
const res = await page.request.get('/api/addons/atlas/stats')
|
||||
const { countries } = (await res.json()) as { countries: { code: string; status: string }[] }
|
||||
return new Set(countries.filter(c => c.status === 'visited').map(c => c.code))
|
||||
}
|
||||
/** What was visited before the countries guide confirmed anything, so `cleanup` takes exactly its additions off again. */
|
||||
let visitedBefore = new Set<string>()
|
||||
|
||||
async function searchCountry(page: Page, name: string): Promise<void> {
|
||||
const input = searchInput(page)
|
||||
await input.click()
|
||||
await input.fill('')
|
||||
await typeInto(page, input, name)
|
||||
await expect(countryHit(page, name)).toBeVisible()
|
||||
}
|
||||
|
||||
async function pickCountry(page: Page, name: string): Promise<void> {
|
||||
await countryHit(page, name).click()
|
||||
await page.waitForTimeout(900) // fitBounds animates
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the country popup with the button in its head band. Not with Escape:
|
||||
* after a key press the browser draws its focus ring on the next popup, which
|
||||
* takes the focus as it opens, and that ring would be in the picture.
|
||||
*/
|
||||
async function closePopup(page: Page): Promise<void> {
|
||||
await popup(page).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(popup(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The next unset select of the Add place form in the Bucket List tab: month
|
||||
* and year both read a long dash until picked. The popup's bucket step labels
|
||||
* its two selects instead, so there they are found by Month and Year.
|
||||
*/
|
||||
const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first()
|
||||
|
||||
/** Pick an option in one of the month / year selects. */
|
||||
async function choose(page: Page, trigger: Locator, label: string): Promise<void> {
|
||||
await trigger.click()
|
||||
await page.getByRole('button', { name: label, exact: true }).last().click()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the region path under the middle of the map, so a step can ring it. The
|
||||
* regions are SVG, but no element carries a name; what is at the centre after
|
||||
* flying to a country is what the reader would click first anyway.
|
||||
*/
|
||||
async function tagCentreRegion(page: Page): Promise<void> {
|
||||
// The first region request builds the server's admin-1 index; that can take a while.
|
||||
await expect(page.locator('.leaflet-region-pane path').first()).toBeVisible({ timeout: 90_000 })
|
||||
const map = await page.locator('.leaflet-container').boundingBox()
|
||||
if (!map) throw new Error('no map on screen')
|
||||
const point = { x: map.x + map.width / 2, y: map.y + map.height / 2 }
|
||||
await page.evaluate(({ x, y }) => {
|
||||
document.querySelectorAll('[data-help-target]').forEach(el => el.removeAttribute('data-help-target'))
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!(el instanceof SVGPathElement)) throw new Error(`no region under the map centre, found ${el?.tagName ?? 'nothing'}`)
|
||||
el.setAttribute('data-help-target', '1')
|
||||
}, point)
|
||||
}
|
||||
const centreRegion = (page: Page) => page.locator('path[data-help-target]')
|
||||
|
||||
async function mapCentre(page: Page): Promise<{ x: number; y: number }> {
|
||||
const map = await page.locator('.leaflet-container').boundingBox()
|
||||
if (!map) throw new Error('no map on screen')
|
||||
return { x: map.x + map.width / 2, y: map.y + map.height / 2 }
|
||||
}
|
||||
|
||||
async function hoverMapCentre(page: Page): Promise<void> {
|
||||
const c = await mapCentre(page)
|
||||
await page.mouse.move(c.x, c.y)
|
||||
}
|
||||
|
||||
async function clickMapCentre(page: Page): Promise<void> {
|
||||
const c = await mapCentre(page)
|
||||
await page.mouse.click(c.x, c.y)
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* An invisible box laid over several elements, for a step whose text names
|
||||
* things that have no parent of their own to ring. The runner rings whatever
|
||||
* box the target has, so this gives it one. It takes no pointer events, so the
|
||||
* hover and the click the step makes go to the app underneath, and a step
|
||||
* that uses it needs its own `hover` for the same reason.
|
||||
*/
|
||||
async function spanOver(page: Page, boxes: Box[]): Promise<void> {
|
||||
await page.evaluate(boxes => {
|
||||
document.getElementById('trek-help-span')?.remove()
|
||||
const left = Math.min(...boxes.map(b => b.x))
|
||||
const top = Math.min(...boxes.map(b => b.y))
|
||||
const right = Math.max(...boxes.map(b => b.x + b.width))
|
||||
const bottom = Math.max(...boxes.map(b => b.y + b.height))
|
||||
const el = document.createElement('div')
|
||||
el.id = 'trek-help-span'
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${right - left}px`,
|
||||
height: `${bottom - top}px`,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
document.body.appendChild(el)
|
||||
}, boxes)
|
||||
}
|
||||
const span = (page: Page) => page.locator('#trek-help-span')
|
||||
async function clearSpan(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.getElementById('trek-help-span')?.remove())
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator): Promise<Box> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`no box for ${locator}`)
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* The name the Atlas shows beside the pointer over a region. It is one fixed
|
||||
* div the map moves after the pointer, with no role or name of its own; the
|
||||
* country under the region name is how it is told apart.
|
||||
*/
|
||||
async function regionTooltipBox(page: Page, country: string): Promise<Box> {
|
||||
const box = await page.evaluate(country => {
|
||||
const tip = Array.from(document.querySelectorAll('div')).find(d =>
|
||||
d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country))
|
||||
if (!tip) return null
|
||||
const r = tip.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
||||
}, country)
|
||||
if (!box) throw new Error(`no region tooltip naming ${country} on screen`)
|
||||
return box
|
||||
}
|
||||
|
||||
/** 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'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => { await searchCountry(p, 'Greece') },
|
||||
},
|
||||
{
|
||||
target: p => countryHit(p, 'Greece'),
|
||||
act: async p => {
|
||||
await pickCountry(p, 'Greece')
|
||||
await expect(popup(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'unmark-country': {
|
||||
guide: guide('unmark-country'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Greece')
|
||||
await pickCountry(p, 'Greece')
|
||||
await expect(popup(p).getByText('Remove this country from your visited list?')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: 'Remove', exact: true }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'country-details': {
|
||||
guide: guide('country-details'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => { await searchCountry(p, 'Japan') },
|
||||
},
|
||||
{
|
||||
target: p => countryHit(p, 'Japan'),
|
||||
act: async p => {
|
||||
await pickCountry(p, 'Japan')
|
||||
await expect(detailCard(p, 'Japan')).toBeVisible()
|
||||
// The step promises the card's flag, which is a Twemoji image from a
|
||||
// CDN. Waited for here so the result shows the flag rather than an
|
||||
// empty square; should the CDN be out of reach, the card falls back to
|
||||
// two letters and the image never loads, which fails this step.
|
||||
const flag = detailCard(p, 'Japan').locator('img[alt="JP"]')
|
||||
await expect(flag).toBeVisible({ timeout: 15_000 })
|
||||
await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Show planned countries' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
const toggle = p.getByRole('button', { name: 'Show planned countries' })
|
||||
if ((await toggle.getAttribute('aria-pressed')) !== 'true') await toggle.click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await searchCountry(p, 'Portugal')
|
||||
await pickCountry(p, 'Portugal')
|
||||
await expect(detailCard(p, 'Portugal')).toBeVisible()
|
||||
},
|
||||
target: p => detailCard(p, 'Portugal'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
regions: {
|
||||
guide: guide('regions'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Austria')
|
||||
await pickCountry(p, 'Austria')
|
||||
// An unvisited country opens its popup on arrival; the regions are the point here.
|
||||
await expect(popup(p)).toBeVisible()
|
||||
await closePopup(p)
|
||||
await tagCentreRegion(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The text says hovering names the region. That name floats right of
|
||||
// the pointer and reaches past the region's own box, so a ring round
|
||||
// the region alone ran straight through it. The ring goes round both.
|
||||
prepare: async p => {
|
||||
await hoverMapCentre(p)
|
||||
await settle(p)
|
||||
await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')])
|
||||
},
|
||||
target: span,
|
||||
hover: hoverMapCentre,
|
||||
act: async p => {
|
||||
await clearSpan(p)
|
||||
await clickMapCentre(p)
|
||||
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'search-place': {
|
||||
guide: guide('search-place'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
const input = searchInput(p)
|
||||
await input.click()
|
||||
await typeInto(p, input, 'Munich')
|
||||
await expect(placeHit(p, 'Munich, Bavaria')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => placeHit(p, 'Munich, Bavaria'),
|
||||
act: async p => {
|
||||
await placeHit(p, 'Munich, Bavaria').click()
|
||||
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'bucket-country': {
|
||||
guide: guide('bucket-country'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Iceland')
|
||||
await pickCountry(p, 'Iceland')
|
||||
await expect(popup(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Add to bucket list/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Add to bucket list/ }).click()
|
||||
await expect(popup(p).getByText('When do you plan to visit?')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await choose(p, popup(p).getByLabel('Month', { exact: true }), 'June')
|
||||
await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1)))
|
||||
},
|
||||
// The whole popup: the step names both selects and the button under them.
|
||||
target: p => popup(p),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
await p.getByRole('button', { name: 'Bucket List' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'bucket-place': {
|
||||
guide: guide('bucket-place'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Bucket List' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Bucket List' }).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add place' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Add place' }).click() },
|
||||
},
|
||||
{
|
||||
// The row, not the field alone: the text has the reader press the
|
||||
// search button beside it as well.
|
||||
target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'),
|
||||
act: async p => {
|
||||
const input = p.getByPlaceholder('Name (country, city, place...)')
|
||||
await typeInto(p, input, 'Petra')
|
||||
await input.locator('xpath=following-sibling::button[1]').click()
|
||||
// The geocoder also knows a Petra in Mallorca and a few streets; the one in Jordan is the wish.
|
||||
const hit = p.locator('body > div.bg-surface-card button').filter({ hasText: 'Jordan' }).first()
|
||||
await expect(hit).toBeVisible({ timeout: 15_000 })
|
||||
await hit.click()
|
||||
await expect(input).toHaveValue(/petra/i)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await choose(p, unsetSelect(panel(p)), 'Sep')
|
||||
await choose(p, unsetSelect(panel(p)), String(year(1)))
|
||||
},
|
||||
// The whole form: framed on the small Add button alone, the picture lost
|
||||
// the name and the month, and looked like Add on an empty form.
|
||||
target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'),
|
||||
hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(panel(p).getByText('Petra').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
stats: {
|
||||
guide: guide('stats'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{ target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() },
|
||||
{
|
||||
// The text covers the continents, the streak and this year's trips.
|
||||
// Those are two groups side by side with no box of their own around
|
||||
// both, so the ring gets one laid over them.
|
||||
prepare: async p => {
|
||||
await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))])
|
||||
},
|
||||
target: span,
|
||||
hover: p => continentStats(p).hover(),
|
||||
act: clearSpan,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'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 => {
|
||||
await openAtlas(p)
|
||||
visitedBefore = await visitedCodes(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Countries'),
|
||||
act: async p => {
|
||||
await tile(p, 'Countries').click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Look for countries' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The pitch with its button rather than the button alone: a frame
|
||||
// centred on the button cut off the dialog's head band, and with it the
|
||||
// name of the service this dialog asks.
|
||||
target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Look for countries' }).click()
|
||||
// A year in thirteen 30-day reads, one after another, each of which
|
||||
// Dawarich computes on the spot; the client gives it four minutes.
|
||||
// An empty answer shows a sentence instead of rows and fails here,
|
||||
// which is right: a dialog with nothing to add is no picture.
|
||||
await expect(rows(p).first()).toBeVisible({ timeout: 240_000 })
|
||||
await settle(p)
|
||||
// The flags come from a CDN and land after the rows.
|
||||
await p.waitForTimeout(800)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole dialog: the list step 2 describes (flag, cities, ticked
|
||||
// rows) is only ever pictured here, and a frame centred on the button
|
||||
// cut the rows in half. The button sits in the footer, in the ring too.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click()
|
||||
await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 })
|
||||
// Closed with the button in its head band, not Escape: the focus goes
|
||||
// back to the Countries tile, and after a key press the browser draws
|
||||
// its focus ring there, into the result picture.
|
||||
await modal(p).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// The Atlas re-reads itself after a write; the colours land with the answer.
|
||||
await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size)
|
||||
await settle(p)
|
||||
await p.waitForTimeout(900)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Off the map again, so the next run and the guides after this one start
|
||||
// from the seed. Unmarking tombstones a country; confirming it again from
|
||||
// the dialog lifts that, so a re-run offers the same rows.
|
||||
const after = await visitedCodes(p)
|
||||
for (const code of after) {
|
||||
if (!visitedBefore.has(code)) await p.request.delete(`/api/addons/atlas/country/${code}/mark`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'dawarich-wishes': {
|
||||
guide: guide('dawarich-wishes'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Wishlist'),
|
||||
act: async p => {
|
||||
await tile(p, 'Wishlist').click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Check wishlist' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// As in the countries guide: the pitch with its button, so the head band
|
||||
// stays in the frame.
|
||||
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Check wishlist' }).click()
|
||||
// One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra),
|
||||
// fifteen seconds each at worst; New Zealand and Iceland have none and
|
||||
// are counted in the note instead.
|
||||
await expect(rows(p).first()).toBeVisible({ timeout: 120_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole dialog, as in the countries guide: the rows with distance,
|
||||
// stay and day and the note with the 250 m rule are what step 2 quotes.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click()
|
||||
await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 })
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// The result is the list itself: the ticked date under each wish is a
|
||||
// button labelled Undo, there once the list has re-read itself.
|
||||
await p.getByRole('button', { name: 'Bucket List' }).click()
|
||||
await expect(panel(p).getByRole('button', { name: 'Undo' }).first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Every tick that came from a recording goes back, by the route the Undo
|
||||
// button uses, so the wishes are open again for the next run.
|
||||
const res = await p.request.get('/api/addons/atlas/bucket-list')
|
||||
const { items } = (await res.json()) as { items: { id: number; visited_source: string | null }[] }
|
||||
for (const item of items) {
|
||||
if (item.visited_source === 'dawarich') await p.request.delete(`/api/integrations/dawarich/bucket-list/${item.id}/visit`)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureAtlasFixtures(request)
|
||||
// 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)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide: while the addon is on, every Atlas carries the Dawarich
|
||||
// panel left of Stats. This file's pictures show it on purpose; the files
|
||||
// after it do not, so it goes off again here, whatever happened in between.
|
||||
await disconnectDawarich(request)
|
||||
})
|
||||
|
||||
test('every registered atlas guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(atlasContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: atlas', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, atlasContext.id, openAtlas)
|
||||
})
|
||||
|
||||
for (const id of atlasContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
// The Dawarich reads are a year of countries in thirteen pieces and one
|
||||
// look-up per wish: more waiting on another service than four minutes
|
||||
// may hold on a slow instance.
|
||||
if (id.startsWith('dawarich-')) test.slow()
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,594 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { monthName, monthsAhead, pickerLabelOn } from '../dates'
|
||||
import { ensureExtraTrips, coverFixture, card, EXTRA_TRIPS } from './fixtures'
|
||||
import { 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
|
||||
* `src/help/contexts/dashboard.ts` and has exactly one action per registered
|
||||
* step; the runner refuses a mismatch, so a step added to the registry without
|
||||
* an action here fails this run instead of shipping a raw key.
|
||||
*/
|
||||
|
||||
const LISBON = EXTRA_TRIPS[0].title
|
||||
const NORWAY = EXTRA_TRIPS[1].title
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = dashboardGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
async function openDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.locator('.hero-trip')).toBeVisible()
|
||||
await expect(card(page, LISBON)).toBeVisible()
|
||||
}
|
||||
|
||||
|
||||
/** The trip form, scoped so the cover-search inputs above the title cannot be matched by accident. */
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop')
|
||||
/** The topmost dialog of any kind (confirm, copy, calendar): they all share the backdrop class. */
|
||||
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
|
||||
const titleField = (page: Page) => modal(page).getByPlaceholder('e.g. Summer in Japan')
|
||||
/**
|
||||
* A date picker's trigger by the label above it. The trigger names itself after
|
||||
* its value once one is set (and the end date is filled in the moment the start
|
||||
* is picked), so "End Date" as a button name only works while it is empty.
|
||||
*/
|
||||
const datePicker = (page: Page, label: string) =>
|
||||
modal(page).getByText(label, { exact: true }).locator('xpath=..').getByRole('button').first()
|
||||
|
||||
/** Pick a date in the calendar popup: open it, go to the month, click the day. */
|
||||
async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, monthsAhead: number, dayLabel: string): Promise<void> {
|
||||
await trigger.click()
|
||||
for (let i = 0; i < monthsAhead; i++) {
|
||||
await page.getByRole('button', { name: 'Next month' }).click()
|
||||
await beat(page, 350)
|
||||
}
|
||||
await page.getByRole('button', { name: dayLabel, exact: true }).click()
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
|
||||
|
||||
/** The trip search above the cards (#2190), by its accessible name. */
|
||||
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
|
||||
const JAPAN = 'Autumn in Japan'
|
||||
|
||||
/** 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'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => tripSearch(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await typeInto(p, tripSearch(p), 'Nishiki')
|
||||
await expect(card(p, JAPAN)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(card(p, JAPAN).getByText('Nishiki Market')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => card(p, JAPAN),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Clear search' }).click()
|
||||
await expect(card(p, LISBON)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
if (await tripSearch(p).count()) await tripSearch(p).fill('')
|
||||
},
|
||||
},
|
||||
'create-trip': {
|
||||
guide: guide('create-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.add-trip-card'),
|
||||
act: async p => { await p.locator('.add-trip-card').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => titleField(p),
|
||||
act: async p => { await typeInto(p, titleField(p), NEW_TRIP) },
|
||||
},
|
||||
{
|
||||
target: p => datePicker(p, 'Start Date'),
|
||||
act: async p => {
|
||||
await pickDate(p, datePicker(p, 'Start Date'), 8, pickerLabelOn(monthsAhead(8, 8)))
|
||||
await beat(p, 500)
|
||||
// The end date follows the start date, so its calendar already opens on May.
|
||||
await pickDate(p, datePicker(p, 'End Date'), 0, pickerLabelOn(monthsAhead(8, 15)))
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create New Trip' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create New Trip' }).click()
|
||||
await expect(card(p, NEW_TRIP)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// The delete guide removes this trip on camera; until then it stays.
|
||||
void p
|
||||
},
|
||||
},
|
||||
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Edit'),
|
||||
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => titleField(p),
|
||||
act: async p => {
|
||||
await titleField(p).fill('')
|
||||
await typeInto(p, titleField(p), 'Norway Road Trip')
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update' }).click()
|
||||
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'cover-image': {
|
||||
guide: guide('cover-image'),
|
||||
// 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'),
|
||||
act: async p => { await cardAction(p, LISBON, 'Edit').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
|
||||
act: async p => {
|
||||
await modal(p).locator('input[type="file"]').setInputFiles((await coverPhoto('lisbon')) ?? (await coverFixture()))
|
||||
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await settle(p) },
|
||||
target: p => modal(p).getByRole('button', { name: 'Update' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update' }).click()
|
||||
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
|
||||
await expect(card(p, LISBON).locator('.trip-cover img')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, LISBON, 'Duplicate'),
|
||||
act: async p => { await cardAction(p, LISBON, 'Duplicate').click() },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Copy trip' }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Copy trip' }).click()
|
||||
await expect(p.locator('.trip-card').filter({ hasText: LISBON })).toHaveCount(2, { timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Drop the copy over the API so the grid is back to the seed for the next
|
||||
// guide. By title, not by position: the list came back in an order that
|
||||
// left the copy standing, and a trip nobody expected then broke a guide
|
||||
// three files later, in a screen that has nothing to do with this one.
|
||||
const res = await p.request.get('/api/trips')
|
||||
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
|
||||
const trips = Array.isArray(body) ? body : (body.trips ?? [])
|
||||
for (const t of trips.filter(x => x.title !== LISBON && x.title.startsWith(LISBON))) {
|
||||
const dropped = await p.request.delete(`/api/trips/${t.id}`)
|
||||
if (!dropped.ok()) throw new Error(`could not drop the copy "${t.title}": ${dropped.status()}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'archive-trip': {
|
||||
guide: guide('archive-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Archive'),
|
||||
act: async p => {
|
||||
await cardAction(p, NORWAY, 'Archive').click()
|
||||
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.seg').getByRole('button', { name: 'Archived' }),
|
||||
act: async p => {
|
||||
await p.locator('.seg').getByRole('button', { name: 'Archived' }).click()
|
||||
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Restore'),
|
||||
act: async p => {
|
||||
await cardAction(p, NORWAY, 'Restore').click()
|
||||
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
|
||||
await beat(p, 400)
|
||||
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
|
||||
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'delete-trip': {
|
||||
guide: guide('delete-trip'),
|
||||
start: async p => {
|
||||
await openDashboard(p)
|
||||
await expect(card(p, NEW_TRIP)).toBeVisible()
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NEW_TRIP, 'Delete'),
|
||||
act: async p => { await cardAction(p, NEW_TRIP, 'Delete').click() },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(card(p, NEW_TRIP)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'filter-and-view': {
|
||||
guide: guide('filter-and-view'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.seg'),
|
||||
act: async p => {
|
||||
await p.locator('.seg').getByRole('button', { name: 'Completed' }).click()
|
||||
await beat(p, 900)
|
||||
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
|
||||
await expect(card(p, LISBON)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Toggle view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Toggle view' }).click()
|
||||
await expect(p.locator('.trips.list-view')).toBeVisible()
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.getByRole('button', { name: 'Toggle view' }).click() },
|
||||
},
|
||||
|
||||
'calendar-feed': {
|
||||
guide: guide('calendar-feed'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Subscribe to all trips' }),
|
||||
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')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
// Open the manual links too, so the picture shows every way the step names.
|
||||
prepare: async p => {
|
||||
await p.getByText('Or copy a link manually', { exact: true }).click()
|
||||
await beat(p, 400)
|
||||
},
|
||||
target: p => p.getByRole('dialog').last(),
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const turnOff = p.getByRole('button', { name: 'Turn off' })
|
||||
if (await turnOff.isVisible()) {
|
||||
await turnOff.click()
|
||||
await expect(p.getByRole('button', { name: 'Enable calendar subscription' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
await p.keyboard.press('Escape')
|
||||
},
|
||||
},
|
||||
|
||||
'upcoming-reservations': {
|
||||
guide: guide('upcoming-reservations'),
|
||||
start: async p => {
|
||||
await openDashboard(p)
|
||||
await expect(upcomingRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => widget(p, 'Upcoming reservations'),
|
||||
},
|
||||
{
|
||||
target: p => upcomingRow(p),
|
||||
act: async p => {
|
||||
await upcomingRow(p).click()
|
||||
await expect(p).toHaveURL(/\/trips\/\d+/, { timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
widgets: {
|
||||
guide: guide('widgets'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.locator('nav button').filter({ has: p.locator('svg.lucide-chevron-down') }).first().click()
|
||||
await beat(p, 500)
|
||||
},
|
||||
target: p => p.getByRole('link', { name: 'Settings' }),
|
||||
act: async p => {
|
||||
await p.getByRole('link', { name: 'Settings' }).click()
|
||||
await expect(p).toHaveURL(/\/settings/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Appearance', exact: true }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Appearance', exact: true }).first().click()
|
||||
await expect(p.getByText('Dashboard widgets', { exact: true }).first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByText('Dashboard widgets', { exact: true }).first().scrollIntoViewIfNeeded()
|
||||
await beat(p, 400)
|
||||
},
|
||||
// The Section card itself: the nearest rounded container around its heading.
|
||||
target: p => p.getByText('Dashboard widgets', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('link', { name: 'My Trips' }),
|
||||
act: async p => {
|
||||
await p.getByRole('link', { name: 'My Trips' }).click()
|
||||
await expect(p.locator('.hero-trip')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'currency-widget': {
|
||||
guide: guide('currency-widget'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.tool').filter({ hasText: 'Currency' }).locator('.fx-input'),
|
||||
act: async p => {
|
||||
const amount = p.locator('.tool').filter({ hasText: 'Currency' }).locator('input.amt').first()
|
||||
await amount.fill('')
|
||||
await typeInto(p, amount, '250')
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Swap currencies' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Swap currencies' }).click() },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'timezones-widget': {
|
||||
guide: guide('timezones-widget'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
// The picker opens under the + and lists the zones by their IANA names.
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Add timezone' }).click()
|
||||
await widget(p, 'Timezones').getByRole('button', { name: 'Search timezone…' }).click()
|
||||
await typeInto(p, p.getByLabel('Search', { exact: true }), 'Lisbon')
|
||||
await expect(p.getByRole('button', { name: 'Europe/Lisbon', exact: true })).toBeVisible()
|
||||
},
|
||||
target: p => widget(p, 'Timezones'),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Europe/Lisbon', exact: true }).click()
|
||||
await expect(clockRow(p, 'Lisbon')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await clockRow(p, 'Lisbon').hover()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => clockRow(p, 'Lisbon').locator('.tz-del'),
|
||||
act: async p => {
|
||||
await clockRow(p, 'Lisbon').locator('.tz-del').click()
|
||||
await expect(clockRow(p, 'Lisbon')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
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() }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(dashboardContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: the dashboard', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, dashboardContext.id, openDashboard)
|
||||
})
|
||||
|
||||
for (const id of dashboardContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
/**
|
||||
* A synthetic Dawarich recording for the help-center pictures.
|
||||
*
|
||||
* TREK only ever reads from Dawarich, so the media run has to put a recording
|
||||
* there itself. This module makes one: a phone track that follows the seeded
|
||||
* demo trip through Tokyo and Kyoto, day by day, plus five short trips in the
|
||||
* year before it so the Atlas has countries to offer. Everything is relative to
|
||||
* the day the pictures are taken, so it stays inside the trip's dates whenever
|
||||
* the run happens.
|
||||
*
|
||||
* The points go through Dawarich's own batch endpoint as GeoJSON features;
|
||||
* see `ensureDawarichFixtures` in fixtures.ts for the upload.
|
||||
*/
|
||||
|
||||
export interface TrackFeature {
|
||||
type: 'Feature'
|
||||
geometry: { type: 'Point'; coordinates: [number, number] }
|
||||
properties: {
|
||||
timestamp: string
|
||||
horizontal_accuracy: number
|
||||
altitude: number
|
||||
speed: number
|
||||
device_id: string
|
||||
}
|
||||
}
|
||||
|
||||
/** One place on a day: when the phone gets there (local time) and how long it stays. */
|
||||
interface Waypoint {
|
||||
t: string
|
||||
lat: number
|
||||
lng: number
|
||||
stay?: number
|
||||
}
|
||||
|
||||
interface DayPlan {
|
||||
offset: number
|
||||
utcOffset: number
|
||||
points: Waypoint[]
|
||||
}
|
||||
|
||||
export const DAWARICH_DEVICE = 'trek-help-fixture'
|
||||
|
||||
const JST = 9
|
||||
|
||||
// Every place the trail visits. The trip's own places come first and match the
|
||||
// seed's coordinates, so the dashed line ends exactly on the markers.
|
||||
const P = {
|
||||
sensoji: [35.7148, 139.7967],
|
||||
nakamise: [35.7118, 139.7963],
|
||||
asakusaStation: [35.7109, 139.7975],
|
||||
toyosuStation: [35.6551, 139.7961],
|
||||
teamlab: [35.6486, 139.79],
|
||||
shinjukuStation: [35.6896, 139.7006],
|
||||
gracery: [35.6946, 139.7012],
|
||||
shibuya: [35.6595, 139.7005],
|
||||
harajuku: [35.6702, 139.7027],
|
||||
meiji: [35.6764, 139.6993],
|
||||
ueno: [35.7146, 139.7714],
|
||||
akihabara: [35.6984, 139.7731],
|
||||
ginza: [35.6717, 139.765],
|
||||
tsukiji: [35.6654, 139.7707],
|
||||
tokyoTower: [35.6586, 139.7454],
|
||||
roppongi: [35.6605, 139.7292],
|
||||
tokyoStation: [35.6812, 139.7671],
|
||||
shinYokohama: [35.5075, 139.6174],
|
||||
nagoya: [35.1709, 136.8815],
|
||||
kyotoStation: [34.9858, 135.7588],
|
||||
kanra: [34.9942, 135.7592],
|
||||
fushimi: [34.9671, 135.7727],
|
||||
arashiyama: [35.017, 135.6716],
|
||||
togetsukyo: [35.013, 135.6776],
|
||||
nishiki: [35.005, 135.7649],
|
||||
kiyomizu: [34.9949, 135.785],
|
||||
gion: [35.0037, 135.7752],
|
||||
yasaka: [35.0037, 135.7785],
|
||||
todaiji: [34.689, 135.8398],
|
||||
naraPark: [34.6851, 135.843],
|
||||
ginkakuji: [35.027, 135.7982],
|
||||
philosophersPath: [35.0227, 135.7948],
|
||||
nanzenji: [35.0114, 135.7939],
|
||||
heian: [35.0163, 135.7824],
|
||||
} as const
|
||||
|
||||
type Place = readonly [number, number]
|
||||
|
||||
const at = (t: string, [lat, lng]: Place, stay?: number): Waypoint => ({ t, lat, lng, stay })
|
||||
|
||||
/** The trip itself: the last day is the picture day (offset 0), the first is nine days before. */
|
||||
function japanDays(): DayPlan[] {
|
||||
const day = (offset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset: JST, points })
|
||||
return [
|
||||
day(-9, [
|
||||
at('08:40', P.gracery, 10),
|
||||
at('09:30', P.sensoji, 90),
|
||||
at('11:05', P.nakamise, 40),
|
||||
at('11:50', P.asakusaStation, 5),
|
||||
at('12:40', P.ginza, 70),
|
||||
at('13:50', P.toyosuStation, 5),
|
||||
at('14:00', P.teamlab, 120),
|
||||
at('16:10', P.toyosuStation, 5),
|
||||
at('17:00', P.shinjukuStation, 10),
|
||||
at('17:15', P.gracery, 60),
|
||||
at('19:00', P.shinjukuStation, 90),
|
||||
at('21:00', P.gracery, 30),
|
||||
]),
|
||||
day(-8, [
|
||||
at('09:00', P.gracery, 10),
|
||||
at('09:40', P.shibuya, 45),
|
||||
at('10:40', P.harajuku, 30),
|
||||
at('11:20', P.meiji, 75),
|
||||
at('13:00', P.harajuku, 60),
|
||||
at('14:30', P.shibuya, 90),
|
||||
at('16:40', P.shinjukuStation, 10),
|
||||
at('17:00', P.gracery, 240),
|
||||
]),
|
||||
day(-7, [
|
||||
at('09:10', P.gracery, 10),
|
||||
at('10:00', P.ueno, 120),
|
||||
at('12:30', P.akihabara, 100),
|
||||
at('14:40', P.ginza, 120),
|
||||
at('17:20', P.shinjukuStation, 10),
|
||||
at('17:40', P.gracery, 200),
|
||||
]),
|
||||
day(-6, [
|
||||
at('08:30', P.gracery, 10),
|
||||
at('09:20', P.tsukiji, 90),
|
||||
at('11:30', P.tokyoTower, 80),
|
||||
at('13:30', P.roppongi, 150),
|
||||
at('16:40', P.shinjukuStation, 10),
|
||||
at('17:00', P.gracery, 240),
|
||||
]),
|
||||
day(-5, [
|
||||
at('07:40', P.gracery, 10),
|
||||
at('08:15', P.tokyoStation, 15),
|
||||
at('08:48', P.shinYokohama, 1),
|
||||
at('10:05', P.nagoya, 1),
|
||||
at('10:45', P.kyotoStation, 20),
|
||||
at('11:20', P.kanra, 120),
|
||||
at('14:00', P.gion, 90),
|
||||
at('16:20', P.kanra, 30),
|
||||
at('17:00', P.fushimi, 150),
|
||||
at('20:00', P.kanra, 60),
|
||||
]),
|
||||
day(-4, [
|
||||
at('08:30', P.kanra, 10),
|
||||
at('09:30', P.togetsukyo, 30),
|
||||
at('10:10', P.arashiyama, 60),
|
||||
at('11:20', P.togetsukyo, 40),
|
||||
at('12:30', P.nishiki, 90),
|
||||
at('14:20', P.gion, 60),
|
||||
at('15:40', P.kanra, 240),
|
||||
]),
|
||||
day(-3, [
|
||||
at('09:00', P.kanra, 10),
|
||||
at('09:50', P.kiyomizu, 100),
|
||||
at('11:50', P.yasaka, 40),
|
||||
at('12:40', P.gion, 120),
|
||||
at('15:00', P.heian, 60),
|
||||
at('16:30', P.kanra, 240),
|
||||
]),
|
||||
day(-2, [
|
||||
at('08:20', P.kanra, 10),
|
||||
at('08:45', P.kyotoStation, 15),
|
||||
at('09:45', P.naraPark, 60),
|
||||
at('11:00', P.todaiji, 90),
|
||||
at('12:40', P.naraPark, 120),
|
||||
at('15:30', P.kyotoStation, 10),
|
||||
at('16:00', P.kanra, 200),
|
||||
]),
|
||||
day(-1, [
|
||||
at('09:00', P.kanra, 10),
|
||||
at('09:50', P.nanzenji, 60),
|
||||
at('11:00', P.philosophersPath, 45),
|
||||
at('11:50', P.ginkakuji, 60),
|
||||
at('13:20', P.nishiki, 90),
|
||||
at('15:20', P.kanra, 240),
|
||||
]),
|
||||
day(0, [
|
||||
at('08:30', P.kanra, 30),
|
||||
at('09:30', P.gion, 60),
|
||||
at('11:00', P.nishiki, 60),
|
||||
at('12:30', P.kanra, 60),
|
||||
at('14:00', P.kyotoStation, 120),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Five short trips in the year before the pictures, in countries the Atlas does
|
||||
* not have yet: Portugal, Jordan, Czechia, South Korea and Taiwan. Jordan
|
||||
* dwells at Petra long enough for the wishlist rule (250 m, 20 minutes).
|
||||
*/
|
||||
function worldDays(): DayPlan[] {
|
||||
const day = (offset: number, utcOffset: number, points: Waypoint[]): DayPlan => ({ offset, utcOffset, points })
|
||||
return [
|
||||
// Portugal, spring
|
||||
day(-158, 1, [
|
||||
at('09:30', [38.7077, -9.1366], 60),
|
||||
at('11:00', [38.7114, -9.13], 120),
|
||||
at('14:00', [38.6979, -9.2065], 90),
|
||||
at('15:50', [38.6916, -9.216], 60),
|
||||
at('18:00', [38.7077, -9.1366], 120),
|
||||
]),
|
||||
day(-157, 1, [
|
||||
at('10:00', [41.1407, -8.611], 90),
|
||||
at('12:00', [41.147, -8.6149], 60),
|
||||
at('13:30', [41.1403, -8.6094], 120),
|
||||
at('16:00', [41.1496, -8.6109], 120),
|
||||
]),
|
||||
// Jordan, late winter
|
||||
day(-205, 3, [
|
||||
at('09:00', [31.9544, 35.9344], 90),
|
||||
at('11:00', [31.9539, 35.9106], 120),
|
||||
at('15:00', [31.717, 35.587], 150),
|
||||
]),
|
||||
day(-204, 3, [
|
||||
at('08:30', [30.3285, 35.4444], 90),
|
||||
at('10:20', [30.3222, 35.4517], 120),
|
||||
at('13:00', [30.3285, 35.4444], 60),
|
||||
at('15:30', [29.576, 35.42], 180),
|
||||
]),
|
||||
// Czechia, early summer
|
||||
day(-100, 2, [
|
||||
at('09:30', [50.0875, 14.4213], 90),
|
||||
at('11:15', [50.0865, 14.4114], 45),
|
||||
at('12:30', [50.0911, 14.4016], 150),
|
||||
at('16:00', [50.0645, 14.4183], 90),
|
||||
]),
|
||||
day(-99, 2, [
|
||||
at('10:00', [50.0793, 14.4275], 120),
|
||||
at('13:00', [50.0865, 14.4114], 60),
|
||||
at('15:00', [50.0875, 14.4213], 120),
|
||||
]),
|
||||
// South Korea and Taiwan, one journey last autumn
|
||||
day(-300, 9, [
|
||||
at('09:30', [37.5796, 126.977], 120),
|
||||
at('12:00', [37.5826, 126.9831], 90),
|
||||
at('14:30', [37.5636, 126.9856], 120),
|
||||
at('17:30', [37.5512, 126.9882], 90),
|
||||
]),
|
||||
day(-299, 9, [
|
||||
at('10:00', [37.5563, 126.9236], 150),
|
||||
at('14:00', [37.5796, 126.977], 90),
|
||||
]),
|
||||
day(-297, 8, [
|
||||
at('10:00', [25.0372, 121.4999], 60),
|
||||
at('11:30', [25.0421, 121.5074], 120),
|
||||
at('15:00', [25.0339, 121.5645], 120),
|
||||
at('18:30', [25.088, 121.5245], 120),
|
||||
]),
|
||||
day(-296, 8, [
|
||||
at('10:30', [25.1096, 121.8446], 240),
|
||||
at('16:00', [25.0421, 121.5074], 120),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
/** Deterministic jitter, so two runs produce the same recording. */
|
||||
function noise(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
function isoAt(dayIso: string, hhmm: string, utcOffset: number, plusSeconds = 0): string {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
const local = Date.UTC(+dayIso.slice(0, 4), +dayIso.slice(5, 7) - 1, +dayIso.slice(8, 10), h, m)
|
||||
return new Date(local - utcOffset * 3600_000 + plusSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
function minutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
function shiftDay(baseDay: string, offset: number): string {
|
||||
const d = new Date(`${baseDay}T00:00:00Z`)
|
||||
d.setUTCDate(d.getUTCDate() + offset)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function feature(lng: number, lat: number, timestamp: string, speed: number, seed: number): TrackFeature {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [+lng.toFixed(6), +lat.toFixed(6)] },
|
||||
properties: {
|
||||
timestamp,
|
||||
horizontal_accuracy: 4 + Math.round(noise(seed) * 8),
|
||||
altitude: 20 + Math.round(noise(seed + 1) * 30),
|
||||
speed: +speed.toFixed(2),
|
||||
device_id: DAWARICH_DEVICE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns one day's waypoints into a track: a point a minute while moving, one every five while staying. */
|
||||
function dayFeatures(dayIso: string, plan: DayPlan, seedBase: number): TrackFeature[] {
|
||||
const out: TrackFeature[] = []
|
||||
const pts = plan.points
|
||||
let seed = seedBase
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const here = pts[i]
|
||||
const stay = here.stay ?? 0
|
||||
const arrive = minutes(here.t)
|
||||
for (let k = 0; k <= stay; k += 5) {
|
||||
seed++
|
||||
const wobble = 0.00012
|
||||
out.push(
|
||||
feature(
|
||||
here.lng + (noise(seed) - 0.5) * wobble,
|
||||
here.lat + (noise(seed + 7) - 0.5) * wobble,
|
||||
isoAt(dayIso, here.t, plan.utcOffset, k * 60),
|
||||
0,
|
||||
seed,
|
||||
),
|
||||
)
|
||||
}
|
||||
const next = pts[i + 1]
|
||||
if (!next) break
|
||||
const leaveAt = arrive + stay
|
||||
const travel = minutes(next.t) - leaveAt
|
||||
if (travel <= 0) continue
|
||||
// Moving: straight line with a little sway, a point a minute. Long hops
|
||||
// (the Shinkansen) get thinned out so a day never exceeds Dawarich's page.
|
||||
const step = travel > 60 ? 2 : 1
|
||||
const km = Math.hypot((next.lat - here.lat) * 111, (next.lng - here.lng) * 91)
|
||||
const speed = (km * 1000) / (travel * 60)
|
||||
for (let m = step; m < travel; m += step) {
|
||||
seed++
|
||||
const f = m / travel
|
||||
const sway = Math.sin(f * Math.PI) * 0.0008 * (noise(seed) - 0.5)
|
||||
out.push(
|
||||
feature(
|
||||
here.lng + (next.lng - here.lng) * f + sway,
|
||||
here.lat + (next.lat - here.lat) * f + sway,
|
||||
isoAt(dayIso, here.t, plan.utcOffset, (stay + m) * 60),
|
||||
speed,
|
||||
seed,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The whole recording for a picture day: the trip's ten days plus the year before. */
|
||||
export function dawarichRecording(baseDay: string): { trip: TrackFeature[]; world: TrackFeature[] } {
|
||||
const build = (plans: DayPlan[], seedBase: number) =>
|
||||
plans.flatMap((plan, i) => dayFeatures(shiftDay(baseDay, plan.offset), plan, seedBase + i * 10_000))
|
||||
return { trip: build(japanDays(), 1), world: build(worldDays(), 500_000) }
|
||||
}
|
||||
|
||||
/** The UTC window one part of the recording covers, for checking whether it is already there. */
|
||||
export function recordingWindow(baseDay: string, part: 'trip' | 'world'): { from: string; to: string } {
|
||||
const plans = part === 'trip' ? japanDays() : worldDays()
|
||||
const offsets = plans.map(p => p.offset)
|
||||
return { from: shiftDay(baseDay, Math.min(...offsets) - 1), to: shiftDay(baseDay, Math.max(...offsets) + 1) }
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { day, PICTURE_DAY_ISO } from '../dates'
|
||||
import { DAWARICH_DEVICE, dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track'
|
||||
|
||||
/**
|
||||
* The services outside TREK that a few guides need: an AirTrail with flights,
|
||||
* a Dawarich with a recording, a Nextcloud with a folder of documents. Each is
|
||||
* reached through its own API with the address and key from the environment
|
||||
* (`e2e/help/media.env`, see README), and each fixture puts the service into
|
||||
* the state the guide expects before TREK is connected to it. Nothing here is
|
||||
* silent: a missing variable or an unreachable service fails the run rather
|
||||
* than producing a picture of an empty panel.
|
||||
*
|
||||
* TREK's SSRF guard refuses loopback whatever the flags say, so every address
|
||||
* has to be the machine's LAN address, and the media backend needs
|
||||
* ALLOW_INTERNAL_NETWORK=true to reach it at all.
|
||||
*/
|
||||
|
||||
function env(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`)
|
||||
return value.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function expectOk(res: Response, what: string): Promise<Response> {
|
||||
if (!res.ok) throw new Error(`${what}: HTTP ${res.status} ${await res.text()}`)
|
||||
return res
|
||||
}
|
||||
|
||||
// ── AirTrail ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AirtrailWanted {
|
||||
flightNumber: string
|
||||
from: string
|
||||
to: string
|
||||
offset: number
|
||||
departureTime: string
|
||||
arrivalTime: string
|
||||
airline: string
|
||||
aircraft: string
|
||||
seat: 'window' | 'aisle'
|
||||
seatNumber: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Two flights home on the trip's last day that connect in Tokyo, so the picker
|
||||
* offers to import them as one flight with a layover, and the flights of the
|
||||
* Lisbon weekend in spring for its "Other flights" list. LH716 out is already
|
||||
* in the seed and would only be skipped as a duplicate, so it stays out.
|
||||
*/
|
||||
export const AIRTRAIL_FLIGHTS: AirtrailWanted[] = [
|
||||
{ flightNumber: 'NH16', from: 'ITM', to: 'HND', offset: 0, departureTime: '08:00', arrivalTime: '09:10', airline: 'ANA', aircraft: 'B789', seat: 'window', seatNumber: '12A' },
|
||||
{ flightNumber: 'LH717', from: 'HND', to: 'FRA', offset: 0, departureTime: '12:35', arrivalTime: '18:25', airline: 'DLH', aircraft: 'A359', seat: 'aisle', seatNumber: '31C' },
|
||||
{ flightNumber: 'TP573', from: 'FRA', to: 'LIS', offset: -158, departureTime: '10:20', arrivalTime: '12:25', airline: 'TAP', aircraft: 'A320', seat: 'window', seatNumber: '7F' },
|
||||
{ flightNumber: 'TP574', from: 'LIS', to: 'FRA', offset: -156, departureTime: '13:10', arrivalTime: '17:05', airline: 'TAP', aircraft: 'A320', seat: 'aisle', seatNumber: '9C' },
|
||||
]
|
||||
|
||||
interface AirtrailFlightRow {
|
||||
id: number
|
||||
flightNumber: string | null
|
||||
date: string
|
||||
passengers?: { id: number; userId: string | null }[]
|
||||
seats?: { id: number; userId: string | null }[]
|
||||
}
|
||||
|
||||
/** Puts the wanted flights into the AirTrail account the key belongs to, once. */
|
||||
export async function ensureAirtrailFlights(): Promise<void> {
|
||||
const base = env('HELP_MEDIA_AIRTRAIL_URL')
|
||||
const headers = { Authorization: `Bearer ${env('HELP_MEDIA_AIRTRAIL_API_KEY')}`, 'Content-Type': 'application/json' }
|
||||
const listed = await expectOk(await fetch(`${base}/api/flight/list`, { headers }), 'AirTrail flight list')
|
||||
const { flights } = (await listed.json()) as { flights: AirtrailFlightRow[] }
|
||||
for (const wanted of AIRTRAIL_FLIGHTS) {
|
||||
const date = day(wanted.offset)
|
||||
const existing = flights.find(f => f.flightNumber === wanted.flightNumber)
|
||||
if (existing && existing.date === date) continue
|
||||
const owner = (existing?.passengers ?? existing?.seats ?? []).find(p => p.userId)
|
||||
const body = {
|
||||
...(existing ? { id: existing.id } : {}),
|
||||
from: wanted.from,
|
||||
to: wanted.to,
|
||||
departure: date,
|
||||
departureTime: wanted.departureTime,
|
||||
arrival: date,
|
||||
arrivalTime: wanted.arrivalTime,
|
||||
airline: wanted.airline,
|
||||
flightNumber: wanted.flightNumber,
|
||||
aircraft: wanted.aircraft,
|
||||
passengers: [{ ...(owner ? { id: owner.id } : {}), userId: '<USER_ID>', seat: wanted.seat, seatNumber: wanted.seatNumber, seatClass: 'economy', flightReason: 'leisure' }],
|
||||
}
|
||||
await expectOk(await fetch(`${base}/api/flight/save`, { method: 'POST', headers, body: JSON.stringify(body) }), `AirTrail save ${wanted.flightNumber}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Switches the addon on and connects the signed-in user to the AirTrail, loudly. */
|
||||
export async function ensureAirtrailConnection(api: APIRequestContext): Promise<void> {
|
||||
await ensureAirtrailFlights()
|
||||
await toggleAddon(api, 'airtrail', true)
|
||||
const saved = await api.put('/api/integrations/airtrail/settings', {
|
||||
data: { url: env('HELP_MEDIA_AIRTRAIL_URL'), apiKey: env('HELP_MEDIA_AIRTRAIL_API_KEY') },
|
||||
})
|
||||
if (!saved.ok()) throw new Error(`could not save the AirTrail connection: ${saved.status()} ${await saved.text()}`)
|
||||
const status = await api.get('/api/integrations/airtrail/status')
|
||||
const body = (await status.json()) as { connected: boolean; error?: string }
|
||||
if (!body.connected) throw new Error(`AirTrail is not reachable from the media backend: ${body.error ?? status.status()} (is ALLOW_INTERNAL_NETWORK=true?)`)
|
||||
}
|
||||
|
||||
export async function disconnectAirtrail(api: APIRequestContext): Promise<void> {
|
||||
await api.put('/api/integrations/airtrail/settings', { data: { url: '' } })
|
||||
await toggleAddon(api, 'airtrail', false)
|
||||
}
|
||||
|
||||
// ── Dawarich ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The ids of the fixture's own points between two instants (ISO), every page of them. */
|
||||
async function fixturePointIds(base: string, key: string, from: string, to: string): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await expectOk(
|
||||
await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1000&page=${page}`, { headers: { Authorization: `Bearer ${key}` } }),
|
||||
`Dawarich points ${from}..${to}`,
|
||||
)
|
||||
const points = (await res.json()) as { id: number; tracker_id?: string | null }[]
|
||||
// Only what this fixture uploaded: whatever else the account holds is not ours to touch.
|
||||
ids.push(...points.filter(p => p.tracker_id === DAWARICH_DEVICE).map(p => p.id))
|
||||
if (points.length === 0 || page >= Number(res.headers.get('x-total-pages') ?? 1)) return ids
|
||||
}
|
||||
}
|
||||
|
||||
const shiftIso = (iso: string, days: number) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
|
||||
const aroundInstant = (iso: string, seconds: number) => new Date(Date.parse(iso) + seconds * 1000).toISOString()
|
||||
|
||||
/**
|
||||
* Uploads the synthetic recording, one part at a time, unless this run's copy
|
||||
* of it is already there.
|
||||
*
|
||||
* The recording moves with the picture day, so the copy an earlier run left
|
||||
* sits a few days off this run's and overlaps its window. Asking whether the
|
||||
* window held any point at all found that copy and uploaded nothing, and the
|
||||
* trip map then drew the earlier run's last days, in Kyoto, on this run's
|
||||
* first days, which are in Tokyo. So the probe asks for this run's first and
|
||||
* last point, and a part that lacks either is cleared of the fixture's own
|
||||
* points, a month either side of its window, before it goes up again.
|
||||
*/
|
||||
export async function ensureDawarichRecording(): Promise<void> {
|
||||
const base = env('HELP_MEDIA_DAWARICH_URL')
|
||||
const key = env('HELP_MEDIA_DAWARICH_API_KEY')
|
||||
const recording = dawarichRecording(PICTURE_DAY_ISO)
|
||||
for (const part of ['world', 'trip'] as const) {
|
||||
const features = recording[part]
|
||||
const ends = [features[0], features[features.length - 1]].map(f => f.properties.timestamp)
|
||||
let present = true
|
||||
for (const at of ends) {
|
||||
if ((await fixturePointIds(base, key, aroundInstant(at, -1), aroundInstant(at, 1))).length === 0) present = false
|
||||
}
|
||||
if (present) continue
|
||||
|
||||
const { from, to } = recordingWindow(PICTURE_DAY_ISO, part)
|
||||
const stale = await fixturePointIds(base, key, shiftIso(from, -30), shiftIso(to, 30))
|
||||
for (let i = 0; i < stale.length; i += 1000) {
|
||||
await expectOk(
|
||||
await fetch(`${base}/api/v1/points/bulk_destroy`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ point_ids: stale.slice(i, i + 1000) }),
|
||||
}),
|
||||
`Dawarich clear ${part}`,
|
||||
)
|
||||
}
|
||||
for (let i = 0; i < features.length; i += 500) {
|
||||
const batch: TrackFeature[] = features.slice(i, i + 500)
|
||||
await expectOk(
|
||||
await fetch(`${base}/api/v1/points?api_key=${key}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locations: batch }),
|
||||
}),
|
||||
`Dawarich upload ${part} batch ${i / 500 + 1}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two wishes the recording reaches, with coordinates so the scan can match them. */
|
||||
export const DAWARICH_WISHES = [
|
||||
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, country_code: 'JP' },
|
||||
{ name: 'Wadi Rum', lat: 29.576, lng: 35.42, country_code: 'JO' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Switches the addon on, connects the signed-in user to the Dawarich and adds
|
||||
* the wishes. Automatic syncing stays off: a sync would seed suggestion rows
|
||||
* that head the Places column of every later picture.
|
||||
*/
|
||||
export async function ensureDawarichConnection(api: APIRequestContext): Promise<void> {
|
||||
await ensureDawarichRecording()
|
||||
await toggleAddon(api, 'dawarich', true)
|
||||
const saved = await api.put('/api/integrations/dawarich/settings', {
|
||||
data: { url: env('HELP_MEDIA_DAWARICH_URL'), apiKey: env('HELP_MEDIA_DAWARICH_API_KEY'), allowInsecureTls: false, syncEnabled: false },
|
||||
})
|
||||
if (!saved.ok()) throw new Error(`could not save the Dawarich connection: ${saved.status()} ${await saved.text()}`)
|
||||
// The probe wants the address in its body; the key it takes from the connection just saved.
|
||||
const test = await api.post('/api/integrations/dawarich/test', { data: { url: env('HELP_MEDIA_DAWARICH_URL'), allowInsecureTls: false } })
|
||||
const body = (await test.json()) as { connected: boolean; error?: string; errorDetail?: string }
|
||||
if (!body.connected) throw new Error(`Dawarich is not reachable from the media backend: ${body.error ?? ''} ${body.errorDetail ?? ''} (is ALLOW_INTERNAL_NETWORK=true?)`)
|
||||
|
||||
const listRes = await api.get('/api/addons/atlas/bucket-list')
|
||||
const { items } = (await listRes.json()) as { items: { name: string }[] }
|
||||
for (const wish of DAWARICH_WISHES) {
|
||||
if (items.some(i => i.name === wish.name)) continue
|
||||
const created = await api.post('/api/addons/atlas/bucket-list', { data: wish })
|
||||
if (!created.ok()) throw new Error(`could not add the ${wish.name} wish: ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDawarich(api: APIRequestContext): Promise<void> {
|
||||
await api.delete('/api/integrations/dawarich/settings')
|
||||
await toggleAddon(api, 'dawarich', false)
|
||||
}
|
||||
|
||||
// ── Nextcloud ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const NEXTCLOUD_BASE_PATH = '/Reisen'
|
||||
export const NEXTCLOUD_FOLDER = 'Autumn in Japan'
|
||||
export const NEXTCLOUD_DOCUMENTS = ['ryokan-invoice.pdf', 'shinkansen-eticket.pdf']
|
||||
|
||||
/** What `drawPdf` in fixtures.ts takes; repeated here so this file needs nothing from it. */
|
||||
export interface StoreDocument {
|
||||
issuer: string
|
||||
title: string
|
||||
reference: string
|
||||
fields: readonly (readonly [string, string])[]
|
||||
footer: string
|
||||
}
|
||||
|
||||
function davRoot(): { url: string; auth: string } {
|
||||
const url = `${env('HELP_MEDIA_NEXTCLOUD_URL')}/remote.php/dav/files/${env('HELP_MEDIA_NEXTCLOUD_USER')}`
|
||||
const auth = 'Basic ' + Buffer.from(`${env('HELP_MEDIA_NEXTCLOUD_USER')}:${env('HELP_MEDIA_NEXTCLOUD_PASSWORD')}`).toString('base64')
|
||||
return { url, auth }
|
||||
}
|
||||
|
||||
async function dav(method: string, path: string, body?: BodyInit, extra: Record<string, string> = {}): Promise<Response> {
|
||||
const { url, auth } = davRoot()
|
||||
const target = url + path.split('/').map(encodeURIComponent).join('/')
|
||||
return fetch(target, { method, headers: { Authorization: auth, ...extra }, body })
|
||||
}
|
||||
|
||||
/**
|
||||
* The store side of the document-sync guide: a base folder with one trip
|
||||
* folder holding two documents and nothing else, however the last run left it.
|
||||
* `pdf` draws the documents, so this file stays free of binary fixtures.
|
||||
*/
|
||||
export async function ensureNextcloudFolder(pdf: (doc: StoreDocument) => Buffer): Promise<void> {
|
||||
const folder = `${NEXTCLOUD_BASE_PATH}/${NEXTCLOUD_FOLDER}`
|
||||
for (const dir of [NEXTCLOUD_BASE_PATH, folder]) {
|
||||
const made = await dav('MKCOL', `${dir}/`)
|
||||
if (![201, 405].includes(made.status)) throw new Error(`Nextcloud MKCOL ${dir}: HTTP ${made.status} ${await made.text()}`)
|
||||
}
|
||||
const listing = await dav('PROPFIND', `${folder}/`, undefined, { Depth: '1' })
|
||||
if (listing.status !== 207) throw new Error(`Nextcloud PROPFIND ${folder}: HTTP ${listing.status}`)
|
||||
const names = [...(await listing.text()).matchAll(/<d:href>([^<]+)<\/d:href>/g)]
|
||||
.map(m => decodeURIComponent(m[1]).replace(/\/$/, '').split('/').pop() ?? '')
|
||||
.filter(n => n && n !== NEXTCLOUD_FOLDER)
|
||||
for (const name of names) {
|
||||
if (NEXTCLOUD_DOCUMENTS.includes(name)) continue
|
||||
const gone = await dav('DELETE', `${folder}/${name}`)
|
||||
if (![204, 404].includes(gone.status)) throw new Error(`Nextcloud DELETE ${name}: HTTP ${gone.status}`)
|
||||
}
|
||||
const documents: Record<string, StoreDocument> = {
|
||||
'ryokan-invoice.pdf': {
|
||||
issuer: 'Hakone Ginyu',
|
||||
title: 'Invoice',
|
||||
reference: 'Invoice 2026-0918, confirmation RY-4471',
|
||||
fields: [
|
||||
['Guests', '2 adults, one room'],
|
||||
['Nights', 'Two nights, half board'],
|
||||
['Room and board', 'JPY 84,000'],
|
||||
['Onsen tax', 'JPY 12,000'],
|
||||
['Total, paid by card', 'JPY 96,000'],
|
||||
],
|
||||
footer: 'Paid in full. Thank you for staying with us.',
|
||||
},
|
||||
'shinkansen-eticket.pdf': {
|
||||
issuer: 'JR Central',
|
||||
title: 'Reserved seat ticket',
|
||||
reference: 'Nozomi 21, Tokyo to Kyoto',
|
||||
fields: [
|
||||
['Date', 'Day 6 of the trip'],
|
||||
['Departure / arrival', 'Tokyo 08:30, Kyoto 10:45'],
|
||||
['Car and seats', 'Car 7, seats 12A and 12B'],
|
||||
['Class', 'Ordinary, reserved, non-smoking'],
|
||||
],
|
||||
footer: 'Keep the ticket until you leave the station; the gate reads it twice.',
|
||||
},
|
||||
}
|
||||
for (const name of NEXTCLOUD_DOCUMENTS) {
|
||||
if (names.includes(name)) continue
|
||||
const bytes = pdf(documents[name])
|
||||
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
const put = await dav('PUT', `${folder}/${name}`, body, { 'Content-Type': 'application/pdf' })
|
||||
if (![201, 204].includes(put.status)) throw new Error(`Nextcloud PUT ${name}: HTTP ${put.status} ${await put.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const NEXTCLOUD_CONNECTION = () => ({
|
||||
providerId: 'nextcloud',
|
||||
baseUrl: env('HELP_MEDIA_NEXTCLOUD_URL'),
|
||||
credentials: { login_name: env('HELP_MEDIA_NEXTCLOUD_USER'), app_password: env('HELP_MEDIA_NEXTCLOUD_PASSWORD'), base_path: NEXTCLOUD_BASE_PATH },
|
||||
allowInsecureTls: false,
|
||||
})
|
||||
|
||||
/** Takes every binding and connection off the trip, keeping the documents. */
|
||||
export async function resetDocSync(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
const links = await api.get(`/api/trips/${tripId}/docsync/links`)
|
||||
if (links.ok()) {
|
||||
const body = (await links.json()) as { links?: { id: number }[] } | { id: number }[]
|
||||
for (const link of Array.isArray(body) ? body : (body.links ?? [])) await api.delete(`/api/trips/${tripId}/docsync/links/${link.id}`)
|
||||
}
|
||||
const connections = await api.get(`/api/trips/${tripId}/docsync/connections`)
|
||||
if (connections.ok()) {
|
||||
const body = (await connections.json()) as { connections?: { id: number }[] } | { id: number }[]
|
||||
for (const c of Array.isArray(body) ? body : (body.connections ?? [])) await api.delete(`/api/trips/${tripId}/docsync/connections/${c.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booking extractor ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import from file is rendered only when the server can read a confirmation.
|
||||
* The media run gets that from the extractor named in media.env
|
||||
* (KITINERARY_EXTRACTOR_PATH, see README). The AI Parsing addon would show
|
||||
* the button too, but with no model behind it every parse ends in a red card,
|
||||
* so the guides ask for the extractor and nothing else.
|
||||
*/
|
||||
export async function requireExtractor(api: APIRequestContext): Promise<void> {
|
||||
const res = await api.get('/api/health/features')
|
||||
const body = (await res.json()) as { bookingImport?: boolean }
|
||||
if (!body.bookingImport) {
|
||||
throw new Error('the booking extractor is not available to the media backend: set KITINERARY_EXTRACTOR_PATH in e2e/help/media.env (see README)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The review attaches the confirmation to the booking it made through the
|
||||
* ordinary file upload, and that upload honours the administrator's Allowed
|
||||
* File Types, which do not include a mail out of the box: the booking is
|
||||
* saved and the .eml dropped without a word. So the list gets `eml` for the
|
||||
* import guides and loses it again in their cleanup. Idempotent either way.
|
||||
*/
|
||||
export async function allowEmlUploads(api: APIRequestContext, allowed: boolean): Promise<void> {
|
||||
const current = await api.get('/api/auth/app-settings')
|
||||
const { allowed_file_types = '' } = (await current.json()) as { allowed_file_types?: string }
|
||||
const types = allowed_file_types.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
|
||||
if (types.includes('eml') === allowed) return
|
||||
const next = allowed ? [...types, 'eml'] : types.filter(t => t !== 'eml')
|
||||
const res = await api.put('/api/auth/app-settings', { data: { allowed_file_types: next.join(',') } })
|
||||
if (!res.ok()) throw new Error(`could not ${allowed ? 'allow' : 'disallow'} .eml uploads: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
|
||||
// ── shared ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function toggleAddon(api: APIRequestContext, id: string, enabled: boolean): Promise<void> {
|
||||
const res = await api.put(`/api/admin/addons/${id}`, { data: { enabled } })
|
||||
if (!res.ok()) throw new Error(`could not switch addon ${id} ${enabled ? 'on' : 'off'}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Small API helpers the admin guides use to put the seed back after a guide
|
||||
* changed something (a user it created, a default it set). Kept out of
|
||||
* `fixtures.ts`, which every screen shares.
|
||||
*/
|
||||
|
||||
/** Deletes the account with this username, if there is one. */
|
||||
export async function deleteUser(api: APIRequestContext, username: string): Promise<void> {
|
||||
const res = await api.get('/api/admin/users');
|
||||
if (!res.ok()) throw new Error(`could not list the users: ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as { users?: { id: number; username: string }[] };
|
||||
const user = (body.users ?? []).find((u) => u.username === username);
|
||||
if (!user) return;
|
||||
const del = await api.delete(`/api/admin/users/${user.id}`);
|
||||
if (!del.ok()) throw new Error(`could not delete user "${username}": ${del.status()} ${await del.text()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns these default user settings to TREK's built-in value, the way the
|
||||
* reset link beside a field does (one field per request, as the page sends it).
|
||||
*/
|
||||
export async function resetDefaults(api: APIRequestContext, keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
const res = await api.put('/api/admin/default-user-settings', { data: { [key]: null } });
|
||||
if (!res.ok()) throw new Error(`could not reset the default "${key}": ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { request as apiRequest, type APIRequestContext } from '@playwright/test';
|
||||
import { E2E_BASE_URL } from '../../playwright.config';
|
||||
import { SPARE_TRIP } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the Collections guides need beyond the seed and the shared fixtures:
|
||||
* a trip a kept tour can go into, a second list for the duplicate guide and a
|
||||
* list another user invites the demo account to. Every helper reads first and
|
||||
* only writes what is missing, so a retried guide finds the same state.
|
||||
*/
|
||||
|
||||
type Row = { id: number; title?: string; name?: string };
|
||||
|
||||
const rows = <T>(body: unknown, key: string): T[] =>
|
||||
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
|
||||
|
||||
/** The trip the kept tour is taken into: the Alps trip the Journey guides use as well. */
|
||||
export const TOUR_TARGET_TRIP = SPARE_TRIP.title;
|
||||
|
||||
export async function ensureTourTargetTrip(api: APIRequestContext): Promise<number> {
|
||||
const res = await api.get('/api/trips');
|
||||
const trips = rows<Row>(await res.json(), 'trips');
|
||||
const have = trips.find((t) => t.title === SPARE_TRIP.title);
|
||||
if (have) return have.id;
|
||||
const created = await api.post('/api/trips', { data: SPARE_TRIP });
|
||||
if (!created.ok())
|
||||
throw new Error(`could not create "${SPARE_TRIP.title}": ${created.status()} ${await created.text()}`);
|
||||
const body = (await created.json()) as { trip?: { id: number }; id?: number };
|
||||
const id = body.trip?.id ?? body.id;
|
||||
if (id == null) throw new Error(`"${SPARE_TRIP.title}" came back without an id`);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Lists over the API ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listIdOf(api: APIRequestContext, name: string): Promise<number | null> {
|
||||
const res = await api.get('/api/addons/collections');
|
||||
if (!res.ok()) throw new Error(`GET collections: ${res.status()}`);
|
||||
const lists = rows<Row>(await res.json(), 'collections');
|
||||
return lists.find((l) => l.name === name)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function createList(
|
||||
api: APIRequestContext,
|
||||
name: string,
|
||||
color: string,
|
||||
description?: string
|
||||
): Promise<number> {
|
||||
const res = await api.post('/api/addons/collections', { data: { name, color, description } });
|
||||
if (!res.ok()) throw new Error(`could not create the list "${name}": ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as { id?: number; collection?: { id: number } };
|
||||
const id = body.id ?? body.collection?.id;
|
||||
if (id == null) throw new Error(`the list "${name}" came back without an id`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function deleteList(api: APIRequestContext, name: string): Promise<void> {
|
||||
const id = await listIdOf(api, name);
|
||||
if (id == null) return;
|
||||
const res = await api.delete(`/api/addons/collections/${id}`);
|
||||
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE list "${name}": ${res.status()}`);
|
||||
}
|
||||
|
||||
/** The empty list the duplicate guide copies two temples into; removed again in its cleanup. */
|
||||
export const SECOND_LIST = { name: 'Kyoto temples', color: '#0ea5e9' };
|
||||
|
||||
export async function ensureSecondList(api: APIRequestContext): Promise<void> {
|
||||
if ((await listIdOf(api, SECOND_LIST.name)) == null) await createList(api, SECOND_LIST.name, SECOND_LIST.color);
|
||||
}
|
||||
|
||||
// ── An invite from another user ───────────────────────────────────────────────
|
||||
|
||||
/** The seeded member who shares a list with the demo account. */
|
||||
const INVITER = { email: 'mira@example.com', password: 'DemoSeed12345!' };
|
||||
|
||||
/** Mira's list, with two places, so the accepted invite opens on something real. */
|
||||
export const INVITED_LIST = {
|
||||
name: 'Osaka street food',
|
||||
color: '#ef4444',
|
||||
description: 'Takoyaki, okonomiyaki and the stalls of Kuromon.',
|
||||
places: [
|
||||
{ name: 'Kuromon Market', lat: 34.6655, lng: 135.5068, address: '2 Chome-4-1 Nipponbashi, Chuo Ward, Osaka' },
|
||||
{ name: 'Dotonbori', lat: 34.6687, lng: 135.5013, address: 'Dotonbori, Chuo Ward, Osaka' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A request context acting as the inviter. Its own context with a bearer
|
||||
* token: a login on the shared one would set the session cookie there, and
|
||||
* every later request would be hers.
|
||||
*/
|
||||
async function asInviter(): Promise<APIRequestContext> {
|
||||
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined });
|
||||
const login = await anon.post('/api/auth/login', { data: INVITER });
|
||||
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined;
|
||||
await anon.dispose();
|
||||
if (!token) throw new Error(`could not log in as ${INVITER.email}`);
|
||||
return apiRequest.newContext({
|
||||
baseURL: E2E_BASE_URL,
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mira makes her list afresh and invites the demo account as an editor. A list
|
||||
* left over from an earlier attempt is deleted first, so the invite is always
|
||||
* pending when the guide starts.
|
||||
*/
|
||||
export async function sendInviteToMe(api: APIRequestContext): Promise<void> {
|
||||
const me = await api.get('/api/auth/me');
|
||||
const myId = ((await me.json()) as { user: { id: number } }).user.id;
|
||||
const mira = await asInviter();
|
||||
try {
|
||||
await deleteList(mira, INVITED_LIST.name);
|
||||
const id = await createList(mira, INVITED_LIST.name, INVITED_LIST.color, INVITED_LIST.description);
|
||||
for (const place of INVITED_LIST.places) {
|
||||
const res = await mira.post('/api/addons/collections/places', { data: { collection_id: id, ...place } });
|
||||
if (!res.ok())
|
||||
throw new Error(`could not add "${place.name}" to "${INVITED_LIST.name}": ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const invite = await mira.post('/api/addons/collections/invite', {
|
||||
data: { collection_id: id, user_id: myId, role: 'editor' },
|
||||
});
|
||||
if (!invite.ok()) throw new Error(`could not invite the demo account: ${invite.status()} ${await invite.text()}`);
|
||||
} finally {
|
||||
await mira.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** The inviter deletes her list again, which takes it off the demo account's rail. */
|
||||
export async function withdrawInvitedList(): Promise<void> {
|
||||
const mira = await asInviter();
|
||||
try {
|
||||
await deleteList(mira, INVITED_LIST.name);
|
||||
} finally {
|
||||
await mira.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { at, day } from '../dates';
|
||||
import { EXTRA_TRIPS } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the dashboard guides need beyond the seed and `ensureExtraTrips`.
|
||||
*
|
||||
* The seeded Japan trip ends on the picture day, so every one of its bookings
|
||||
* is behind us and the Upcoming reservations widget would read "Nothing booked
|
||||
* yet." The Lisbon weekend, 25 days out, gets three bookings of its own: a
|
||||
* flight, a table that is not confirmed yet (so the Pending tag shows) and a
|
||||
* day trip. Trip reminders are switched on, which is the instance default, so
|
||||
* the reminder guide finds the presets and not the "disabled" hint.
|
||||
*/
|
||||
|
||||
/** The flight, the hotel and the Sintra train come from the showcase (showcase.ts); the guides add a dinner. */
|
||||
export const LISBON_FLIGHT = 'TP579 FRA → LIS';
|
||||
export const LISBON_BOOKINGS = [
|
||||
{
|
||||
title: 'Dinner at Cervejaria Ramiro',
|
||||
type: 'restaurant',
|
||||
status: 'pending',
|
||||
location: 'Av. Almirante Reis 1, Lisbon',
|
||||
offset: 25,
|
||||
time: '20:30',
|
||||
confirmation_number: '',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const listIn = <T>(body: unknown, key: string): T[] =>
|
||||
Array.isArray(body) ? (body as T[]) : (((body as Record<string, unknown>)?.[key] as T[] | undefined) ?? []);
|
||||
|
||||
async function json(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<unknown> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Id of a trip by its title; throws when it is missing. */
|
||||
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
|
||||
const trip = listIn<{ id: number; title: string }>(
|
||||
await json(await api.get('/api/trips'), 'list trips'),
|
||||
'trips'
|
||||
).find((t) => t.title === title);
|
||||
if (!trip) throw new Error(`fixture trip "${title}" is missing`);
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
export async function ensureDashboardFixtures(api: APIRequestContext): Promise<void> {
|
||||
const settings = await api.put('/api/auth/app-settings', { data: { notify_trip_reminder: 'true' } });
|
||||
if (!settings.ok())
|
||||
throw new Error(`could not switch trip reminders on: ${settings.status()} ${await settings.text()}`);
|
||||
|
||||
const tripId = await tripIdByTitle(api, EXTRA_TRIPS[0].title);
|
||||
const reservations = listIn<{ title: string }>(
|
||||
await json(await api.get(`/api/trips/${tripId}/reservations`), 'list reservations'),
|
||||
'reservations'
|
||||
);
|
||||
const days = listIn<{ id: number; date?: string | null }>(
|
||||
await json(await api.get(`/api/trips/${tripId}/days`), 'list days'),
|
||||
'days'
|
||||
);
|
||||
for (const { offset, time, ...booking } of LISBON_BOOKINGS) {
|
||||
if (reservations.some((r) => r.title === booking.title)) continue;
|
||||
const dayId = days.find((d) => d.date === day(offset))?.id;
|
||||
const created = await api.post(`/api/trips/${tripId}/reservations`, {
|
||||
data: {
|
||||
...booking,
|
||||
confirmation_number: booking.confirmation_number || undefined,
|
||||
day_id: dayId,
|
||||
reservation_time: at(offset, time),
|
||||
},
|
||||
});
|
||||
if (!created.ok())
|
||||
throw new Error(`could not create "${booking.title}": ${created.status()} ${await created.text()}`);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
||||
import { day } from '../dates';
|
||||
import { disconnectDawarich, ensureDawarichConnection } from './external';
|
||||
|
||||
/**
|
||||
* What the journal guides about a recorded day need beyond the seed: the
|
||||
* Dawarich from `media.env` connected, a handful of stays on one day of the
|
||||
* trip, and those stays synced into TREK, so the day of the journal carries
|
||||
* its stay row, its route button and its take-over button.
|
||||
*
|
||||
* The recording itself is `dawarich-track.ts`, uploaded by
|
||||
* `ensureDawarichConnection`. Dawarich detects stays from points only in a
|
||||
* background job of its own, so the stays are put there here, as visits, on
|
||||
* the Arashiyama day: the times and places are the ones the recording dwells
|
||||
* at, so the stays sit on the route.
|
||||
*
|
||||
* Everything a guide leaves is taken back in `releaseJournalDawarich`: the
|
||||
* stays still waiting are dismissed (the trip planner lists waiting stays at
|
||||
* the head of its Places column), a kept route is removed, the automatic
|
||||
* preparation is switched off, entries the guide wrote are deleted and the
|
||||
* addon goes off again. The next guide's `connectJournalDawarich` brings the
|
||||
* dismissed stays back.
|
||||
*/
|
||||
|
||||
/** The day the stays are on: the trip's sixth day, Arashiyama and Nishiki. */
|
||||
export const STAY_DAY = day(-4);
|
||||
|
||||
const JST = '+09:00';
|
||||
const STAYS = [
|
||||
{ name: 'Togetsukyo Bridge', lat: 35.013, lng: 135.6776, from: '09:30', to: '10:00' },
|
||||
{ name: 'Arashiyama Bamboo Grove', lat: 35.017, lng: 135.6716, from: '10:10', to: '11:10' },
|
||||
{ name: 'Nishiki Market', lat: 35.005, lng: 135.7649, from: '12:30', to: '14:00' },
|
||||
{ name: 'Gion', lat: 35.0037, lng: 135.7752, from: '14:20', to: '15:20' },
|
||||
];
|
||||
|
||||
function env(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value)
|
||||
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function ok<T>(res: APIResponse, what: string): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
const instant = (time: string) => new Date(`${STAY_DAY}T${time}:00${JST}`).toISOString();
|
||||
|
||||
/** The stays on the day as visits in Dawarich; those already there by name are left alone. */
|
||||
async function ensureDawarichStays(): Promise<void> {
|
||||
const base = env('HELP_MEDIA_DAWARICH_URL');
|
||||
const key = env('HELP_MEDIA_DAWARICH_API_KEY');
|
||||
const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
|
||||
const from = instant('00:00');
|
||||
const to = instant('23:59');
|
||||
const listed = await fetch(`${base}/api/v1/visits?start_at=${from}&end_at=${to}`, { headers });
|
||||
if (!listed.ok) throw new Error(`Dawarich visits ${STAY_DAY}: HTTP ${listed.status} ${await listed.text()}`);
|
||||
const body = (await listed.json()) as { name?: string }[] | { visits?: { name?: string }[] };
|
||||
const have = new Set((Array.isArray(body) ? body : (body.visits ?? [])).map((v) => v.name));
|
||||
for (const stay of STAYS) {
|
||||
if (have.has(stay.name)) continue;
|
||||
const created = await fetch(`${base}/api/v1/visits`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
visit: {
|
||||
name: stay.name,
|
||||
latitude: stay.lat,
|
||||
longitude: stay.lng,
|
||||
started_at: instant(stay.from),
|
||||
ended_at: instant(stay.to),
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!created.ok) throw new Error(`Dawarich visit "${stay.name}": HTTP ${created.status} ${await created.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
id: number;
|
||||
state: string;
|
||||
}
|
||||
|
||||
async function suggestions(api: APIRequestContext, state: 'new' | 'dismissed'): Promise<Suggestion[]> {
|
||||
const body = await ok<{ suggestions: Suggestion[] }>(
|
||||
await api.get(`/api/integrations/dawarich/suggestions?state=${state}`),
|
||||
`list ${state} stays`
|
||||
);
|
||||
return body.suggestions;
|
||||
}
|
||||
|
||||
async function setState(api: APIRequestContext, list: Suggestion[], state: 'new' | 'dismissed'): Promise<void> {
|
||||
for (const s of list) {
|
||||
await ok(
|
||||
await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state } }),
|
||||
`set stay ${s.id} ${state}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The ids of the journal's written entries, for `releaseJournalDawarich` to keep. */
|
||||
export async function writtenEntryIds(api: APIRequestContext, journeyId: number): Promise<Set<number>> {
|
||||
const { entries } = await ok<{ entries: { id: number; type: string }[] }>(
|
||||
await api.get(`/api/journeys/${journeyId}/entries`),
|
||||
'list entries'
|
||||
);
|
||||
return new Set(entries.filter((e) => e.type !== 'skeleton').map((e) => e.id));
|
||||
}
|
||||
|
||||
/** The addon on, the user connected, the stays in Dawarich and synced into TREK, all waiting again. */
|
||||
export async function connectJournalDawarich(api: APIRequestContext, journeyId: number): Promise<void> {
|
||||
await ensureDawarichConnection(api);
|
||||
await ensureDawarichStays();
|
||||
await ok(await api.post('/api/integrations/dawarich/sync'), 'sync Dawarich');
|
||||
await setState(api, await suggestions(api, 'dismissed'), 'new');
|
||||
await ok(
|
||||
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
|
||||
'switch the preparation off'
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything a recorded-day guide changed, put back; `keep` are the entries that were there before it. */
|
||||
export async function releaseJournalDawarich(
|
||||
api: APIRequestContext,
|
||||
journeyId: number,
|
||||
keep: Set<number>
|
||||
): Promise<void> {
|
||||
await setState(api, await suggestions(api, 'new'), 'dismissed');
|
||||
const { days } = await ok<{ days: { date: string }[] }>(
|
||||
await api.get(`/api/journeys/${journeyId}/recordings`),
|
||||
'list kept days'
|
||||
);
|
||||
for (const kept of days) {
|
||||
await ok(
|
||||
await api.delete(`/api/journeys/${journeyId}/recordings/${kept.date}`),
|
||||
`remove the route of ${kept.date}`
|
||||
);
|
||||
}
|
||||
await ok(
|
||||
await api.patch(`/api/journeys/${journeyId}`, { data: { auto_prepare_recordings: false } }),
|
||||
'switch the preparation off'
|
||||
);
|
||||
for (const id of await writtenEntryIds(api, journeyId)) {
|
||||
if (!keep.has(id)) await ok(await api.delete(`/api/journeys/entries/${id}`), `delete entry ${id}`);
|
||||
}
|
||||
await disconnectDawarich(api);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { seededTrip } from './fixtures';
|
||||
import { asMember, PARTNER } from './fixtures-vacay';
|
||||
|
||||
/**
|
||||
* The in-app notifications the notification guides read, mark and delete.
|
||||
*
|
||||
* The seed already leaves the admin a few "New Message" notifications, one per
|
||||
* chat line mira and jonas posted in the Japan trip. On top of those the
|
||||
* members do two more things a trip's people really do, through their own
|
||||
* bearer tokens: jonas saves a booking of the Japan trip unchanged (the
|
||||
* "Booking Updated" notification, with View Trip) and mira shares her Vacay
|
||||
* calendar with the admin (the "Vacay Calendar Shared" one, with View Vacay).
|
||||
* Neither changes anything another picture shows; removeNotificationFixtures
|
||||
* takes the share and both notifications back.
|
||||
*
|
||||
* Every helper is idempotent: a guide that deleted one gets it again at its start.
|
||||
*/
|
||||
|
||||
const INBOX = '/api/notifications/in-app';
|
||||
const VACAY = '/api/addons/vacay';
|
||||
|
||||
/** The member who changes the booking. Not mira: two notifications from one sender read like one. */
|
||||
export const BOOKER = { username: 'jonas', email: 'jonas@example.com', password: PARTNER.password };
|
||||
/** A booking the seed puts on the Japan trip; jonas saves it as it is. */
|
||||
const BOOKING = 'LH716 FRA → HND';
|
||||
|
||||
/** The titles as the list renders them (`notif.*.title` in en). */
|
||||
export const BOOKING_TITLE = 'Booking Updated';
|
||||
export const SHARE_TITLE = 'Vacay Calendar Shared';
|
||||
export const MESSAGE_TITLE = 'New Message';
|
||||
|
||||
const BOOKING_KEY = 'notif.booking_change.title';
|
||||
const SHARE_KEY = 'notif.vacay_share.title';
|
||||
|
||||
interface InboxRow {
|
||||
id: number;
|
||||
title_key: string;
|
||||
sender_username: string | null;
|
||||
is_read: boolean | number;
|
||||
}
|
||||
|
||||
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** The admin's newest notifications, as many as the server hands out at once. */
|
||||
export async function inbox(api: APIRequestContext): Promise<InboxRow[]> {
|
||||
const res = await api.get(`${INBOX}?limit=50`);
|
||||
await ok(res, 'could not load the notifications');
|
||||
return ((await res.json()) as { notifications: InboxRow[] }).notifications;
|
||||
}
|
||||
|
||||
const find = (rows: InboxRow[], key: string, sender: string): InboxRow | undefined =>
|
||||
rows.find((n) => n.title_key === key && n.sender_username === sender);
|
||||
|
||||
async function adminId(api: APIRequestContext): Promise<number> {
|
||||
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
|
||||
}
|
||||
|
||||
/** jonas saves the seeded flight unchanged; the server tells every other member. */
|
||||
async function bookingChangeFromJonas(): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const jonas = await asMember(BOOKER);
|
||||
try {
|
||||
const list = await jonas.get(`/api/trips/${tripId}/reservations`);
|
||||
await ok(list, 'jonas could not load the bookings');
|
||||
const { reservations } = (await list.json()) as { reservations: { id: number; title: string }[] };
|
||||
const booking = reservations.find((r) => r.title === BOOKING) ?? reservations[0];
|
||||
if (!booking) throw new Error('the seeded trip has no booking for jonas to save');
|
||||
await ok(
|
||||
await jonas.put(`/api/trips/${tripId}/reservations/${booking.id}`, { data: { title: booking.title } }),
|
||||
'jonas could not save the booking'
|
||||
);
|
||||
} finally {
|
||||
await jonas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** mira shares her Vacay calendar with the admin, as she would from her own Vacay. */
|
||||
async function shareFromMira(api: APIRequestContext): Promise<void> {
|
||||
const target = await adminId(api);
|
||||
const mira = await asMember(PARTNER);
|
||||
try {
|
||||
const res = await mira.post(`${VACAY}/shares`, { data: { user_id: target } });
|
||||
// Already shared: the notification was deleted, the share is still there.
|
||||
// Share again from scratch so the server sends a fresh one.
|
||||
if (!res.ok() && (await res.text()).includes('Already shared')) {
|
||||
const shares = (await (await mira.get(`${VACAY}/shares`)).json()) as {
|
||||
outgoing?: { id: number; user_id: number }[];
|
||||
};
|
||||
for (const s of (shares.outgoing ?? []).filter((x) => x.user_id === target))
|
||||
await mira.delete(`${VACAY}/shares/${s.id}`);
|
||||
await ok(
|
||||
await mira.post(`${VACAY}/shares`, { data: { user_id: target } }),
|
||||
'mira could not share her calendar again'
|
||||
);
|
||||
} else {
|
||||
await ok(res, 'mira could not share her calendar');
|
||||
}
|
||||
} finally {
|
||||
await mira.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until the fire-and-forget send has written the row. */
|
||||
async function waitFor(api: APIRequestContext, key: string, sender: string): Promise<InboxRow> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const row = find(await inbox(api), key, sender);
|
||||
if (row) return row;
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
}
|
||||
throw new Error(`no "${key}" notification from ${sender} arrived`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two notifications from the members, both unread, the share newest. A
|
||||
* guide before may have read or deleted one; this brings it back as it was.
|
||||
*/
|
||||
export async function ensureNotificationFixtures(api: APIRequestContext): Promise<void> {
|
||||
let rows = await inbox(api);
|
||||
if (!find(rows, BOOKING_KEY, BOOKER.username)) await bookingChangeFromJonas();
|
||||
await waitFor(api, BOOKING_KEY, BOOKER.username);
|
||||
rows = await inbox(api);
|
||||
if (!find(rows, SHARE_KEY, PARTNER.username)) await shareFromMira(api);
|
||||
await waitFor(api, SHARE_KEY, PARTNER.username);
|
||||
for (const n of await inbox(api)) {
|
||||
const ours =
|
||||
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
|
||||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
|
||||
if (ours && !n.is_read) continue;
|
||||
if (ours) await ok(await api.put(`${INBOX}/${n.id}/unread`), 'could not mark a notification unread');
|
||||
}
|
||||
}
|
||||
|
||||
/** The ids of every unread notification, to give back after a guide that reads them all. */
|
||||
export async function unreadIds(api: APIRequestContext): Promise<number[]> {
|
||||
return (await inbox(api)).filter((n) => !n.is_read).map((n) => n.id);
|
||||
}
|
||||
|
||||
/** Mark these unread again; one that is gone by now is skipped. */
|
||||
export async function markUnread(api: APIRequestContext, ids: number[]): Promise<void> {
|
||||
for (const id of ids) await api.put(`${INBOX}/${id}/unread`);
|
||||
}
|
||||
|
||||
/** Back to the seed: mira's share and both notifications from the members go. */
|
||||
export async function removeNotificationFixtures(api: APIRequestContext): Promise<void> {
|
||||
const shares = (await (await api.get(`${VACAY}/shares`)).json()) as { incoming: { id: number; username: string }[] };
|
||||
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
|
||||
await ok(await api.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
|
||||
}
|
||||
for (const n of await inbox(api)) {
|
||||
const ours =
|
||||
(n.title_key === BOOKING_KEY && n.sender_username === BOOKER.username) ||
|
||||
(n.title_key === SHARE_KEY && n.sender_username === PARTNER.username);
|
||||
if (ours) await api.delete(`${INBOX}/${n.id}`);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { toggleAddon } from './external';
|
||||
import { pluginPackage, seededTrip } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the settings guides need beyond the seed: a wanderer to connect to, a
|
||||
* plugin so the Plugins tab exists, and two parked offline changes so the
|
||||
* Sync conflicts card and the failed changes have something to show.
|
||||
*
|
||||
* The wanderer guides follow the pattern of `external.ts`: the address and the
|
||||
* token come from `e2e/help/media.env`, and without them those guides fail at
|
||||
* their start while every other guide runs as before.
|
||||
*
|
||||
* # wanderer (Settings, Integrations: wanderer). A token from wanderer under
|
||||
* # Settings, Account, API tokens; it starts with wanderer_key_.
|
||||
* HELP_MEDIA_WANDERER_URL=http://192.168.1.10:3030
|
||||
* HELP_MEDIA_WANDERER_TOKEN=wanderer_key_...
|
||||
*
|
||||
* TREK's wanderer connection takes a URL and an API token, nothing else (see
|
||||
* `wandererSettingsRequestSchema`), so no user name or password is needed. As
|
||||
* with the other services, the address has to be the machine's LAN address and
|
||||
* the media backend needs ALLOW_INTERNAL_NETWORK=true.
|
||||
*/
|
||||
|
||||
export function mediaEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value)
|
||||
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// ── wanderer ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const wandererUrl = (): string => mediaEnv('HELP_MEDIA_WANDERER_URL');
|
||||
export const wandererToken = (): string => mediaEnv('HELP_MEDIA_WANDERER_TOKEN');
|
||||
|
||||
/** The admin's wanderer switch under Tours, as it was before the guide turned it on. */
|
||||
let wandererWasOn: boolean | null = null;
|
||||
|
||||
/**
|
||||
* The Tours addon on and wanderer allowed under it, so the wanderer card shows
|
||||
* in Integrations, with no connection yet. Remembers the admin switch, so
|
||||
* `disconnectWanderer` puts it back the way the seed had it.
|
||||
*/
|
||||
export async function allowWanderer(api: APIRequestContext): Promise<void> {
|
||||
// Both variables first: a missing one should fail here, not halfway into a picture.
|
||||
wandererUrl();
|
||||
wandererToken();
|
||||
await toggleAddon(api, 'tours', true);
|
||||
if (wandererWasOn === null) {
|
||||
const current = await api.get('/api/admin/tours-wanderer');
|
||||
wandererWasOn = current.ok() ? !!((await current.json()) as { enabled?: boolean }).enabled : false;
|
||||
}
|
||||
const res = await api.put('/api/admin/tours-wanderer', { data: { enabled: true } });
|
||||
if (!res.ok()) throw new Error(`could not allow wanderer under Tours: ${res.status()} ${await res.text()}`);
|
||||
await api.delete('/api/integrations/wanderer/settings');
|
||||
}
|
||||
|
||||
/** Connected through the API and tested, loudly, for the guides that start from a connection. */
|
||||
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
|
||||
await allowWanderer(api);
|
||||
const saved = await api.put('/api/integrations/wanderer/settings', {
|
||||
data: { url: wandererUrl(), token: wandererToken(), allowInsecureTls: false },
|
||||
});
|
||||
if (!saved.ok()) throw new Error(`could not save the wanderer connection: ${saved.status()} ${await saved.text()}`);
|
||||
const test = await api.post('/api/integrations/wanderer/test', { data: {} });
|
||||
const body = (await test.json()) as { ok?: boolean; code?: string };
|
||||
if (!body.ok)
|
||||
throw new Error(
|
||||
`wanderer is not reachable from the media backend: ${body.code ?? test.status()} (is ALLOW_INTERNAL_NETWORK=true?)`
|
||||
);
|
||||
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
|
||||
}
|
||||
|
||||
/** The watch off, the connection gone, and the admin switch back where it was. */
|
||||
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
|
||||
await api.put('/api/integrations/wanderer/watch', { data: { enabled: false } });
|
||||
await api.delete('/api/integrations/wanderer/settings');
|
||||
if (wandererWasOn === false) await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
|
||||
wandererWasOn = null;
|
||||
}
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Plugins tab only exists while a plugin is active. The seed installs two
|
||||
* from the registry, which needs the network, and the admin file uninstalls
|
||||
* Trip Doctor in its cleanup; so when no plugin is active any more, the SDK's
|
||||
* Trip Doctor example is sideloaded and switched on. Answers whether it did,
|
||||
* so the caller can take it away again.
|
||||
*/
|
||||
export async function ensureActivePlugin(api: APIRequestContext): Promise<boolean> {
|
||||
const feed = await api.get('/api/plugins');
|
||||
const { plugins = [] } = (await feed.json()) as { plugins?: { id: string }[] };
|
||||
if (plugins.length > 0) return false;
|
||||
const upload = await api.post('/api/admin/plugins/upload', {
|
||||
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
|
||||
});
|
||||
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`);
|
||||
const active = await api.post('/api/admin/plugins/trip-doctor/activate', { data: { consent: true } });
|
||||
if (!active.ok()) throw new Error(`could not activate the plugin: ${active.status()} ${await active.text()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeSideloadedPlugin(api: APIRequestContext): Promise<void> {
|
||||
await api.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } });
|
||||
}
|
||||
|
||||
// ── Offline: parked changes ─────────────────────────────────────────────────
|
||||
|
||||
/** The two rows this file parks in the offline queue; their ids make them easy to take out again. */
|
||||
const CONFLICT_ID = 'help-media-conflict-nishiki';
|
||||
const FAILED_ID = 'help-media-failed-todaiji';
|
||||
export const CONFLICT_PLACE = 'Nishiki Market';
|
||||
export const FAILED_PLACE = 'Todai-ji';
|
||||
|
||||
async function placeId(api: APIRequestContext, tripId: number, name: string): Promise<number> {
|
||||
const res = await api.get(`/api/trips/${tripId}/places`);
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[];
|
||||
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.name === name);
|
||||
if (!place) throw new Error(`the seeded trip has no place "${name}"`);
|
||||
return place.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change that clashes with the server and one the server keeps refusing,
|
||||
* written straight into this browser's offline queue (Dexie table
|
||||
* `mutationQueue` of `trek-offline-u<userId>`), the way the mutation queue
|
||||
* parks them. Neither is ever replayed on its own: a parked row waits for
|
||||
* Keep mine, Keep theirs, Try again or Discard, none of which the guide
|
||||
* clicks. Needs the app loaded once, so the database exists.
|
||||
*/
|
||||
export async function parkOfflineChanges(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
|
||||
const conflictPlace = await placeId(page.request, tripId, CONFLICT_PLACE);
|
||||
const failedPlace = await placeId(page.request, tripId, FAILED_PLACE);
|
||||
const now = Date.now();
|
||||
const rows = [
|
||||
{
|
||||
id: CONFLICT_ID,
|
||||
tripId,
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${conflictPlace}`,
|
||||
body: { name: CONFLICT_PLACE, notes: 'Go before 10, the stalls open early.' },
|
||||
createdAt: now - 60_000,
|
||||
status: 'conflict',
|
||||
attempts: 1,
|
||||
lastError: 'conflict',
|
||||
resource: 'places',
|
||||
entityId: conflictPlace,
|
||||
baseUpdatedAt: null,
|
||||
conflictServer: { name: CONFLICT_PLACE },
|
||||
conflictAt: now - 30_000,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
{
|
||||
id: FAILED_ID,
|
||||
tripId,
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${failedPlace}`,
|
||||
body: { name: FAILED_PLACE, notes: 'Deer crackers at the gate.' },
|
||||
createdAt: now - 50_000,
|
||||
status: 'failed',
|
||||
attempts: 5,
|
||||
lastError: 'HTTP 500',
|
||||
resource: 'places',
|
||||
entityId: failedPlace,
|
||||
baseUpdatedAt: null,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
];
|
||||
await page.evaluate(
|
||||
async ({ name, rows }) => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const open = indexedDB.open(name);
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
});
|
||||
try {
|
||||
if (!db.objectStoreNames.contains('mutationQueue'))
|
||||
throw new Error(`${name} has no mutationQueue; was the app loaded?`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction('mutationQueue', 'readwrite');
|
||||
for (const row of rows) tx.objectStore('mutationQueue').put(row);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
{ name: `trek-offline-u${me.user.id}`, rows }
|
||||
);
|
||||
}
|
||||
|
||||
/** Takes the two parked rows out again, whatever the guide did with them. */
|
||||
export async function clearParkedChanges(page: Page): Promise<void> {
|
||||
const me = (await (await page.request.get('/api/auth/me')).json()) as { user: { id: number } };
|
||||
await page.evaluate(
|
||||
async ({ name, ids }) => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const open = indexedDB.open(name);
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
});
|
||||
try {
|
||||
if (!db.objectStoreNames.contains('mutationQueue')) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction('mutationQueue', 'readwrite');
|
||||
for (const id of ids) tx.objectStore('mutationQueue').delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
{ name: `trek-offline-u${me.user.id}`, ids: [CONFLICT_ID, FAILED_ID] }
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { seededTrip } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the two note guides of the Collab tab need beyond `ensureCollabFixtures`.
|
||||
* Kept apart from the shared fixtures because nothing outside
|
||||
* `trip-collab.guide.ts` reads it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The note `manage-notes` opens, pins, edits and finally deletes. It is made
|
||||
* fresh at the start of that guide rather than in the seed, so no other picture
|
||||
* of the tab (the hero among them) carries a note that is about to go.
|
||||
*/
|
||||
export const MANAGE_NOTE = {
|
||||
title: 'Onsen etiquette',
|
||||
category: 'Accommodation',
|
||||
color: '#ef4444',
|
||||
content:
|
||||
'Wash at the stools **before** you get into the bath.\n\n- The small towel stays out of the water\n- No swimwear\n- Ask at the front desk about tattoos',
|
||||
website: 'https://www.japan.travel/en/guide/onsen/',
|
||||
};
|
||||
|
||||
/** The line the edit step adds to it. */
|
||||
export const MANAGE_NOTE_EDIT = '- Drink water afterwards, the baths run hot';
|
||||
|
||||
/**
|
||||
* The category and colour every seeded note had before `note-categories` renames
|
||||
* and recolours them (`e2e/screenshots/seed.ts`, step 11b), by title.
|
||||
*/
|
||||
export const SEEDED_NOTE_CATEGORIES: Record<string, { category: string; color: string }> = {
|
||||
'Rail passes': { category: 'Transport', color: '#3b82f6' },
|
||||
'Ryokan etiquette': { category: 'Accommodation', color: '#ef4444' },
|
||||
'Rainy-day alternatives': { category: 'Ideas', color: '#22c55e' },
|
||||
};
|
||||
|
||||
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`;
|
||||
|
||||
async function notesOf(
|
||||
api: APIRequestContext
|
||||
): Promise<{ id: number; title: string; category: string | null; color: string | null }[]> {
|
||||
const res = await api.get(`${collab()}/notes`);
|
||||
const body = (await res.json()) as {
|
||||
notes?: { id: number; title: string; category: string | null; color: string | null }[];
|
||||
};
|
||||
return body.notes ?? [];
|
||||
}
|
||||
|
||||
/** Removes every note of that title. */
|
||||
export async function removeNotes(api: APIRequestContext, title: string): Promise<void> {
|
||||
for (const note of (await notesOf(api)).filter((n) => n.title === title)) {
|
||||
await api.delete(`${collab()}/notes/${note.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One open, unpinned MANAGE_NOTE; a copy left by a run that stopped short is thrown away first. */
|
||||
export async function ensureManageNote(api: APIRequestContext): Promise<void> {
|
||||
await removeNotes(api, MANAGE_NOTE.title);
|
||||
const created = await api.post(`${collab()}/notes`, { data: MANAGE_NOTE });
|
||||
if (!created.ok())
|
||||
throw new Error(`could not write the note "${MANAGE_NOTE.title}": ${created.status()} ${await created.text()}`);
|
||||
}
|
||||
|
||||
/** Puts every seeded note back into its seeded category and colour. Safe to run when nothing changed. */
|
||||
export async function restoreNoteCategories(api: APIRequestContext): Promise<void> {
|
||||
for (const note of await notesOf(api)) {
|
||||
const seeded = SEEDED_NOTE_CATEGORIES[note.title];
|
||||
if (!seeded || (note.category === seeded.category && note.color === seeded.color)) continue;
|
||||
const res = await api.put(`${collab()}/notes/${note.id}`, { data: seeded });
|
||||
if (!res.ok()) throw new Error(`could not restore the note "${note.title}": ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { day } from '../dates';
|
||||
import { seededTrip } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the receipts guide of the Costs tab needs beyond the shared fixtures: an
|
||||
* expense of its own to attach a receipt to, and the receipt itself. Nothing in
|
||||
* the repository may carry a real person's paperwork, so the till slip is drawn
|
||||
* here, the way `fixtures.ts` draws its documents: a white strip of thermal
|
||||
* paper with the shop, the lines and the total, which is what a phone photo of
|
||||
* a receipt cropped to the paper looks like.
|
||||
*/
|
||||
|
||||
/** The expense the receipt belongs to; its total is the slip's total. */
|
||||
export const RAMEN = {
|
||||
name: 'Ichiran ramen in Shibuya',
|
||||
category: 'food',
|
||||
total_price: 4450,
|
||||
currency: 'JPY',
|
||||
expense_date: day(-6),
|
||||
};
|
||||
|
||||
/** The file the guide attaches, by the name the editor and the preview show. */
|
||||
export const RECEIPT_FILE = 'ichiran-receipt.png';
|
||||
|
||||
const LINES: [string, string, string][] = [
|
||||
['Ramen, classic', '3', '2,940'],
|
||||
['Kaedama (extra noodles)', '2', '420'],
|
||||
['Matcha almond tofu', '2', '700'],
|
||||
['Soft-boiled egg', '3', '390'],
|
||||
];
|
||||
|
||||
const tmpDir = (): string => {
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
const esc = (v: string) => v.replace(/&/g, '&').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`);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_FOLDER, resetDocSync } from './external';
|
||||
import { drawPdf } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the binding-settings guide needs beyond the Files fixtures: the trip
|
||||
* already bound to the Nextcloud folder, so the guide opens on the card and
|
||||
* not on the connection form that `files-sync` walks through. It takes the same
|
||||
* three calls the dialog makes (save the connection, list the folders, bind
|
||||
* one), starting from no binding at all, so it can run any number of times.
|
||||
* The store's address and app password come from media.env (external.ts).
|
||||
*/
|
||||
|
||||
interface Scope {
|
||||
scopeKey: string;
|
||||
label: string;
|
||||
remoteRootId: string | null;
|
||||
remoteRootPath: string | null;
|
||||
}
|
||||
|
||||
async function ok<T>(
|
||||
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
|
||||
what: string
|
||||
): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: HTTP ${res.status()} ${await res.text()}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** Binds the trip to the fixture folder in Nextcloud with the dialog's defaults; returns the link's id. */
|
||||
export async function ensureNextcloudBinding(api: APIRequestContext, tripId: number): Promise<number> {
|
||||
await resetDocSync(api, tripId);
|
||||
await ensureNextcloudFolder(drawPdf);
|
||||
const base = `/api/trips/${tripId}/docsync`;
|
||||
const connection = await ok<{ id: number }>(
|
||||
await api.put(`${base}/connections`, { data: NEXTCLOUD_CONNECTION() }),
|
||||
'save the Nextcloud connection'
|
||||
);
|
||||
const { scopes = [] } = await ok<{ scopes?: Scope[] }>(
|
||||
await api.get(`${base}/connections/${connection.id}/scopes`),
|
||||
'list the Nextcloud folders'
|
||||
);
|
||||
// The picker finds the folder by its text the same way (folderRow in the guide).
|
||||
const scope =
|
||||
scopes.find((s) => s.label === NEXTCLOUD_FOLDER) ?? scopes.find((s) => s.label.includes(NEXTCLOUD_FOLDER));
|
||||
if (!scope)
|
||||
throw new Error(`no folder "${NEXTCLOUD_FOLDER}" in the store (found: ${scopes.map((s) => s.label).join(', ')})`);
|
||||
const link = await ok<{ id: number }>(
|
||||
await api.post(`${base}/links`, {
|
||||
data: {
|
||||
connectionId: connection.id,
|
||||
scopeKey: scope.scopeKey,
|
||||
remoteRootId: scope.remoteRootId,
|
||||
remoteRootPath: scope.remoteRootPath,
|
||||
remoteLabel: scope.label,
|
||||
direction: 'both',
|
||||
deletePolicy: 'unlink',
|
||||
conflictPolicy: 'manual',
|
||||
syncEnabled: true,
|
||||
},
|
||||
}),
|
||||
'bind the trip to the folder'
|
||||
);
|
||||
return link.id;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { seededTrip } from './fixtures';
|
||||
|
||||
/**
|
||||
* Putting the packing list back the way the seed left it.
|
||||
*
|
||||
* The seed's list is long (six lists, most of it ticked) and several guides
|
||||
* change it in ways that are hard to undo one by one: a list dragged to the
|
||||
* front, pieces counted in, a weight typed, items imported or brought in by a
|
||||
* template. So the trip-lists script takes one snapshot of the list before the
|
||||
* first guide and every packing guide puts that snapshot back, before it starts
|
||||
* and again when it is done. Nothing here hard-codes the seed's names, so a
|
||||
* richer seed needs no change in this file.
|
||||
*/
|
||||
|
||||
export interface PackingSnapshotRow {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string | null;
|
||||
checked: number;
|
||||
quantity: number;
|
||||
weight_grams: number | null;
|
||||
packed_quantity: number | null;
|
||||
bag_id: number | null;
|
||||
is_private: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
type RawRow = Partial<PackingSnapshotRow> & { id: number; name: string };
|
||||
|
||||
function normalise(row: RawRow): PackingSnapshotRow {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category ?? null,
|
||||
checked: row.checked ? 1 : 0,
|
||||
quantity: row.quantity || 1,
|
||||
weight_grams: row.weight_grams ?? null,
|
||||
packed_quantity: row.packed_quantity ?? null,
|
||||
bag_id: row.bag_id ?? null,
|
||||
is_private: row.is_private ? 1 : 0,
|
||||
sort_order: row.sort_order ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function packingRows(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/packing`);
|
||||
if (!res.ok()) throw new Error(`could not read the packing list: ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as { items?: RawRow[] };
|
||||
return (body.items ?? []).map(normalise).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
|
||||
}
|
||||
|
||||
/** The packing list as it is now, in its order. */
|
||||
export async function snapshotPacking(api: APIRequestContext): Promise<PackingSnapshotRow[]> {
|
||||
return packingRows(api);
|
||||
}
|
||||
|
||||
const differs = (a: PackingSnapshotRow, b: PackingSnapshotRow): boolean =>
|
||||
a.name !== b.name ||
|
||||
a.category !== b.category ||
|
||||
a.checked !== b.checked ||
|
||||
a.quantity !== b.quantity ||
|
||||
a.weight_grams !== b.weight_grams ||
|
||||
a.packed_quantity !== b.packed_quantity ||
|
||||
a.bag_id !== b.bag_id;
|
||||
|
||||
/**
|
||||
* Make the trip's packing list equal to the snapshot again: what a guide added
|
||||
* goes, what it deleted comes back, ticks, quantities, counts and weights are
|
||||
* written back, and the order is restored. Cheap when nothing changed: one read.
|
||||
*/
|
||||
export async function restorePacking(api: APIRequestContext, snapshot: PackingSnapshotRow[]): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const current = await packingRows(api);
|
||||
const wanted = new Set(snapshot.map((row) => row.id));
|
||||
|
||||
for (const row of current.filter((r) => !wanted.has(r.id))) {
|
||||
await api.delete(`/api/trips/${tripId}/packing/${row.id}`);
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const row of snapshot) {
|
||||
const now = current.find((r) => r.id === row.id);
|
||||
if (now) {
|
||||
ids.push(row.id);
|
||||
if (!differs(now, row)) continue;
|
||||
// Naming `checked` resets the piece counter on the server, which is what a
|
||||
// seeded row has: none. A partial count is written after it when there was one.
|
||||
const data: Record<string, unknown> = {
|
||||
name: row.name,
|
||||
checked: row.checked,
|
||||
quantity: row.quantity,
|
||||
weight_grams: row.weight_grams,
|
||||
};
|
||||
if (row.category) data.category = row.category;
|
||||
if (now.bag_id !== row.bag_id) data.bag_id = row.bag_id;
|
||||
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data });
|
||||
if (!res.ok()) throw new Error(`could not restore "${row.name}": ${res.status()} ${await res.text()}`);
|
||||
if (row.packed_quantity != null) {
|
||||
await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { packed_quantity: row.packed_quantity } });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const created = await api.post(`/api/trips/${tripId}/packing`, {
|
||||
data: {
|
||||
name: row.name,
|
||||
...(row.category ? { category: row.category } : {}),
|
||||
visibility: row.is_private ? 'personal' : 'common',
|
||||
quantity: row.quantity,
|
||||
weight_grams: row.weight_grams,
|
||||
checked: row.checked,
|
||||
},
|
||||
});
|
||||
if (!created.ok())
|
||||
throw new Error(`could not re-create "${row.name}": ${created.status()} ${await created.text()}`);
|
||||
const { item } = (await created.json()) as { item: { id: number } };
|
||||
ids.push(item.id);
|
||||
}
|
||||
|
||||
const after = await packingRows(api);
|
||||
const inOrder = after.map((r) => r.id);
|
||||
if (inOrder.length === ids.length && inOrder.every((id, i) => id === ids[i])) return;
|
||||
await api.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds: ids } });
|
||||
}
|
||||
|
||||
/** Untick one item for a guide that starts from an item still to pack. */
|
||||
export async function untickPacking(api: APIRequestContext, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const row = (await packingRows(api)).find((r) => r.name === name);
|
||||
if (!row) throw new Error(`no packing item "${name}" in the seed`);
|
||||
if (!row.checked) return;
|
||||
const res = await api.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { checked: 0 } });
|
||||
if (!res.ok()) throw new Error(`could not untick "${name}": ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { seededTrip } from './fixtures';
|
||||
import { TOUR_NAMES } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the tour guide on the plan map needs beyond `ensureToursFixtures`: one
|
||||
* of the trip's tours on a day, so the day's route has a tour to run to and on
|
||||
* from. The Tours fixtures may already put one there; then nothing is added.
|
||||
* Otherwise the Fushimi Inari hike goes onto day 5, the day that already holds
|
||||
* Fushimi Inari Taisha, and `removeTourFromDay` takes exactly that assignment
|
||||
* out again in `afterAll`, so the screens after this one see the seed as it was.
|
||||
*/
|
||||
|
||||
interface DayAssignment {
|
||||
id: number;
|
||||
place_id: number;
|
||||
place?: { name?: string };
|
||||
}
|
||||
|
||||
/** The tour names, whatever shape the Tours fixtures export them in (a list or a map of them). */
|
||||
export function tourNames(): string[] {
|
||||
const values = Object.values(TOUR_NAMES as unknown as Record<string, unknown>);
|
||||
return values.filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
export interface TourOnDay {
|
||||
/** The day's number in the days column, 1-based. */
|
||||
day: number;
|
||||
/** The tour's name, as the list and its card show it. */
|
||||
name: string;
|
||||
/** The assignment this fixture created, to remove again; null when the tour was already on a day. */
|
||||
created: { dayId: number; id: number } | null;
|
||||
}
|
||||
|
||||
async function assignmentsOf(api: APIRequestContext, tripId: number, dayId: number): Promise<DayAssignment[]> {
|
||||
const res = await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`);
|
||||
if (!res.ok()) throw new Error(`could not read the assignments of day ${dayId}: ${res.status()}`);
|
||||
const body = (await res.json()) as { assignments?: DayAssignment[] };
|
||||
return body.assignments ?? [];
|
||||
}
|
||||
|
||||
export async function ensureTourOnDay(api: APIRequestContext): Promise<TourOnDay> {
|
||||
const { tripId, dayIds } = seededTrip();
|
||||
const names = tourNames();
|
||||
if (!names.length) throw new Error('the Tours fixtures export no tour names');
|
||||
|
||||
for (let i = 0; i < dayIds.length; i++) {
|
||||
const onDay = (await assignmentsOf(api, tripId, dayIds[i])).find((a) => names.includes(a.place?.name ?? ''));
|
||||
if (onDay?.place?.name) return { day: i + 1, name: onDay.place.name, created: null };
|
||||
}
|
||||
|
||||
const res = await api.get(`/api/trips/${tripId}/places`);
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[];
|
||||
const places = Array.isArray(body) ? body : (body.places ?? []);
|
||||
const tours = places.filter((p) => names.includes(p.name));
|
||||
const tour = tours.find((p) => /fushimi/i.test(p.name)) ?? tours[0];
|
||||
if (!tour) throw new Error(`none of the tours (${names.join(', ')}) is in the trip`);
|
||||
|
||||
const dayId = dayIds[4];
|
||||
const created = await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: tour.id } });
|
||||
if (!created.ok())
|
||||
throw new Error(`could not put "${tour.name}" on day 5: ${created.status()} ${await created.text()}`);
|
||||
const { assignment } = (await created.json()) as { assignment: { id: number } };
|
||||
return { day: 5, name: tour.name, created: { dayId, id: assignment.id } };
|
||||
}
|
||||
|
||||
export async function removeTourFromDay(api: APIRequestContext, onDay: TourOnDay | null): Promise<void> {
|
||||
if (!onDay?.created) return;
|
||||
const { tripId } = seededTrip();
|
||||
await api.delete(`/api/trips/${tripId}/days/${onDay.created.dayId}/assignments/${onDay.created.id}`);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { coverFixture, seededTrip } from './fixtures';
|
||||
|
||||
/**
|
||||
* What the place card's own guides need beyond the shared fixtures: a photo
|
||||
* already attached to a place, so the round picture offers From attached files.
|
||||
* The cover drawing is a mountain ridge at sunset, which is what an evening on
|
||||
* Mount Inari looks like, so it goes onto Fushimi Inari Taisha.
|
||||
*/
|
||||
|
||||
export const PHOTO_PLACE = 'Fushimi Inari Taisha';
|
||||
export const PHOTO_FILE = 'inari-evening.jpg';
|
||||
|
||||
type ApiFile = { id: number; original_name: string; place_id?: number | null };
|
||||
|
||||
async function tripFiles(api: APIRequestContext): Promise<ApiFile[]> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/files`);
|
||||
if (!res.ok()) throw new Error(`could not list the trip's files: ${res.status()}`);
|
||||
const body = (await res.json()) as { files?: ApiFile[] };
|
||||
return body.files ?? [];
|
||||
}
|
||||
|
||||
async function placeId(api: APIRequestContext, name: string): Promise<number> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/places`);
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[];
|
||||
const places = Array.isArray(body) ? body : (body.places ?? []);
|
||||
const found = places.find((p) => p.name === name);
|
||||
if (!found) throw new Error(`the seeded place "${name}" is missing`);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
/** Attach the photo to the place, once. */
|
||||
export async function ensurePlacePhoto(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const id = await placeId(api, PHOTO_PLACE);
|
||||
if ((await tripFiles(api)).some((f) => f.original_name === PHOTO_FILE && f.place_id === id)) return;
|
||||
const res = await api.post(`/api/trips/${tripId}/files`, {
|
||||
multipart: {
|
||||
file: { name: PHOTO_FILE, mimeType: 'image/jpeg', buffer: readFileSync(await coverFixture()) },
|
||||
place_id: String(id),
|
||||
},
|
||||
});
|
||||
if (!res.ok()) throw new Error(`could not attach ${PHOTO_FILE}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** Take the photo off the trip again, trash included, and the picture made from it. */
|
||||
export async function removePlacePhoto(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const id = await placeId(api, PHOTO_PLACE);
|
||||
await api.put(`/api/trips/${tripId}/places/${id}`, { data: { image_url: null } });
|
||||
for (const file of (await tripFiles(api)).filter((f) => f.original_name === PHOTO_FILE)) {
|
||||
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
|
||||
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { ensureDawarichConnection, toggleAddon } from './external';
|
||||
|
||||
/**
|
||||
* What the places column's own guides need beyond the seed and `fixtures.ts`.
|
||||
* Not a `.guide.ts`, so importing it registers no tests.
|
||||
*/
|
||||
|
||||
interface Suggestion {
|
||||
id: number;
|
||||
state: string;
|
||||
name: string;
|
||||
acceptedPlaceId: number | null;
|
||||
}
|
||||
|
||||
async function tripSuggestions(api: APIRequestContext, tripId: number): Promise<Suggestion[]> {
|
||||
const res = await api.get(`/api/integrations/dawarich/suggestions?tripId=${tripId}`);
|
||||
if (!res.ok()) throw new Error(`could not list the Dawarich stays: ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as { suggestions?: Suggestion[] };
|
||||
return body.suggestions ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dawarich connected and its stays of the seeded trip read into TREK, at
|
||||
* least two of them waiting, so the guide can take one over and put another
|
||||
* aside. The connection keeps automatic checks off (`ensureDawarichConnection`);
|
||||
* this asks once, by hand, the way Check now does. What an earlier attempt
|
||||
* accepted or dismissed is taken back first, so a rerun starts from the same list.
|
||||
*/
|
||||
export async function ensureDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
await ensureDawarichConnection(api);
|
||||
const synced = await api.post('/api/integrations/dawarich/sync');
|
||||
if (!synced.ok())
|
||||
throw new Error(`could not read the stays from Dawarich: ${synced.status()} ${await synced.text()}`);
|
||||
await undoDawarichStays(api, tripId);
|
||||
const waiting = (await tripSuggestions(api, tripId)).filter((s) => s.state === 'new');
|
||||
if (waiting.length < 2) {
|
||||
throw new Error(
|
||||
`Dawarich gave ${waiting.length} waiting stays for the trip; the guide needs two. ` +
|
||||
'Does the instance detect visits in the uploaded recording (Dawarich > Visits)?'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts every dismissed stay of the trip back into review. */
|
||||
export async function resetDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
for (const s of await tripSuggestions(api, tripId)) {
|
||||
if (s.state !== 'dismissed') continue;
|
||||
const res = await api.put(`/api/integrations/dawarich/suggestions/${s.id}/state`, { data: { state: 'new' } });
|
||||
if (!res.ok()) throw new Error(`could not put back the stay ${s.name}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours is on for every install, and with it on the places column has no
|
||||
* Import file. The file guide pictures the column of an install that switched
|
||||
* it off, and switches it back on afterwards.
|
||||
*/
|
||||
export const setTours = (api: APIRequestContext, enabled: boolean): Promise<void> => toggleAddon(api, 'tours', enabled);
|
||||
|
||||
/**
|
||||
* Takes back what the Dawarich guide did: the places it accepted are deleted,
|
||||
* which returns their stays to review on the server, and the dismissed stays
|
||||
* are put back. By the ids the stays carry, never by name: a stay is often
|
||||
* named like a place of the seed, and deleting by name would take that one too.
|
||||
*/
|
||||
export async function undoDawarichStays(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
for (const s of await tripSuggestions(api, tripId)) {
|
||||
if (s.state !== 'accepted' || s.acceptedPlaceId == null) continue;
|
||||
const res = await api.delete(`/api/trips/${tripId}/places/${s.acceptedPlaceId}`);
|
||||
if (!res.ok() && res.status() !== 404)
|
||||
throw new Error(`could not delete the place from ${s.name}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
await resetDawarichStays(api, tripId);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { at, day } from '../dates';
|
||||
import { seededTrip } from './fixtures';
|
||||
import { PICTURE_DAY } from './guide';
|
||||
|
||||
/**
|
||||
* What a few road trip guides need beyond `ensureRoadtripFixtures`, each one
|
||||
* written by the guide that needs it and taken away again in its cleanup: the
|
||||
* other guides of the screen draw the same rail, and a fuel stop or a ferry
|
||||
* left behind would change their drive.
|
||||
*
|
||||
* Day N of "Autumn in Japan" is `day(N - 10)`: the trip ends on the picture day.
|
||||
*/
|
||||
|
||||
type Place = { id: number; name: string };
|
||||
type Assignment = { id: number; place_id: number };
|
||||
|
||||
async function placesOf(api: APIRequestContext): Promise<Place[]> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/places`);
|
||||
const body = (await res.json()) as { places?: Place[] } | Place[];
|
||||
return Array.isArray(body) ? body : (body.places ?? []);
|
||||
}
|
||||
|
||||
export async function assignmentsOn(api: APIRequestContext, dayIndex: number): Promise<Assignment[]> {
|
||||
const { tripId, dayIds } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`);
|
||||
const body = (await res.json()) as { assignments?: Assignment[] };
|
||||
return body.assignments ?? [];
|
||||
}
|
||||
|
||||
export async function placeIdOf(api: APIRequestContext, name: string): Promise<number | null> {
|
||||
return (await placesOf(api)).find((p) => p.name === name)?.id ?? null;
|
||||
}
|
||||
|
||||
// ── A fuel stop on day 4, for the fill-level guide ───────────────────────────
|
||||
|
||||
/** A motorway service area on the Tomei, between Tokyo and Hakone. */
|
||||
export const FUEL_STOP = {
|
||||
name: 'Ebina Service Area',
|
||||
lat: 35.4178,
|
||||
lng: 139.3955,
|
||||
address: 'Ebina, Kanagawa',
|
||||
description: 'Fuel and food on the Tomei Expressway, halfway to Hakone.',
|
||||
stop_type: 'fuel',
|
||||
};
|
||||
|
||||
/**
|
||||
* Put the service area on day 4 between Shinjuku Gyoen and Hakone Shrine, the
|
||||
* leg it really lies on. Idempotent: an existing one is only put back in place.
|
||||
*/
|
||||
export async function ensureFuelStop(api: APIRequestContext): Promise<void> {
|
||||
const { tripId, dayIds } = seededTrip();
|
||||
let id = await placeIdOf(api, FUEL_STOP.name);
|
||||
if (id === null) {
|
||||
const created = await api.post(`/api/trips/${tripId}/places`, { data: FUEL_STOP });
|
||||
if (!created.ok())
|
||||
throw new Error(`could not create "${FUEL_STOP.name}": ${created.status()} ${await created.text()}`);
|
||||
id = await placeIdOf(api, FUEL_STOP.name);
|
||||
}
|
||||
if (id === null) throw new Error(`"${FUEL_STOP.name}" is missing after creating it`);
|
||||
if (!(await assignmentsOn(api, 3)).some((a) => a.place_id === id)) {
|
||||
const pinned = await api.post(`/api/trips/${tripId}/days/${dayIds[3]}/assignments`, { data: { place_id: id } });
|
||||
if (!pinned.ok())
|
||||
throw new Error(`could not put the fuel stop on day 4: ${pinned.status()} ${await pinned.text()}`);
|
||||
}
|
||||
// Right before Hakone Shrine, so the drive is Shinjuku, the service area, Hakone.
|
||||
const hakone = await placeIdOf(api, 'Hakone Shrine');
|
||||
const day4 = await assignmentsOn(api, 3);
|
||||
const fuel = day4.find((a) => a.place_id === id);
|
||||
const rest = day4.filter((a) => a !== fuel);
|
||||
const slot = rest.findIndex((a) => a.place_id === hakone);
|
||||
if (fuel && slot >= 0) {
|
||||
const orderedIds = [...rest.slice(0, slot), fuel, ...rest.slice(slot)].map((a) => a.id);
|
||||
await api.put(`/api/trips/${tripId}/days/${dayIds[3]}/assignments/reorder`, { data: { orderedIds } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFuelStop(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const id = await placeIdOf(api, FUEL_STOP.name);
|
||||
if (id !== null) await api.delete(`/api/trips/${tripId}/places/${id}`);
|
||||
}
|
||||
|
||||
// ── A ferry on day 3, for the bookings guide ─────────────────────────────────
|
||||
|
||||
/** The boat across Lake Ashi, between the two Hakone stops of day 3. */
|
||||
export const FERRY = {
|
||||
title: 'Lake Ashi cruise Togendai → Moto-Hakone',
|
||||
from: { name: 'Togendai Port', lat: 35.2386, lng: 139.0128, time: '11:50' },
|
||||
to: { name: 'Moto-Hakone Port', lat: 35.1973, lng: 139.025, time: '12:25' },
|
||||
};
|
||||
|
||||
type Booking = { id: number; title: string };
|
||||
|
||||
async function bookingsOf(api: APIRequestContext): Promise<Booking[]> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.get(`/api/trips/${tripId}/reservations`);
|
||||
const body = (await res.json()) as { reservations?: Booking[] } | Booking[];
|
||||
return Array.isArray(body) ? body : (body.reservations ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ferry booking with both piers located, so the drive ends at one and starts
|
||||
* again at the other. 11:50 leaves the half hour of check-in a ferry is pinned
|
||||
* ahead of the timetable after the stop at Lake Ashi, so the badge reads on time.
|
||||
*/
|
||||
export async function ensureFerry(api: APIRequestContext): Promise<void> {
|
||||
if ((await bookingsOf(api)).some((b) => b.title === FERRY.title)) return;
|
||||
const { tripId, dayIds } = seededTrip();
|
||||
const created = await api.post(`/api/trips/${tripId}/reservations`, {
|
||||
data: {
|
||||
title: FERRY.title,
|
||||
type: 'ferry',
|
||||
status: 'confirmed',
|
||||
day_id: dayIds[2],
|
||||
end_day_id: dayIds[2],
|
||||
reservation_time: at(-7, FERRY.from.time),
|
||||
reservation_end_time: at(-7, FERRY.to.time),
|
||||
confirmation_number: 'HKN-3307',
|
||||
location: FERRY.from.name,
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: FERRY.from.name,
|
||||
lat: FERRY.from.lat,
|
||||
lng: FERRY.from.lng,
|
||||
timezone: 'Asia/Tokyo',
|
||||
local_date: day(-7),
|
||||
local_time: FERRY.from.time,
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: FERRY.to.name,
|
||||
lat: FERRY.to.lat,
|
||||
lng: FERRY.to.lng,
|
||||
timezone: 'Asia/Tokyo',
|
||||
local_date: day(-7),
|
||||
local_time: FERRY.to.time,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!created.ok()) throw new Error(`could not create the ferry: ${created.status()} ${await created.text()}`);
|
||||
}
|
||||
|
||||
export async function removeFerry(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
for (const booking of (await bookingsOf(api)).filter((b) => b.title === FERRY.title)) {
|
||||
await api.delete(`/api/trips/${tripId}/reservations/${booking.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── A night found along the route, taken away again ──────────────────────────
|
||||
|
||||
/** Cancel the stays booked for a place, with their bookings, then the place. */
|
||||
export async function removeNightAt(api: APIRequestContext, name: string): Promise<void> {
|
||||
if (!name) return;
|
||||
const { tripId } = seededTrip();
|
||||
const id = await placeIdOf(api, name);
|
||||
if (id === null) return;
|
||||
const res = await api.get(`/api/trips/${tripId}/accommodations`);
|
||||
const body = (await res.json()) as { accommodations?: { id: number; place_id: number }[] };
|
||||
for (const stay of (body.accommodations ?? []).filter((a) => a.place_id === id)) {
|
||||
await api.delete(`/api/trips/${tripId}/accommodations/${stay.id}`);
|
||||
}
|
||||
await api.delete(`/api/trips/${tripId}/places/${id}`);
|
||||
}
|
||||
|
||||
// ── The Google Maps import, answered without asking Google ───────────────────
|
||||
|
||||
/** A route from Hakone round Mount Fuji, the way the preview reads one. */
|
||||
export const GOOGLE_ROUTE = {
|
||||
url: 'https://maps.app.goo.gl/hakoneFujiFiveLakes',
|
||||
stops: [
|
||||
{ name: 'Gotemba Premium Outlets', lat: 35.3076, lng: 138.964 },
|
||||
{ name: 'Oshino Hakkai', lat: 35.4602, lng: 138.8327 },
|
||||
{ name: 'Lake Kawaguchi', lat: 35.5171, lng: 138.7519 },
|
||||
{ name: 'Fuji viewpoint (dropped pin)', lat: null, lng: null },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The preview is the one request that goes to Google, so it is answered here;
|
||||
* the import itself is TREK's own and runs for real.
|
||||
*/
|
||||
export async function stubGooglePreview(page: Page): Promise<void> {
|
||||
await page.route('**/api/roadtrip/google-maps-preview', (route) =>
|
||||
route.fulfill({ status: 200, json: { stops: GOOGLE_ROUTE.stops } })
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeImportedStops(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const names = GOOGLE_ROUTE.stops.filter((s) => s.lat !== null).map((s) => s.name);
|
||||
for (const place of (await placesOf(api)).filter((p) => names.includes(p.name))) {
|
||||
await api.delete(`/api/trips/${tripId}/places/${place.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Current warnings, answered without the live feeds ────────────────────────
|
||||
|
||||
/** A storm over Hakone and the Izu peninsula, the shape GDACS reports one in. */
|
||||
export const HAZARD_TITLE = 'Tropical storm, green alert';
|
||||
|
||||
/**
|
||||
* The warnings come live from DWD and GDACS, and neither has to have anything
|
||||
* near Japan on the day the pictures are taken. The feed is answered here.
|
||||
*/
|
||||
export async function stubHazards(page: Page): Promise<void> {
|
||||
const now = PICTURE_DAY.toISOString();
|
||||
const until = new Date(PICTURE_DAY.getTime() + 36 * 3600_000).toISOString();
|
||||
// On the context, not the page: in dev the service worker makes this GET itself,
|
||||
// and only the context's routes see what a service worker asks for.
|
||||
await page.context().route('**/api/trips/*/roadtrip/hazards*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
fetchedAt: now,
|
||||
sources: [
|
||||
{ source: 'DWD', status: 'ok' },
|
||||
{ source: 'GDACS', status: 'ok' },
|
||||
],
|
||||
hazards: [
|
||||
{
|
||||
id: 'gdacs-tc-demo',
|
||||
source: 'GDACS',
|
||||
title: HAZARD_TITLE,
|
||||
description: 'Heavy rain and gusts of up to 90 km/h around Hakone and the Izu peninsula.',
|
||||
updatedAt: now,
|
||||
validUntil: until,
|
||||
url: 'https://www.gdacs.org/',
|
||||
alertScore: 0.5,
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[138.82, 35.02],
|
||||
[139.18, 34.98],
|
||||
[139.36, 35.16],
|
||||
[139.3, 35.4],
|
||||
[138.98, 35.46],
|
||||
[138.78, 35.26],
|
||||
[138.82, 35.02],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function hideHazards(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
await api.put(`/api/trips/${tripId}/roadtrip/preferences`, { data: { roadtrip_show_hazards: false } });
|
||||
}
|
||||
|
||||
// ── The flight out, as the drive reads it ────────────────────────────────────
|
||||
|
||||
/** The seed's flight; it leaves Frankfurt on day 1, after day 1's first stops in Tokyo. */
|
||||
const FLIGHT_TITLE = 'LH716 FRA → HND';
|
||||
|
||||
type Endpoint = {
|
||||
role: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone?: string | null;
|
||||
local_date?: string | null;
|
||||
local_time?: string | null;
|
||||
};
|
||||
type FlightRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
day_id: number | null;
|
||||
end_day_id: number | null;
|
||||
endpoints?: Endpoint[];
|
||||
};
|
||||
|
||||
let flightAsSeeded: FlightRow | null = null;
|
||||
|
||||
async function flightOf(api: APIRequestContext): Promise<FlightRow | null> {
|
||||
return ((await bookingsOf(api)) as unknown as FlightRow[]).find((b) => b.title === FLIGHT_TITLE) ?? null;
|
||||
}
|
||||
|
||||
async function putFlight(api: APIRequestContext, id: number, data: Partial<FlightRow>): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const res = await api.put(`/api/trips/${tripId}/reservations/${id}`, { data });
|
||||
if (!res.ok()) throw new Error(`could not move the flight: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive starts where the flight lands. The seed has it leave Frankfurt at
|
||||
* 13:05 on day 1, after Senso-ji, so the road trip would drive from Tokyo to
|
||||
* Frankfurt and back. For these pictures it is the overnight flight it should
|
||||
* be, into Haneda at 06:50 on day 1, and only its Haneda end is located.
|
||||
*/
|
||||
export async function overnightFlight(api: APIRequestContext): Promise<void> {
|
||||
const flight = await flightOf(api);
|
||||
if (!flight) throw new Error(`no booking "${FLIGHT_TITLE}" to move overnight`);
|
||||
flightAsSeeded ??= flight;
|
||||
const ends = flight.endpoints ?? [];
|
||||
await putFlight(api, flight.id, {
|
||||
reservation_time: at(-10, '17:55'),
|
||||
reservation_end_time: at(-9, '06:50'),
|
||||
// The server keeps a booking on the trip's first day even when it leaves the
|
||||
// evening before, so the drive would still start in Frankfurt. A ride without a
|
||||
// located end at both sides is left to the day plan, so the Frankfurt end goes.
|
||||
endpoints: ends.filter((e) => e.role !== 'from').map((e) => ({ ...e, local_date: day(-9), local_time: '06:50' })),
|
||||
});
|
||||
const moved = await flightOf(api);
|
||||
if ((moved?.endpoints ?? []).some((e) => e.role === 'from')) {
|
||||
throw new Error(`the flight still starts in Frankfurt: ${JSON.stringify(moved?.endpoints)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The flight back as the seed has it, for the screens after these. */
|
||||
export async function restoreFlight(api: APIRequestContext): Promise<void> {
|
||||
const seeded = flightAsSeeded;
|
||||
if (!seeded) return;
|
||||
await putFlight(api, seeded.id, {
|
||||
reservation_time: seeded.reservation_time,
|
||||
reservation_end_time: seeded.reservation_end_time,
|
||||
day_id: seeded.day_id,
|
||||
end_day_id: seeded.end_day_id,
|
||||
endpoints: seeded.endpoints,
|
||||
});
|
||||
flightAsSeeded = null;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { short } from '../dates';
|
||||
import { drawPdf, seededTrip } from './fixtures';
|
||||
import { TOUR_NAMES, tourDayOffset } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the tour card's guides need beyond `ensureToursFixtures`: a packing
|
||||
* category of the via ferrata gear for the ferrata tour to be linked to (two
|
||||
* of its items packed, so the bar is partly full), the hut booking a guide
|
||||
* uploads to the hut trek, and the small API helpers the guides clean up with.
|
||||
* Everything here is idempotent: it finds its rows by name before it makes them.
|
||||
*/
|
||||
|
||||
/** The packing category the ferrata tour gets linked to in `tour-packing-link`. */
|
||||
export const FERRATA_PACKING = {
|
||||
category: 'Via ferrata',
|
||||
items: ['Helmet', 'Climbing harness', 'Via ferrata set', 'Leather gloves', 'Head torch'],
|
||||
packed: ['Helmet', 'Climbing harness'],
|
||||
};
|
||||
|
||||
/** The confirmation the files guide puts next to the hut trek. */
|
||||
export const HUT_BOOKING_FILE = 'rifugio-puez-half-board.pdf';
|
||||
|
||||
type Res = { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> };
|
||||
|
||||
async function json<T>(res: Res, what: string): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 500)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
interface TourRow {
|
||||
place_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
|
||||
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
|
||||
.tours;
|
||||
}
|
||||
|
||||
/** A tour's place id by its name; throws when the fixtures did not make it. */
|
||||
export async function tourIdOf(api: APIRequestContext, tripId: number, name: string): Promise<number> {
|
||||
const found = (await toursOf(api, tripId)).find((t) => t.name === name);
|
||||
if (!found) throw new Error(`no tour "${name}" in trip ${tripId}: ensureToursFixtures did not make it`);
|
||||
return found.place_id;
|
||||
}
|
||||
|
||||
interface PackingItem {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string | null;
|
||||
checked?: number | boolean;
|
||||
}
|
||||
|
||||
async function packingOf(api: APIRequestContext, tripId: number): Promise<PackingItem[]> {
|
||||
const body = await json<{ items?: PackingItem[] } | PackingItem[]>(
|
||||
await api.get(`/api/trips/${tripId}/packing`),
|
||||
'packing'
|
||||
);
|
||||
return Array.isArray(body) ? body : (body.items ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata gear in the Dolomites packing list, two of it packed, and
|
||||
* the ferrata tour without a packing link, which is where the guide starts.
|
||||
*/
|
||||
export async function ensureTourDetailFixtures(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
let items = await packingOf(api, tripId);
|
||||
for (const name of FERRATA_PACKING.items) {
|
||||
if (items.some((i) => i.name === name && i.category === FERRATA_PACKING.category)) continue;
|
||||
await json(
|
||||
await api.post(`/api/trips/${tripId}/packing`, {
|
||||
data: { name, category: FERRATA_PACKING.category, visibility: 'common' },
|
||||
}),
|
||||
`packing item ${name}`
|
||||
);
|
||||
}
|
||||
items = await packingOf(api, tripId);
|
||||
for (const name of FERRATA_PACKING.packed) {
|
||||
const item = items.find((i) => i.name === name && i.category === FERRATA_PACKING.category);
|
||||
if (item && !item.checked) {
|
||||
await json(await api.put(`/api/trips/${tripId}/packing/${item.id}`, { data: { checked: true } }), `pack ${name}`);
|
||||
}
|
||||
}
|
||||
await unlinkPacking(api, tripId, await tourIdOf(api, tripId, TOUR_NAMES.ferrata));
|
||||
}
|
||||
|
||||
/** Take a tour's packing link away; a tour without one is left as it is. */
|
||||
export async function unlinkPacking(api: APIRequestContext, tripId: number, placeId: number): Promise<void> {
|
||||
const res = await api.delete(`/api/trips/${tripId}/tours/${placeId}/packing-link`);
|
||||
if (!res.ok() && res.status() !== 404)
|
||||
throw new Error(`unlink packing: ${res.status()} ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** Delete every tour of the trip with this name (the copies a guide made). Tours are places. */
|
||||
export async function deleteToursNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
|
||||
for (const tour of (await toursOf(api, tripId)).filter((t) => t.name === name)) {
|
||||
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Take the tours of this name out of a collection again. */
|
||||
export async function unkeepTour(api: APIRequestContext, collectionId: number, name: string): Promise<void> {
|
||||
const res = await api.get(`/api/addons/collections/${collectionId}/tours`);
|
||||
if (!res.ok()) return;
|
||||
const { tours = [] } = (await res.json()) as { tours?: { id: number; name: string }[] };
|
||||
for (const kept of tours.filter((t) => t.name === name)) {
|
||||
await api.delete(`/api/addons/collections/${collectionId}/tours/${kept.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove an uploaded file from the trip, out of the bin as well. */
|
||||
export async function deleteTripFilesNamed(api: APIRequestContext, tripId: number, name: string): Promise<void> {
|
||||
const res = await api.get(`/api/trips/${tripId}/files`);
|
||||
if (!res.ok()) return;
|
||||
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] };
|
||||
for (const file of files.filter((f) => f.original_name === name)) {
|
||||
await api.delete(`/api/trips/${tripId}/files/${file.id}`);
|
||||
await api.delete(`/api/trips/${tripId}/files/${file.id}/permanent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Fushimi Inari hike of the Japan trip and its linked recordings, as the tour detail reports them. */
|
||||
async function inariActivities(
|
||||
api: APIRequestContext
|
||||
): Promise<{ hikeId: number; activities: { id: number; track: { place_id: number } }[] }> {
|
||||
const { tripId } = seededTrip();
|
||||
const hikeId = await tourIdOf(api, tripId, TOUR_NAMES.inariHike);
|
||||
const detail = await json<{ activities?: { id: number; track: { place_id: number } }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/tours/${hikeId}`),
|
||||
'the Fushimi Inari hike'
|
||||
);
|
||||
return { hikeId, activities: detail.activities ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink the recording from the planned hike, so `tour-activity-link` starts
|
||||
* from a tour with no actual track. The recording itself stays in the trip as
|
||||
* an import, which is what the guide then picks.
|
||||
*/
|
||||
export async function unlinkInariRecording(api: APIRequestContext): Promise<void> {
|
||||
const { tripId } = seededTrip();
|
||||
const { hikeId, activities } = await inariActivities(api);
|
||||
for (const activity of activities) {
|
||||
await json(
|
||||
await api.delete(`/api/trips/${tripId}/tours/${hikeId}/activities/${activity.id}`),
|
||||
'unlink the recording'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the recording out of the Japan trip again once the activity guides are
|
||||
* done: `ensureTourRecording` is opt-in because the recording is one more
|
||||
* tour in a trip every other screen photographs.
|
||||
*/
|
||||
export async function removeInariRecording(api: APIRequestContext): Promise<void> {
|
||||
try {
|
||||
await unlinkInariRecording(api);
|
||||
} catch (err) {
|
||||
console.warn(`[tour-detail] the recording could not be unlinked before it is removed: ${String(err)}`);
|
||||
}
|
||||
await deleteToursNamed(api, seededTrip().tripId, TOUR_NAMES.inariRecording);
|
||||
}
|
||||
|
||||
/** The hut's half-board confirmation, drawn on every call so its date follows the trip. */
|
||||
export function hutBookingFixture(): string {
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, HUT_BOOKING_FILE);
|
||||
writeFileSync(
|
||||
file,
|
||||
drawPdf({
|
||||
issuer: 'Rifugio Puez',
|
||||
title: 'Booking confirmation, half board',
|
||||
reference: 'Booking PZ-2417, Puez-Odle Nature Park, 2475 m',
|
||||
fields: [
|
||||
['Guests', '2 adults, alpine club members'],
|
||||
['Night', `${short(tourDayOffset(4))}, one night`],
|
||||
['Room', 'Dormitory, 6 beds, 2 places'],
|
||||
['Board', 'Dinner at 18:30, breakfast from 06:30'],
|
||||
['Deposit', 'EUR 40 paid, rest in cash at the hut'],
|
||||
],
|
||||
footer: 'Cancel by phone up to 48 hours before arrival. No card payment at the hut.',
|
||||
})
|
||||
);
|
||||
return file;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import { dismissReleaseNotice, settle } from './guide';
|
||||
import { JAPAN_TRIP, TOUR_TRIP } from './tour-fixtures';
|
||||
import { tripReady } from './trip-shared';
|
||||
|
||||
/**
|
||||
* What the hike and run help pictures need besides `tour-fixtures.ts`: a way
|
||||
* into the Tours tab of the Dolomites or the Japan trip, a tour opened in the
|
||||
* editor, a route that has finished planning, and the tours a guide saved
|
||||
* taken away again. Nothing here creates data of its own; the two tours the
|
||||
* plan guides save through the UI are deleted by name in their cleanup.
|
||||
*/
|
||||
|
||||
/** The two tours the plan guides save, by the names they type. */
|
||||
export const SAVED_BY_GUIDES = {
|
||||
hike: 'Up to Rifugio Firenze',
|
||||
run: 'Trail run to Rifugio Firenze',
|
||||
} as const;
|
||||
|
||||
async function tripIdOf(page: Page, title: string): Promise<number> {
|
||||
const res = await page.request.get('/api/trips');
|
||||
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
|
||||
const found = trips.find((t) => t.title === title);
|
||||
if (!found) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Tours tab of a trip, with its list on the right loaded. `tripReady`
|
||||
* waits for the Trip settings pill, which every tab of the v5 header carries.
|
||||
* The account's draft of the trip goes first: the planner restores it on load,
|
||||
* so a draft the guide before left behind (its page can still write one in the
|
||||
* second after its cleanup) would open the editor on that tour instead of the
|
||||
* neutral rail, and a click on a tour row would then ask to discard it.
|
||||
*/
|
||||
export async function openToursTab(page: Page, trip: 'summer' | 'japan' = 'summer'): Promise<number> {
|
||||
const id = await tripIdOf(page, trip === 'japan' ? JAPAN_TRIP : TOUR_TRIP);
|
||||
await page.request.delete(`/api/trips/${id}/tour-draft`).catch(() => undefined);
|
||||
await page.goto(`/trips/${id}?tab=tour-planner`);
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
await page.waitForTimeout(1200);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The left column: the neutral rail and the editor both carry this name. */
|
||||
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
|
||||
/** The right column, Tours of this trip. */
|
||||
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip', exact: true });
|
||||
/** A tour's row in Tours of this trip. */
|
||||
export const tourRow = (page: Page, name: string) =>
|
||||
toursRail(page)
|
||||
.getByRole('option', { name: new RegExp(`^${name}`) })
|
||||
.first();
|
||||
|
||||
/** Words the status dot beside the editor's title carries while it is still working. */
|
||||
const BUSY = /^(Calculating[a-z ]* route|Loading elevation|Waiting to calculate|Saving)/;
|
||||
|
||||
/** The route of the open tour has been planned and its heights are in. */
|
||||
export async function routeSettled(page: Page, timeout = 120_000): Promise<void> {
|
||||
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout });
|
||||
await expect(page.getByTestId('tour-planner-status').getByLabel(BUSY)).toHaveCount(0, { timeout });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Open a saved tour from the right column in the editor and wait for its route. */
|
||||
export async function openTour(page: Page, name: string): Promise<void> {
|
||||
await tourRow(page, name).click();
|
||||
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('#tour-planner-name')).toHaveValue(name, { timeout: 30_000 });
|
||||
await routeSettled(page);
|
||||
await page.waitForTimeout(800);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Plan new tour from the neutral rail, and the editor that opens. */
|
||||
export async function startNewTour(page: Page): Promise<void> {
|
||||
await editorRail(page).getByRole('button', { name: 'Plan new tour' }).click();
|
||||
await expect(editorRail(page).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Pick a tour type in the Tour type field. */
|
||||
export async function pickTourType(page: Page, type: 'hike' | 'run'): Promise<void> {
|
||||
const option = page.getByTestId(`tour-type-option-${type}`);
|
||||
if (!(await option.isVisible().catch(() => false))) await page.getByTestId('tour-type-select').click();
|
||||
await option.click();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Add stops from the trip's own places, through Trip places under the waypoints. */
|
||||
export async function addTripPlaces(page: Page, ...names: string[]): Promise<void> {
|
||||
const picker = page.getByTestId('trip-place-picker');
|
||||
if (!(await picker.isVisible().catch(() => false))) await page.getByTestId('tour-planner-add-place').click();
|
||||
await expect(picker).toBeVisible();
|
||||
const search = picker.getByPlaceholder("Search the trip's places");
|
||||
for (const name of names) {
|
||||
await search.fill(name);
|
||||
await picker.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
await search.fill('');
|
||||
await picker.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The Details section of the editor, unfolded. */
|
||||
export async function openDetails(page: Page): Promise<void> {
|
||||
const show = editorRail(page).getByRole('button', { name: 'Show details', exact: true });
|
||||
if (await show.isVisible().catch(() => false)) await show.click();
|
||||
await expect(page.locator('#tour-planner-details')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Delete the tours a guide saved through the UI, so the next guide finds the list of the seed. */
|
||||
export async function deleteToursNamed(page: Page, tripId: number, ...names: string[]): Promise<void> {
|
||||
const res = await page.request.get(`/api/trips/${tripId}/tours`);
|
||||
if (!res.ok()) return;
|
||||
const { tours = [] } = (await res.json()) as { tours?: { place_id: number; name: string }[] };
|
||||
for (const tour of tours.filter((t) => names.includes(t.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the editor's unsaved draft of the trip: on the account, where the
|
||||
* planner keeps it now and restores it on the next page, and the copy older
|
||||
* builds left in this browser, so a reload starts clean.
|
||||
*/
|
||||
export async function forgetDraft(page: Page, tripId: number): Promise<void> {
|
||||
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
|
||||
await page
|
||||
.evaluate((id) => {
|
||||
try {
|
||||
localStorage.removeItem(`tour-draft-${id}`);
|
||||
} catch {
|
||||
// Storage blocked: nothing was kept either.
|
||||
}
|
||||
}, tripId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
|
||||
import { TOUR_GPX, TOUR_NAMES, TOUR_TRIP, tourGpxFixture } from './tour-fixtures';
|
||||
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
|
||||
|
||||
/**
|
||||
* What the horse ride guides share: the way into the Tours tab with the
|
||||
* Seiser Alm in view, the horse routes the Horse routes card finds there, and
|
||||
* the GPX file of a recorded ride. Not a `.guide.ts`, so importing it
|
||||
* registers no tests.
|
||||
*
|
||||
* The horse routes come from OpenStreetMap through the server (TREK Places,
|
||||
* else Overpass), and a fresh database has neither cached. What the area has
|
||||
* is asked once, in `beforeAll`: when it holds a section long enough to ride
|
||||
* and a second one a short way off its end (the gap guide needs both), that
|
||||
* answer is what the card is given for the whole run, so every picture shows
|
||||
* the same list. When it does not, the card is given two routes cut from the
|
||||
* ride the fixtures saved on the Seiser Alm, whose line follows real ways
|
||||
* (a horse route section where one was found, farm tracks otherwise).
|
||||
*/
|
||||
|
||||
type LatLng = [number, number];
|
||||
|
||||
interface HorseSection {
|
||||
number: number;
|
||||
geometry: LatLng[];
|
||||
length_m: number;
|
||||
access: { value: string; length_m: number }[];
|
||||
signposted: 'with' | 'against' | null;
|
||||
}
|
||||
interface HorseRoute {
|
||||
id: string;
|
||||
name: string | null;
|
||||
ref: string | null;
|
||||
osmUrl: string;
|
||||
continues: boolean;
|
||||
sections: HorseSection[];
|
||||
}
|
||||
interface HorseAnswer {
|
||||
routes: HorseRoute[];
|
||||
truncated: boolean;
|
||||
source: 'openstreetmap';
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
/** Which route and section each guide takes, by the test ids the card and the dialog carry. */
|
||||
export interface HorsePick {
|
||||
routeId: string;
|
||||
section: number;
|
||||
}
|
||||
export interface HorsePlan {
|
||||
answer: HorseAnswer;
|
||||
/** The section the ride starts with. */
|
||||
first: HorsePick;
|
||||
/** A section that starts away from the end of `first`, for the gap guide. */
|
||||
gap: HorsePick;
|
||||
/** Where the answer came from, for the run's log. */
|
||||
source: 'live' | 'demo';
|
||||
}
|
||||
|
||||
/** The name the plan guide saves its new ride under; its cleanup deletes it. */
|
||||
export const NEW_RIDE = 'Morning ride on the Seiser Alm';
|
||||
/** The recorded ride the import guide brings in, and its file. */
|
||||
export const RECORDED_RIDE = { name: 'Rasciesa ride', file: 'rasciesa-ride.gpx' };
|
||||
|
||||
// ── The trip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function tourTripId(api: APIRequestContext): Promise<number> {
|
||||
const res = await api.get('/api/trips');
|
||||
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
|
||||
const found = trips.find((t) => t.title === TOUR_TRIP);
|
||||
if (!found) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
interface TourListRow {
|
||||
place_id: number;
|
||||
name: string;
|
||||
tour_type?: string;
|
||||
}
|
||||
|
||||
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourListRow[]> {
|
||||
const res = await api.get(`/api/trips/${tripId}/tours`);
|
||||
if (!res.ok()) return [];
|
||||
return ((await res.json()) as { tours?: TourListRow[] }).tours ?? [];
|
||||
}
|
||||
|
||||
/** Delete the tours a guide made, by name. A tour is a place, and goes the way a place goes. */
|
||||
export async function deleteToursNamed(api: APIRequestContext, ...names: string[]): Promise<void> {
|
||||
const tripId = await tourTripId(api);
|
||||
for (const tour of (await toursOfTrip(api, tripId)).filter((t) => names.includes(t.name))) {
|
||||
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the caller's unsaved editor draft of the trip; a trip without one answers 404, which is fine. */
|
||||
export async function forgetTourDraft(api: APIRequestContext, tripId?: number): Promise<void> {
|
||||
const id = tripId ?? (await tourTripId(api));
|
||||
const res = await api.delete(`/api/trips/${id}/tour-draft`);
|
||||
if (!res.ok() && res.status() !== 404)
|
||||
console.warn(`[trip-tour-horse] could not drop the tour draft: ${res.status()}`);
|
||||
}
|
||||
|
||||
const rawKey = (page: Page) =>
|
||||
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing)\.[A-Za-z0-9.]+$/);
|
||||
|
||||
/** The trip tab bar's Tours tab: a plain button named after the tab. */
|
||||
export const toursTabButton = (page: Page) => page.getByRole('button', { name: 'Tours', exact: true }).first();
|
||||
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
|
||||
export const tourRow = (page: Page, name: string) =>
|
||||
tripTours(page).getByRole('option').filter({ hasText: name }).first();
|
||||
|
||||
/**
|
||||
* Let a pan of the map land. Leaflet times it from `+new Date()` and the clock
|
||||
* is pinned to the picture day, so a pan starts and never gets anywhere; moving
|
||||
* the pinned clock on a few seconds ends it, as `finishMapPan` does on the
|
||||
* road trip screen.
|
||||
*/
|
||||
export async function finishMapPan(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400);
|
||||
await nudgeClock(page);
|
||||
await page.waitForTimeout(1200);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Dolomites trip on the Tours tab with the Seiser Alm in view.
|
||||
*
|
||||
* A new draft keeps the map where it is, and the planner opens on the area the
|
||||
* plan map showed last (`usePlannerFocusPoints`), so the plan is opened on day 2,
|
||||
* the day of the horse ride, and the Tours tab is reached by its button rather
|
||||
* than by the address: a page load would forget that area.
|
||||
*
|
||||
* The editor's unsaved draft is kept on the account per trip now, not in the
|
||||
* browser, so a draft another guide left on this trip comes back into the left
|
||||
* rail on load: Plan new tour is then gone, and a click on a saved tour asks
|
||||
* first whether to discard it. It is dropped before the page opens.
|
||||
*/
|
||||
export async function openToursTab(page: Page): Promise<number> {
|
||||
const tripId = await tourTripId(page.request);
|
||||
await forgetTourDraft(page.request, tripId);
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
|
||||
await page.waitForTimeout(4000);
|
||||
await page.reload();
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
|
||||
await selectDay(page, 2);
|
||||
await closeDayDetails(page);
|
||||
await finishMapPan(page);
|
||||
await toursTabButton(page).click();
|
||||
await expect(tripTours(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
tourRow(page, TOUR_NAMES.horse),
|
||||
`no "${TOUR_NAMES.horse}": did ensureToursFixtures make it?`
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await finishMapPan(page);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
/** The saved ride open in the editor, its line framed. */
|
||||
export async function openSavedRide(page: Page): Promise<void> {
|
||||
await openToursTab(page);
|
||||
const row = tourRow(page, TOUR_NAMES.horse);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
|
||||
await expect(page.getByRole('region', { name: 'Horse routes' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
|
||||
await finishMapPan(page);
|
||||
}
|
||||
|
||||
// ── The horse routes ──────────────────────────────────────────────────────────
|
||||
|
||||
const R = 6_371_000;
|
||||
function metres(a: LatLng, b: LatLng): number {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (b[0] - a[0]) * rad;
|
||||
const dLng = (b[1] - a[1]) * rad;
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
function lengthOf(line: LatLng[]): number {
|
||||
return line.slice(1).reduce((sum, p, i) => sum + metres(line[i], p), 0);
|
||||
}
|
||||
|
||||
/** The stretch of `line` from `from` to `to`, as shares of its length. */
|
||||
function cut(line: LatLng[], from: number, to: number): LatLng[] {
|
||||
const total = lengthOf(line);
|
||||
const out: LatLng[] = [];
|
||||
let run = 0;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (i > 0) run += metres(line[i - 1], line[i]);
|
||||
const share = run / total;
|
||||
if (share >= from && share <= to) out.push(line[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineOf(raw: unknown): LatLng[] {
|
||||
const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((p): p is number[] => Array.isArray(p) && p.length >= 2)
|
||||
.map((p) => [Number(p[0]), Number(p[1])] as LatLng);
|
||||
}
|
||||
|
||||
/** The saved ride's line, from its detail or, failing that, from the trip's places. */
|
||||
async function savedRideLine(api: APIRequestContext, tripId: number): Promise<LatLng[]> {
|
||||
const ride = (await toursOfTrip(api, tripId)).find((t) => t.name === TOUR_NAMES.horse);
|
||||
if (!ride) throw new Error(`no "${TOUR_NAMES.horse}": call ensureToursFixtures first`);
|
||||
const detail = await api.get(`/api/trips/${tripId}/tours/${ride.place_id}`);
|
||||
if (detail.ok()) {
|
||||
const body = (await detail.json()) as { tour?: { route_geometry?: unknown }; route_geometry?: unknown };
|
||||
const line = lineOf(body.tour?.route_geometry ?? body.route_geometry);
|
||||
if (line.length >= 2) return line;
|
||||
}
|
||||
const res = await api.get(`/api/trips/${tripId}/places`);
|
||||
const body = (await res.json()) as
|
||||
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
|
||||
const place = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === ride.place_id);
|
||||
const line = lineOf(place?.route_geometry);
|
||||
if (line.length < 2) throw new Error(`"${TOUR_NAMES.horse}" has no line to cut horse routes from`);
|
||||
return line;
|
||||
}
|
||||
|
||||
function bboxAround(line: LatLng[], pad: number): string {
|
||||
const lats = line.map((p) => p[0]);
|
||||
const lngs = line.map((p) => p[1]);
|
||||
return [Math.min(...lats) - pad, Math.min(...lngs) - pad, Math.max(...lats) + pad, Math.max(...lngs) + pad]
|
||||
.map((v) => v.toFixed(5))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/** Where a section starts when the dialog offers it to a ride ending at `end` (`preferReversed`). */
|
||||
function offeredStart(section: HorseSection, end: LatLng | null): LatLng {
|
||||
const first = section.geometry[0];
|
||||
const last = section.geometry[section.geometry.length - 1];
|
||||
if (!end) return section.signposted === 'against' ? last : first;
|
||||
return metres(end, last) < metres(end, first) ? last : first;
|
||||
}
|
||||
function offeredEnd(section: HorseSection, end: LatLng | null): LatLng {
|
||||
const start = offeredStart(section, end);
|
||||
const first = section.geometry[0];
|
||||
return start === first ? section.geometry[section.geometry.length - 1] : first;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two picks the guides need from an answer, or null where it cannot give
|
||||
* them: a first section of at least 600 m on an empty ride, and another
|
||||
* section whose nearer end lies between 100 m and 3 km from where that one
|
||||
* ends, so the dialog names a gap that is worth bridging by hand.
|
||||
*/
|
||||
function picksOf(answer: HorseAnswer): { first: HorsePick; gap: HorsePick } | null {
|
||||
const all = answer.routes.flatMap((route) => route.sections.map((section) => ({ route, section })));
|
||||
const first = all.filter((x) => x.section.length_m >= 600).sort((a, b) => b.section.length_m - a.section.length_m)[0];
|
||||
if (!first) return null;
|
||||
const end = offeredEnd(first.section, null);
|
||||
let best: { pick: HorsePick; gap: number } | null = null;
|
||||
for (const { route, section } of all) {
|
||||
if (route.id === first.route.id && section.number === first.section.number) continue;
|
||||
if (section.length_m < 300) continue;
|
||||
const gap = metres(end, offeredStart(section, end));
|
||||
if (gap > 100 && gap < 3000 && (!best || gap < best.gap))
|
||||
best = { pick: { routeId: route.id, section: section.number }, gap };
|
||||
}
|
||||
if (!best) return null;
|
||||
return { first: { routeId: first.route.id, section: first.section.number }, gap: best.pick };
|
||||
}
|
||||
|
||||
/** Two routes along the saved ride's own line: two sections end to end, and one a short gap further on. */
|
||||
function demoAnswer(line: LatLng[]): HorseAnswer {
|
||||
const [lat, lng] = line[Math.floor(line.length / 2)];
|
||||
const osmUrl = `https://www.openstreetmap.org/#map=14/${lat.toFixed(4)}/${lng.toFixed(4)}`;
|
||||
const section = (
|
||||
number: number,
|
||||
from: number,
|
||||
to: number,
|
||||
access: [string, number][],
|
||||
signposted: HorseSection['signposted']
|
||||
): HorseSection => {
|
||||
const geometry = cut(line, from, to);
|
||||
const length = lengthOf(geometry);
|
||||
return {
|
||||
number,
|
||||
geometry,
|
||||
length_m: Math.round(length),
|
||||
access: access.map(([value, share]) => ({ value, length_m: Math.round(length * share) })),
|
||||
signposted,
|
||||
};
|
||||
};
|
||||
return {
|
||||
routes: [
|
||||
{
|
||||
id: 'relation/1',
|
||||
name: 'Seiser Alm bridle path',
|
||||
ref: null,
|
||||
osmUrl,
|
||||
continues: true,
|
||||
sections: [
|
||||
section(
|
||||
1,
|
||||
0,
|
||||
0.38,
|
||||
[
|
||||
['designated', 0.8],
|
||||
['unknown', 0.2],
|
||||
],
|
||||
'with'
|
||||
),
|
||||
section(2, 0.38, 0.62, [['yes', 1]], null),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'relation/2',
|
||||
name: 'Saltria horse trail',
|
||||
ref: null,
|
||||
osmUrl,
|
||||
continues: false,
|
||||
sections: [
|
||||
section(
|
||||
1,
|
||||
0.72,
|
||||
1,
|
||||
[
|
||||
['permissive', 0.6],
|
||||
['unknown', 0.4],
|
||||
],
|
||||
null
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
source: 'openstreetmap',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
};
|
||||
}
|
||||
|
||||
/** The horse routes every guide of the screen gets, worked out once per run. */
|
||||
export async function horsePlan(api: APIRequestContext): Promise<HorsePlan> {
|
||||
const tripId = await tourTripId(api);
|
||||
const line = await savedRideLine(api, tripId);
|
||||
try {
|
||||
const res = await api.get(`/api/trips/${tripId}/tours/horse-routes?bbox=${bboxAround(line, 0.02)}`, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
if (res.ok()) {
|
||||
const answer = (await res.json()) as HorseAnswer;
|
||||
const picks = answer.routes?.length ? picksOf(answer) : null;
|
||||
if (picks) return { answer, ...picks, source: 'live' };
|
||||
}
|
||||
} catch {
|
||||
// Neither TREK Places nor Overpass answered: the demo routes below.
|
||||
}
|
||||
const answer = demoAnswer(line);
|
||||
const picks = picksOf(answer);
|
||||
if (!picks) throw new Error(`"${TOUR_NAMES.horse}" is too short to cut two horse routes from`);
|
||||
console.warn(
|
||||
'[trip-tour-horse] no usable horse routes on the Seiser Alm; the card is given two cut from the saved ride'
|
||||
);
|
||||
return { answer, ...picks, source: 'demo' };
|
||||
}
|
||||
|
||||
/** Hand the card this answer whatever view it asks for, so the list is the same in every picture. */
|
||||
export async function serveHorseRoutes(page: Page, plan: HorsePlan): Promise<void> {
|
||||
// On the context, not the page: in dev the service worker makes this GET itself,
|
||||
// and only the context's routes see what a service worker asks for.
|
||||
await page.context().route(/\/api\/trips\/\d+\/tours\/horse-routes/, (route) => route.fulfill({ json: plan.answer }));
|
||||
}
|
||||
|
||||
/** A route's title as the card and the dialog print it. */
|
||||
export function routeTitle(plan: HorsePlan, routeId: string): string {
|
||||
const route = plan.answer.routes.find((r) => r.id === routeId);
|
||||
return route?.name ?? route?.ref ?? 'Unnamed horse route';
|
||||
}
|
||||
|
||||
// ── The recorded ride ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A ride recorded on a GPS device, as a file to import: the recorded Rasciesa
|
||||
* track of the fixtures, named as a ride. Its points, heights and times stay
|
||||
* those of the recording.
|
||||
*/
|
||||
export async function recordedRideFile(): Promise<string> {
|
||||
const source = await tourGpxFixture('rasciesa');
|
||||
const file = path.join(path.dirname(source), RECORDED_RIDE.file);
|
||||
const gpx = readFileSync(source, 'utf8').split(TOUR_GPX.rasciesa.name).join(RECORDED_RIDE.name);
|
||||
writeFileSync(file, gpx);
|
||||
return file;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { TOUR_GPX, TOUR_NAMES, tourGpxFixture } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the "getting tours in" guides (`trip-tour-import.guide.ts`) need on top
|
||||
* of `ensureToursFixtures`: a few API helpers to put the Dolomites tours back
|
||||
* after a guide imported, planned over or took one over, and the wanderer
|
||||
* instance the trail import is pictured against.
|
||||
*
|
||||
* wanderer is a service outside TREK, set up like the ones in `external.ts`:
|
||||
* its address and an API token come from `e2e/help/media.env`
|
||||
* (HELP_MEDIA_WANDERER_URL, HELP_MEDIA_WANDERER_TOKEN, and
|
||||
* HELP_MEDIA_WANDERER_INSECURE=true for a self-signed certificate). The
|
||||
* fixture uploads its own trails into that account, so the picture does not
|
||||
* depend on what happens to be in it. Without the variables only the wanderer
|
||||
* guide fails, loudly, at its start. The address has to be the machine's LAN
|
||||
* address and the media backend needs ALLOW_INTERNAL_NETWORK=true, as for the
|
||||
* other services (README, "The guides that show another service").
|
||||
*/
|
||||
|
||||
// ── TREK ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TourRowLite {
|
||||
place_id: number;
|
||||
name: string;
|
||||
has_waypoints?: boolean;
|
||||
has_recorded_track?: boolean;
|
||||
}
|
||||
|
||||
async function ok<T>(
|
||||
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
|
||||
what: string
|
||||
): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 400)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number> {
|
||||
const { trips } = await ok<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
const trip = trips.find((t) => t.title === title);
|
||||
if (!trip) throw new Error(`no trip "${title}": call ensureToursFixtures in beforeAll`);
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
export async function toursOfTrip(api: APIRequestContext, tripId: number): Promise<TourRowLite[]> {
|
||||
return (await ok<{ tours: TourRowLite[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
|
||||
.tours;
|
||||
}
|
||||
|
||||
export async function tourNamed(
|
||||
api: APIRequestContext,
|
||||
tripId: number,
|
||||
name: string
|
||||
): Promise<TourRowLite | undefined> {
|
||||
return (await toursOfTrip(api, tripId)).find((t) => t.name === name);
|
||||
}
|
||||
|
||||
/** Deletes every tour of the trip with one of these names; a tour is a place, so it goes the way of a place. */
|
||||
export async function deleteToursNamed(api: APIRequestContext, tripId: number, ...names: string[]): Promise<void> {
|
||||
for (const tour of await toursOfTrip(api, tripId)) {
|
||||
if (!names.includes(tour.name)) continue;
|
||||
const res = await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
if (!res.ok()) console.warn(`[tour-import] could not delete "${tour.name}": ${res.status()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The editor's draft is kept on the server per trip and user; a guide that left one behind would restore it into the next. */
|
||||
export async function forgetTourDraft(api: APIRequestContext, tripId: number): Promise<void> {
|
||||
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
|
||||
if (!res.ok() && res.status() !== 404)
|
||||
console.warn(`[tour-import] could not drop the tour draft of trip ${tripId}: ${res.status()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an import that was planned over in place back to its recorded track,
|
||||
* which is what the seed has. Nothing to do for a tour that is still as
|
||||
* imported.
|
||||
*/
|
||||
export async function restoreRecordedTrack(api: APIRequestContext, tripId: number, name: string): Promise<void> {
|
||||
const tour = await tourNamed(api, tripId, name);
|
||||
if (!tour?.has_recorded_track) return;
|
||||
const res = await api.post(`/api/trips/${tripId}/tours/${tour.place_id}/restore-recorded`);
|
||||
if (!res.ok())
|
||||
throw new Error(`could not restore the recorded track of "${name}": ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// ── wanderer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function env(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value)
|
||||
throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`);
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The trails the fixture keeps in the wanderer account, by name, each drawn
|
||||
* from a recorded walk of `tour-fixtures.ts` near the Dolomites trip. The
|
||||
* guide imports the first; the second is there so the list is a list.
|
||||
*/
|
||||
export const WANDERER_TRAILS = [
|
||||
{ name: TOUR_GPX.rasciesa.name, gpx: 'rasciesa' as const },
|
||||
{ name: 'Seceda ridge from Col Raiser', gpx: 'seceda' as const },
|
||||
];
|
||||
/** The trail the guide imports into the Dolomites trip. */
|
||||
export const WANDERER_TRAIL = WANDERER_TRAILS[0].name;
|
||||
|
||||
interface WandererHit {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function wandererSearch(base: string, token: string, q: string): Promise<WandererHit[]> {
|
||||
const res = await fetch(`${base}/api/v1/search/trails`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q, options: { limit: 20 } }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`wanderer search: HTTP ${res.status} ${await res.text()}`);
|
||||
return ((await res.json()) as { hits?: WandererHit[] }).hits ?? [];
|
||||
}
|
||||
|
||||
/** The GPX under the trail's own name: wanderer names an uploaded trail after its track. */
|
||||
function renamed(file: string, name: string): string {
|
||||
return readFileSync(file, 'utf8').replace(/<name>[^<]*<\/name>/g, `<name>${name}</name>`);
|
||||
}
|
||||
|
||||
async function upload(base: string, token: string, name: string, gpx: string): Promise<void> {
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
// wanderer's documented upload takes the file as the request body.
|
||||
const raw = await fetch(`${base}/api/v1/trail/upload`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/gpx+xml' },
|
||||
body: gpx,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (raw.ok) return;
|
||||
// Older instances read it as a form field instead.
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([gpx], { type: 'application/gpx+xml' }), `${name}.gpx`);
|
||||
const multipart = await fetch(`${base}/api/v1/trail/upload`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!multipart.ok)
|
||||
throw new Error(`wanderer upload of "${name}": HTTP ${raw.status} / ${multipart.status} ${await multipart.text()}`);
|
||||
}
|
||||
|
||||
/** Puts the fixture's trails into the account the token belongs to, once, and waits until the search finds them. */
|
||||
export async function ensureWandererTrails(): Promise<void> {
|
||||
const base = env('HELP_MEDIA_WANDERER_URL');
|
||||
const token = env('HELP_MEDIA_WANDERER_TOKEN');
|
||||
for (const trail of WANDERER_TRAILS) {
|
||||
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) continue;
|
||||
await upload(base, token, trail.name, renamed(await tourGpxFixture(trail.gpx), trail.name));
|
||||
// The search is an index the upload reaches a moment later.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if ((await wandererSearch(base, token, trail.name)).some((h) => h.name === trail.name)) break;
|
||||
if (i === 29) throw new Error(`wanderer never listed the uploaded trail "${trail.name}"`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin switch under Tours, then the signed-in user's connection, tested loudly. */
|
||||
export async function ensureWandererConnection(api: APIRequestContext): Promise<void> {
|
||||
await ensureWandererTrails();
|
||||
await ok(await api.put('/api/admin/tours-wanderer', { data: { enabled: true } }), 'switch wanderer on');
|
||||
await ok(
|
||||
await api.put('/api/integrations/wanderer/settings', {
|
||||
data: {
|
||||
url: env('HELP_MEDIA_WANDERER_URL'),
|
||||
token: env('HELP_MEDIA_WANDERER_TOKEN'),
|
||||
allowInsecureTls: process.env.HELP_MEDIA_WANDERER_INSECURE === 'true',
|
||||
},
|
||||
}),
|
||||
'save the wanderer connection'
|
||||
);
|
||||
const test = await ok<{ ok: boolean; code?: string }>(
|
||||
await api.post('/api/integrations/wanderer/test', { data: {} }),
|
||||
'test the wanderer connection'
|
||||
);
|
||||
if (!test.ok)
|
||||
throw new Error(
|
||||
`wanderer is not reachable from the media backend: ${test.code ?? 'unknown'} (is ALLOW_INTERNAL_NETWORK=true?)`
|
||||
);
|
||||
}
|
||||
|
||||
/** Takes the connection away and switches wanderer off again, as every other screen expects it. */
|
||||
export async function disconnectWanderer(api: APIRequestContext): Promise<void> {
|
||||
await api.delete('/api/integrations/wanderer/settings');
|
||||
await api.put('/api/admin/tours-wanderer', { data: { enabled: false } });
|
||||
}
|
||||
|
||||
/** The recorded walk the import guides pick in the file chooser. */
|
||||
export const importFile = async (): Promise<string> => path.resolve(await tourGpxFixture('rasciesa'));
|
||||
export const IMPORTED_NAME = TOUR_GPX.rasciesa.name;
|
||||
export const RECORDED_NAME = TOUR_NAMES.recorded;
|
||||
@@ -1,154 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
import { settle } from './guide';
|
||||
import { openTourTrip, TOUR_NAMES } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the Tours-tab map guides share: the locators of the layer pill, the
|
||||
* 3D dock and the card that searches along a tour, and the way into the
|
||||
* planner with one tour open. Nothing here writes to the server: every guide
|
||||
* on this screen only looks, or changes the open draft without saving it,
|
||||
* and a draft lives in the browser's storage, which every test starts empty.
|
||||
*/
|
||||
|
||||
/** The tour by its role, for the guides: the hike over the via ferrata is the default, the hut trek has named stops. */
|
||||
export const MAP_TOUR = TOUR_NAMES.ferrata;
|
||||
export const ALONG_TOUR = TOUR_NAMES.hutToHut;
|
||||
export const PADDLE_TOUR = TOUR_NAMES.paddle;
|
||||
/** The ski week's tour with pistes around it: the Sella Ronda where the piste router planned it, else the winter walk. */
|
||||
export const SKI_TOURS = [TOUR_NAMES.skiRonda, TOUR_NAMES.winterWalk];
|
||||
|
||||
/** The right rail of the Tours tab, and one tour's row in it. */
|
||||
export const tripTours = (page: Page) => page.getByRole('listbox', { name: 'Tours of this trip' });
|
||||
export const tourRow = (page: Page, name: string) =>
|
||||
tripTours(page).getByRole('option').filter({ hasText: name }).first();
|
||||
|
||||
/**
|
||||
* The layer pill at the bottom left of the map (`TourMapLayerSwitcher`): a
|
||||
* group named after the setting it mirrors, "Map Style". The flat map and the
|
||||
* 3D map each render exactly one.
|
||||
*/
|
||||
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
|
||||
/** A base map's segment; its name is its visible word on the flat map, its aria-label in the compact 3D pill. */
|
||||
export const baseSegment = (page: Page, label: 'Map' | 'Topo' | 'Hiking' | 'Cycling' | 'Satellite') =>
|
||||
layerPill(page).getByRole('button', { name: label, exact: true });
|
||||
export const terrainToggle = (page: Page) => layerPill(page).getByRole('button', { name: '3D', exact: true });
|
||||
export const pistesToggle = (page: Page) => page.getByTestId('map-overlay-pistes');
|
||||
export const seamarksToggle = (page: Page) => page.getByTestId('map-overlay-seamarks');
|
||||
export const pisteKeyButton = (page: Page) => page.getByTestId('piste-key-button');
|
||||
/** The key's popover, a portal group named like its button. */
|
||||
export const pisteKeyPanel = (page: Page) => page.getByRole('group', { name: 'Piste key' });
|
||||
|
||||
/** The 3D map's own canvas (MapLibre), and the controls in its bottom row. */
|
||||
export const terrainCanvas = (page: Page) => page.locator('.maplibregl-canvas');
|
||||
export const playButton = (page: Page) => page.getByRole('button', { name: 'Play flyover', exact: true });
|
||||
export const pauseButton = (page: Page) => page.getByRole('button', { name: 'Pause', exact: true });
|
||||
export const restartButton = (page: Page) => page.getByRole('button', { name: 'Start over', exact: true });
|
||||
export const overviewButton = (page: Page) => page.getByRole('button', { name: 'Overview', exact: true });
|
||||
export const speedGroup = (page: Page) => page.getByRole('group', { name: 'Speed' });
|
||||
export const reliefSlider = (page: Page) => page.getByRole('slider', { name: 'Relief' });
|
||||
export const gradeSwitch = (page: Page) => page.getByRole('button', { name: 'Grade', exact: true });
|
||||
export const gradeMix = (page: Page) => page.getByRole('button', { name: 'Grade mix', exact: true });
|
||||
/** The unfolded bands above the grade mix pill (`id="tour-terrain-grades"`). */
|
||||
export const gradeBands = (page: Page) => page.locator('#tour-terrain-grades');
|
||||
|
||||
/** "Along the tour": a RailCard, a section named by its title. */
|
||||
export const alongCard = (page: Page) => page.getByRole('region', { name: 'Along the tour' });
|
||||
export const alongCategories = (page: Page) => page.getByTestId('tour-poi-categories');
|
||||
/** The open category list, a portal group named "What to look for". */
|
||||
export const alongCategoryList = (page: Page) => page.getByRole('group', { name: 'What to look for' });
|
||||
export const alongCorridor = (page: Page) =>
|
||||
alongCard(page).getByRole('group', { name: 'Within this distance of the route' });
|
||||
export const alongSearch = (page: Page) => page.getByTestId('tour-poi-search');
|
||||
export const alongList = (page: Page) => page.getByTestId('tour-poi-list');
|
||||
/** A place found that is not a stop of the tour yet: its row carries a distance, not the "In the tour" pill. */
|
||||
export const alongCandidates = (page: Page) =>
|
||||
alongList(page)
|
||||
.locator('li')
|
||||
.filter({ hasNot: page.getByText('In the tour', { exact: true }) });
|
||||
export const poiCard = (page: Page) => page.getByTestId('tour-poi-card-map');
|
||||
export const poiSlot = (page: Page) => page.getByTestId('tour-poi-slot');
|
||||
export const poiAdd = (page: Page) => page.getByTestId('tour-poi-add');
|
||||
export const poiOutcome = (page: Page) => page.getByTestId('tour-poi-outcome');
|
||||
export const poiUndo = (page: Page) => page.getByTestId('tour-poi-undo');
|
||||
|
||||
/**
|
||||
* Open the Tours tab of the Dolomites (or the ski week) and open `name` in the
|
||||
* planner: its row is selected and the layer pill shows the maps of its type.
|
||||
* A multi-day tour opens whole, as a click on its row does.
|
||||
*/
|
||||
export async function openTourInPlanner(page: Page, name: string, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
|
||||
await openTourTrip(page, { trip, tab: 'tour-planner' });
|
||||
const row = tourRow(page, name);
|
||||
await expect(row, `no tour "${name}" in the Tours tab: did ensureToursFixtures make it?`).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 });
|
||||
await expect(layerPill(page)).toBeVisible();
|
||||
// The saved route is drawn and framed once the planner has it; the summary card says so.
|
||||
await expect(page.getByTestId('tour-planner-overview')).toBeVisible({ timeout: 60_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The first of the ski week's tours that is in the trip. */
|
||||
export async function openSkiTour(page: Page): Promise<string> {
|
||||
await openTourTrip(page, { trip: 'ski', tab: 'tour-planner' });
|
||||
for (const name of SKI_TOURS) {
|
||||
if (await tourRow(page, name).count()) {
|
||||
await openTourInPlanner(page, name, 'ski');
|
||||
return name;
|
||||
}
|
||||
}
|
||||
throw new Error(`none of ${SKI_TOURS.join(', ')} is in the ski week: did ensureToursFixtures make them?`);
|
||||
}
|
||||
|
||||
/** Switch the open tour to 3D and wait until the landscape is drawn and the dock is there. */
|
||||
export async function enterTerrain(page: Page): Promise<void> {
|
||||
await terrainToggle(page).click();
|
||||
await expect(terrainCanvas(page)).toBeVisible({ timeout: 60_000 });
|
||||
await expect(overviewButton(page)).toBeVisible({ timeout: 60_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Wait until the corridor has loaded and Play flyover replaces the ring. */
|
||||
export async function waitForFlyover(page: Page): Promise<void> {
|
||||
await expect(playButton(page).or(pauseButton(page))).toBeVisible({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
/** Ask "Along the tour" with what it starts with (the first three kinds, 500 m) and wait for the list. */
|
||||
export async function searchAlong(page: Page): Promise<void> {
|
||||
await alongCard(page).scrollIntoViewIfNeeded();
|
||||
await alongSearch(page).click();
|
||||
await expect(alongSearch(page)).toHaveText(/Search again/, { timeout: 90_000 });
|
||||
await expect(alongList(page), 'the search along the tour found nothing (Overpass or the places index)').toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The first place found whose card can take it in as a stop: one that is not
|
||||
* in the tour already and whose suggested slot is not on a fixed stretch.
|
||||
* Tries them in their card, closes the card again and returns the row's index
|
||||
* among `alongCandidates`, for `candidateRow`.
|
||||
*/
|
||||
export async function pickAddablePlace(page: Page): Promise<number> {
|
||||
const rows = alongCandidates(page);
|
||||
const count = Math.min(await rows.count(), 8);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await candidateRow(page, i).click();
|
||||
await expect(poiCard(page)).toBeVisible();
|
||||
const addable = await poiAdd(page).isEnabled();
|
||||
await closeOverlay(page);
|
||||
await expect(poiCard(page)).toBeHidden();
|
||||
if (addable) return i;
|
||||
}
|
||||
throw new Error('no place along the tour can be added as a stop');
|
||||
}
|
||||
|
||||
/** The button of the `index`-th place found that is not in the tour yet. */
|
||||
export const candidateRow = (page: Page, index: number): Locator =>
|
||||
alongCandidates(page).nth(index).getByRole('button').first();
|
||||
|
||||
/** Close whatever popover or card is open over the map. */
|
||||
export async function closeOverlay(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { day } from '../dates';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
|
||||
import { TOUR_NAMES, TOUR_TRIP, TOUR_TRIP_DATES } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the paddle tour pictures need beyond `ensureToursFixtures`.
|
||||
*
|
||||
* The boat tour "Rowing round Lago di Braies" on day 9 of "Summer in the
|
||||
* Dolomites" is the one every paddle picture shows. Its four legs are drawn by
|
||||
* hand along the shore, because a fresh database holds no water data, and the
|
||||
* pictures keep it that way: TREK fetches a region's water data by itself
|
||||
* when a leg cannot be planned, and an admin planning sets that download
|
||||
* going at once. `holdPaddleDownloads` switches that off for the run and
|
||||
* `restorePaddleDownloads` puts the setting back.
|
||||
*
|
||||
* Three guides need a little more, and each makes it in its `start` and takes
|
||||
* it away in its `cleanup` (and `afterAll` again, for a run that failed):
|
||||
*
|
||||
* - the wind forecast needs a day within the next two weeks, which the
|
||||
* summer trip does not have, so `ensureWindTrip` makes a short trip from
|
||||
* tomorrow with the boat tour taken over onto its first day;
|
||||
* - the checklist lists what is saved, so `ensureCarryCopy` makes a copy of
|
||||
* the boat tour for that guide to save a portage and a lock on;
|
||||
* - the recorded track is a GPX file, written by `paddleGpxFixture` from the
|
||||
* boat tour's own line, with the scatter and the times a watch records.
|
||||
*/
|
||||
|
||||
export const PADDLE_TOUR = TOUR_NAMES.paddle;
|
||||
/** The trip the wind guide makes and deletes again: two days from tomorrow. */
|
||||
export const WIND_TRIP = 'Weekend at Lago di Braies';
|
||||
/** The copy the checklist guide saves a portage and a lock on. */
|
||||
export const CARRY_COPY = 'Lago di Braies with a carry';
|
||||
/** The recorded track the import guide brings in, by its file and the name inside it. */
|
||||
export const PADDLE_GPX = { name: 'Lago di Braies kayak round', file: 'lago-di-braies-kayak-round.gpx' } as const;
|
||||
|
||||
async function json<T>(res: Awaited<ReturnType<APIRequestContext['get']>>, what: string): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 200)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
interface TourRow {
|
||||
place_id: number;
|
||||
name: string;
|
||||
tour_type?: string;
|
||||
}
|
||||
|
||||
export async function tripIdByTitle(api: APIRequestContext, title: string): Promise<number | null> {
|
||||
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
return trips.find((t) => t.title === title)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function summerTripId(api: APIRequestContext): Promise<number> {
|
||||
const id = await tripIdByTitle(api, TOUR_TRIP);
|
||||
if (id === null) throw new Error(`no trip "${TOUR_TRIP}": call ensureToursFixtures in beforeAll`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function toursOf(api: APIRequestContext, tripId: number): Promise<TourRow[]> {
|
||||
return (await json<{ tours: TourRow[] }>(await api.get(`/api/trips/${tripId}/tours`), `tours of trip ${tripId}`))
|
||||
.tours;
|
||||
}
|
||||
|
||||
/** The boat tour's place id in the summer trip. */
|
||||
export async function paddleTourId(api: APIRequestContext): Promise<number> {
|
||||
const tripId = await summerTripId(api);
|
||||
const tour = (await toursOf(api, tripId)).find((t) => t.name === PADDLE_TOUR);
|
||||
if (!tour)
|
||||
throw new Error(
|
||||
`no tour "${PADDLE_TOUR}": the Overpass shore of Lago di Braies was not there when the fixtures ran`
|
||||
);
|
||||
return tour.place_id;
|
||||
}
|
||||
|
||||
/** Deletes the tours of a trip the test picks; a tour is a place, and goes the way a place goes. */
|
||||
export async function deleteToursWhere(
|
||||
api: APIRequestContext,
|
||||
tripId: number,
|
||||
pick: (tour: TourRow) => boolean
|
||||
): Promise<void> {
|
||||
for (const tour of (await toursOf(api, tripId)).filter(pick)) {
|
||||
await api.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The editor's draft ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The planner keeps an unsaved edit on the account per trip and restores it on
|
||||
* the next page load. A paddle guide that stops half way (its cleanup does not
|
||||
* run then) would leave its carry and lock in that draft, and the next guide
|
||||
* would open on it: the editor instead of the neutral rail, and a click on a
|
||||
* tour row asking Discard unsaved tour changes. A trip without a draft answers
|
||||
* 404, which is what is wanted.
|
||||
*/
|
||||
export async function forgetPaddleDraft(api: APIRequestContext): Promise<void> {
|
||||
const tripId = await summerTripId(api);
|
||||
const res = await api.delete(`/api/trips/${tripId}/tour-draft`);
|
||||
if (!res.ok() && res.status() !== 404)
|
||||
console.warn(`[trip-tour-paddle] could not drop the tour draft: ${res.status()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same before a guide opens its page, and the copy older builds kept in the
|
||||
* browser as `tour-draft-<tripId>`, dropped before the app reads it.
|
||||
*/
|
||||
export async function forgetPaddleDraftBeforeLoad(page: Page): Promise<void> {
|
||||
await forgetPaddleDraft(page.request);
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
for (const key of Object.keys(localStorage)) if (key.startsWith('tour-draft-')) localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage blocked: there is no draft to restore either.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── The download switch ───────────────────────────────────────────────────────
|
||||
|
||||
type AutoLoad = { mode: 'auto' | 'ask' | 'off'; max_mb: number };
|
||||
let heldFrom: AutoLoad | null = null;
|
||||
|
||||
/**
|
||||
* No water data downloads while the paddle pictures run. Without this, the
|
||||
* first leg the planner cannot plan makes the server fetch the smallest
|
||||
* Geofabrik extract around Lago di Braies (all of north-east Italy) and build
|
||||
* a water network from it, for minutes, because the picture user is an admin.
|
||||
*/
|
||||
export async function holdPaddleDownloads(api: APIRequestContext): Promise<void> {
|
||||
const current = await json<AutoLoad>(await api.get('/api/admin/paddle-regions/settings'), 'paddle region settings');
|
||||
if (heldFrom === null) heldFrom = current;
|
||||
if (current.mode !== 'off') {
|
||||
await json(
|
||||
await api.put('/api/admin/paddle-regions/settings', { data: { mode: 'off', max_mb: current.max_mb } }),
|
||||
'hold paddle downloads'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function restorePaddleDownloads(api: APIRequestContext): Promise<void> {
|
||||
if (heldFrom === null) return;
|
||||
await api.put('/api/admin/paddle-regions/settings', { data: heldFrom });
|
||||
heldFrom = null;
|
||||
}
|
||||
|
||||
// ── The wind trip ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteTripsNamed(api: APIRequestContext, title: string): Promise<void> {
|
||||
const { trips = [] } = await json<{ trips?: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
for (const trip of trips.filter((t) => t.title === title)) await api.delete(`/api/trips/${trip.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-day trip from tomorrow with the boat tour taken over onto its first
|
||||
* day at 10:00, so its Wind card has a forecast to show. Made fresh every time.
|
||||
*/
|
||||
export async function ensureWindTrip(
|
||||
api: APIRequestContext
|
||||
): Promise<{ tripId: number; placeId: number; name: string }> {
|
||||
await deleteTripsNamed(api, WIND_TRIP);
|
||||
const sourceTrip = await summerTripId(api);
|
||||
const source = await paddleTourId(api);
|
||||
const { trip } = await json<{ trip: { id: number } }>(
|
||||
await api.post('/api/trips', {
|
||||
data: {
|
||||
title: WIND_TRIP,
|
||||
description: 'Two quiet days at the lake, out on the water if the wind allows.',
|
||||
start_date: day(1),
|
||||
end_date: day(2),
|
||||
currency: 'EUR',
|
||||
},
|
||||
}),
|
||||
`create "${WIND_TRIP}"`
|
||||
);
|
||||
const taken = await json<{ tour: { place_id: number } }>(
|
||||
await api.post(`/api/trips/${trip.id}/tours/take-over`, {
|
||||
data: { source_trip_id: sourceTrip, source_place_id: source },
|
||||
}),
|
||||
'take the boat tour over'
|
||||
);
|
||||
const { days } = await json<{ days: { id: number; date?: string | null }[] }>(
|
||||
await api.get(`/api/trips/${trip.id}/days`),
|
||||
'days'
|
||||
);
|
||||
const first = [...days].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '') || a.id - b.id)[0];
|
||||
if (!first) throw new Error(`"${WIND_TRIP}" has no days`);
|
||||
await json(
|
||||
await api.post(`/api/trips/${trip.id}/days/${first.id}/assignments`, { data: { place_id: taken.tour.place_id } }),
|
||||
'put the boat tour on day 1'
|
||||
);
|
||||
const { assignments = [] } = await json<{ assignments?: { id: number; place_id: number }[] }>(
|
||||
await api.get(`/api/trips/${trip.id}/days/${first.id}/assignments`),
|
||||
'assignments'
|
||||
);
|
||||
const row = assignments.find((a) => a.place_id === taken.tour.place_id);
|
||||
if (row) await api.put(`/api/trips/${trip.id}/assignments/${row.id}/time`, { data: { place_time: '10:00' } });
|
||||
const name = (await toursOf(api, trip.id)).find((t) => t.place_id === taken.tour.place_id)?.name ?? PADDLE_TOUR;
|
||||
return { tripId: trip.id, placeId: taken.tour.place_id, name };
|
||||
}
|
||||
|
||||
// ── The copy for the checklist ────────────────────────────────────────────────
|
||||
|
||||
/** A fresh copy of the boat tour in the summer trip, on no day, for a guide to save changes on. */
|
||||
export async function ensureCarryCopy(api: APIRequestContext): Promise<number> {
|
||||
const tripId = await summerTripId(api);
|
||||
await deleteToursWhere(api, tripId, (t) => t.name === CARRY_COPY);
|
||||
const source = await paddleTourId(api);
|
||||
const copy = await json<{ tour: { place_id: number } }>(
|
||||
await api.post(`/api/trips/${tripId}/tours/${source}/copy`, {
|
||||
data: { name: CARRY_COPY },
|
||||
}),
|
||||
'copy the boat tour'
|
||||
);
|
||||
return copy.tour.place_id;
|
||||
}
|
||||
|
||||
// ── The recorded track ────────────────────────────────────────────────────────
|
||||
|
||||
type Point = [number, number];
|
||||
|
||||
function metres(a: Point, b: Point): number {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (b[0] - a[0]) * rad;
|
||||
const dLng = (b[1] - a[1]) * rad;
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
|
||||
return 6_371_000 * 2 * Math.asin(Math.sqrt(s));
|
||||
}
|
||||
|
||||
function seeded(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function addDays(iso: string, n: number): string {
|
||||
const d = new Date(`${iso}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The points of a GPX file, track points or route points, whichever order its attributes come in. */
|
||||
function gpxPoints(gpx: string): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (const match of gpx.matchAll(/<(?:trkpt|rtept)\b([^>]*)>/g)) {
|
||||
const lat = /\blat="([-\d.]+)"/.exec(match[1]);
|
||||
const lon = /\blon="([-\d.]+)"/.exec(match[1]);
|
||||
if (lat && lon) points.push([Number(lat[1]), Number(lon[1])]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kayak round of Lago di Braies as a watch records it, made from the boat
|
||||
* tour's own line (its GPX export): a point every 10 m or so, a few metres of
|
||||
* GPS scatter, 5.5 km/h on the water with a twenty minute stop on the far
|
||||
* shore, on a morning last summer. Written to `e2e/.tmp/` and returned as a path.
|
||||
*/
|
||||
export async function paddleGpxFixture(api: APIRequestContext): Promise<string> {
|
||||
const tripId = await summerTripId(api);
|
||||
const placeId = await paddleTourId(api);
|
||||
const res = await api.get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`);
|
||||
if (!res.ok()) throw new Error(`export the boat tour: ${res.status()}`);
|
||||
const line = gpxPoints(await res.text());
|
||||
if (line.length < 8) throw new Error('the boat tour exported no line to record a track from');
|
||||
const dense: Point[] = [line[0]];
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const a = line[i - 1];
|
||||
const b = line[i];
|
||||
const n = Math.max(1, Math.round(metres(a, b) / 10));
|
||||
for (let k = 1; k <= n; k++) dense.push([a[0] + ((b[0] - a[0]) * k) / n, a[1] + ((b[1] - a[1]) * k) / n]);
|
||||
}
|
||||
const rnd = seeded(4242);
|
||||
// 09:40 in the Dolomites in summer is 07:40 UTC.
|
||||
let t = Date.parse(`${addDays(TOUR_TRIP_DATES.start, -358)}T07:40:00Z`);
|
||||
const pause = Math.floor(dense.length * 0.55);
|
||||
const rows = dense.map((p, i) => {
|
||||
if (i > 0) t += (metres(dense[i - 1], p) / 1000 / (5.5 * (0.9 + rnd() * 0.2))) * 3_600_000;
|
||||
if (i === pause) t += 20 * 60_000;
|
||||
const lat = p[0] + (rnd() - 0.5) * 0.00005;
|
||||
const lng = p[1] + (rnd() - 0.5) * 0.00007;
|
||||
const ele = 1494 + (rnd() - 0.5) * 2;
|
||||
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '')}</time></trkpt>`;
|
||||
});
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>${PADDLE_GPX.name}</name></metadata>
|
||||
<trk><name>${PADDLE_GPX.name}</name><type>kayaking</type><trkseg>
|
||||
${rows.join('\n')}
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
`;
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, PADDLE_GPX.file);
|
||||
writeFileSync(file, gpx);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Whether a tour is the recorded track the import guide brings in, whichever name the server gave it. */
|
||||
export const isPaddleImport = (tour: { name: string }): boolean => /kayak[\s-]round/i.test(tour.name);
|
||||
|
||||
// ── On the page ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The Tours tab of a trip by its id, the way `openTourTrip` opens the summer one. */
|
||||
export async function openToursOf(page: Page, tripId: number): Promise<void> {
|
||||
await page.goto(`/trips/${tripId}?tab=tour-planner`);
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole('listbox', { name: 'Tours of this trip' })).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let a pan or a fit of the map land. Leaflet times its animations from
|
||||
* `+new Date()`, and the clock is pinned to the picture day, so they start and
|
||||
* never finish; moving the pinned clock on a few seconds ends them.
|
||||
*/
|
||||
export async function finishMapMove(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400);
|
||||
await nudgeClock(page);
|
||||
await page.waitForTimeout(1200);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot on the drawn tour line that the pointer can really reach, about
|
||||
* `fraction` of the way along it: the longest line in Leaflet's overlay pane,
|
||||
* sampled near that point until the map itself, not something over it, takes
|
||||
* a click there. A box centre would be no use, the lake's middle is water
|
||||
* nobody paddled.
|
||||
*/
|
||||
export async function pointOnLine(page: Page, fraction: number): Promise<{ x: number; y: number }> {
|
||||
const found = await page.evaluate((wanted: number) => {
|
||||
const paths = Array.from(document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path'));
|
||||
let line: SVGPathElement | null = null;
|
||||
let longest = 0;
|
||||
for (const path of paths) {
|
||||
const length = path.getTotalLength();
|
||||
if (length > longest) {
|
||||
longest = length;
|
||||
line = path;
|
||||
}
|
||||
}
|
||||
if (!line || longest < 40) return null;
|
||||
const ctm = line.getScreenCTM();
|
||||
if (!ctm) return null;
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
for (const sign of [1, -1]) {
|
||||
const f = wanted + sign * step * 0.005;
|
||||
if (f <= 0.02 || f >= 0.98) continue;
|
||||
const at = line.getPointAtLength(longest * f);
|
||||
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e);
|
||||
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f);
|
||||
// The marking tool listens on the map's own click and lays the spot onto
|
||||
// the track itself, and the drawn line is not interactive while it is on,
|
||||
// so the pointer lands on whatever map layer lies under the line (the
|
||||
// vector base map's canvas, a tile). That point is reachable as long as
|
||||
// the map gets it, not a panel, a card, a control or a marker that keeps
|
||||
// the click for itself.
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (
|
||||
hit &&
|
||||
hit.closest('.leaflet-container') &&
|
||||
!hit.closest('.leaflet-control-container, .leaflet-marker-pane, .leaflet-popup-pane, .leaflet-tooltip-pane')
|
||||
) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, fraction);
|
||||
if (!found) throw new Error(`no reachable point on the drawn line near ${Math.round(fraction * 100)} %`);
|
||||
return found;
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import { dismissReleaseNotice, nudgeClock, settle } from './guide';
|
||||
import { TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
|
||||
import { tripReady } from './trip-shared';
|
||||
|
||||
/**
|
||||
* What the Tours-tab planner pictures need beyond `ensureToursFixtures`: a
|
||||
* plain day hike to edit, the spots on real paths a new tour is drawn
|
||||
* through, the way into the Tours tab, and the projection from a spot on the
|
||||
* earth to a pixel of the map. Not a `.guide.ts`, so importing it registers
|
||||
* no tests.
|
||||
*
|
||||
* Everything is cut from the line of the Puez-Odle hut trek, which the tour
|
||||
* fixtures routed along real paths: the spare hike is its last stretch, from
|
||||
* the Rifugio Firenze down to the Col Raiser gondola, and the points a guide
|
||||
* clicks onto the map lie on the same line, so the router always finds a way
|
||||
* between them and no picture shows a point it cannot reach.
|
||||
*/
|
||||
|
||||
type Api = APIRequestContext;
|
||||
type LatLng = [number, number];
|
||||
type LatLngEle = [number, number, number];
|
||||
|
||||
/** The tours this screen makes and takes away again. */
|
||||
export const PLANNER_TOURS = {
|
||||
/** A plain four-stop day hike, on no day; the editing guides open it and never keep their changes. */
|
||||
spare: 'Rifugio Firenze to Col Raiser',
|
||||
/** A copy of the spare that the delete guide deletes. */
|
||||
old: 'Rifugio Firenze to Col Raiser (old plan)',
|
||||
/** The tour the save guide draws and saves. */
|
||||
drawn: 'Cisles meadow walk',
|
||||
} as const;
|
||||
|
||||
export const SPARE_DESCRIPTION = 'From the hut across the Cisles meadows and down to the Col Raiser gondola.';
|
||||
/** What the description reads after the "someone else saved it" write of the conflict guide. */
|
||||
export const REMOTE_DESCRIPTION = 'Mira: we take the gondola down, the last stretch is closed this week.';
|
||||
/** The query the drawing guide types into the map search; the hut is where its points lie. */
|
||||
export const SEARCH_QUERY = 'Rifugio Firenze';
|
||||
|
||||
async function body<T>(
|
||||
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
|
||||
what: string
|
||||
): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function tripIdOf(api: Api): Promise<number> {
|
||||
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
const trip = trips.find((t) => t.title === TOUR_TRIP);
|
||||
if (!trip) throw new Error(`no trip "${TOUR_TRIP}": ensureToursFixtures has not run`);
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
interface PlaceRow {
|
||||
id: number;
|
||||
name: string;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
route_geometry?: unknown;
|
||||
}
|
||||
|
||||
async function placesOf(api: Api, tripId: number): Promise<PlaceRow[]> {
|
||||
const res = await body<{ places?: PlaceRow[] } | PlaceRow[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
|
||||
return Array.isArray(res) ? res : (res.places ?? []);
|
||||
}
|
||||
|
||||
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
|
||||
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/tours`),
|
||||
'tours'
|
||||
);
|
||||
return tours.find((t) => t.name === name)?.place_id ?? null;
|
||||
}
|
||||
|
||||
export async function removeTour(api: Api, tripId: number, name: string): Promise<void> {
|
||||
for (;;) {
|
||||
const id = await tourIdOf(api, tripId, name);
|
||||
if (!id) return;
|
||||
const res = await api.delete(`/api/trips/${tripId}/places/${id}`);
|
||||
if (!res.ok()) throw new Error(`delete "${name}": ${res.status()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The line everything is cut from ───────────────────────────────────────────
|
||||
|
||||
function metres(a: readonly number[], b: readonly number[]): number {
|
||||
const rad = Math.PI / 180;
|
||||
const x = (b[1] - a[1]) * rad * Math.cos(((a[0] + b[0]) / 2) * rad);
|
||||
const y = (b[0] - a[0]) * rad;
|
||||
return Math.hypot(x, y) * 6_371_000;
|
||||
}
|
||||
|
||||
/** The hut trek's line, the index where it passes the Rifugio Firenze, and the distance along it at every point. */
|
||||
export interface HutLine {
|
||||
line: LatLngEle[];
|
||||
hut: number;
|
||||
along: number[];
|
||||
}
|
||||
|
||||
export async function hutLine(api: Api, tripId: number): Promise<HutLine> {
|
||||
const places = await placesOf(api, tripId);
|
||||
const trek = places.find((p) => p.name === TOUR_NAMES.hutToHut);
|
||||
const hutPlace = places.find((p) => p.name === TOUR_PLACES.firenze);
|
||||
const raw = trek?.route_geometry;
|
||||
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as LatLngEle[];
|
||||
if (!trek || line.length < 10)
|
||||
throw new Error(`"${TOUR_NAMES.hutToHut}" has no route line: did ensureToursFixtures make it?`);
|
||||
if (hutPlace?.lat == null || hutPlace.lng == null) throw new Error(`no place "${TOUR_PLACES.firenze}" with a spot`);
|
||||
const spot = [hutPlace.lat, hutPlace.lng];
|
||||
let hut = 0;
|
||||
line.forEach((point, i) => {
|
||||
if (metres(point, spot) < metres(line[hut], spot)) hut = i;
|
||||
});
|
||||
const along = [0];
|
||||
for (let i = 1; i < line.length; i++) along.push(along[i - 1] + metres(line[i - 1], line[i]));
|
||||
return { line, hut, along };
|
||||
}
|
||||
|
||||
/** The index of the point `offset` metres along the line from the hut (negative: before it), kept on the line. */
|
||||
function indexAt(geo: HutLine, offset: number): number {
|
||||
const wanted = geo.along[geo.hut] + offset;
|
||||
let best = 0;
|
||||
geo.along.forEach((d, i) => {
|
||||
if (Math.abs(d - wanted) < Math.abs(geo.along[best] - wanted)) best = i;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The spot `offset` metres along the hut trek from the Rifugio Firenze, on its path. */
|
||||
export function spotAt(geo: HutLine, offset: number): LatLng {
|
||||
const p = geo.line[indexAt(geo, offset)];
|
||||
return [p[0], p[1]];
|
||||
}
|
||||
|
||||
/** Where the drawing guide clicks: down the path to the hut and on past it. */
|
||||
export const DRAWN_OFFSETS = [-420, -120, 240, 560];
|
||||
/** Where the shaping guide pulls its point: back up the approach to the hut. */
|
||||
export const SHAPING_OFFSET = -320;
|
||||
|
||||
/** The spare hike: the hut trek's last stretch, cut at four stops, with its own heights. */
|
||||
export function spareBody(geo: HutLine, description = SPARE_DESCRIPTION): Record<string, unknown> {
|
||||
const route = geo.line.slice(geo.hut);
|
||||
const length = geo.along[geo.along.length - 1] - geo.along[geo.hut];
|
||||
const cuts = [0, Math.round(route.length / 3), Math.round((2 * route.length) / 3), route.length - 1];
|
||||
const waypoints = cuts.map((index, i) => ({
|
||||
lat: route[index][0],
|
||||
lng: route[index][1],
|
||||
role: i === 0 ? 'start' : i === cuts.length - 1 ? 'end' : 'via',
|
||||
sequence: i,
|
||||
}));
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
for (let i = 1; i < route.length; i++) {
|
||||
const step = route[i][2] - route[i - 1][2];
|
||||
if (step > 0) gain += step;
|
||||
else loss -= step;
|
||||
}
|
||||
// DIN 33466, as the planner's own estimate reckons a hike.
|
||||
const flat = length / 1000 / 4;
|
||||
const vertical = gain / 300 + loss / 500;
|
||||
const hours = Math.max(flat, vertical) + Math.min(flat, vertical) / 2;
|
||||
return {
|
||||
name: PLANNER_TOURS.spare,
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
route_geometry: route,
|
||||
waypoints,
|
||||
duration_seconds: Math.round(hours * 3600),
|
||||
break_additional_minutes: 30,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The spare hike, made where it is missing. With `reset` an existing one is
|
||||
* written back to how it was made, after a guide saved a change into it.
|
||||
*/
|
||||
export async function ensureSpare(api: Api, opts: { reset?: boolean } = {}): Promise<number> {
|
||||
const tripId = await tripIdOf(api);
|
||||
const geo = await hutLine(api, tripId);
|
||||
const existing = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
|
||||
if (existing) {
|
||||
if (opts.reset)
|
||||
await body(
|
||||
await api.put(`/api/trips/${tripId}/tours/${existing}`, { data: spareBody(geo) }),
|
||||
'reset the spare hike'
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
const created = await body<{ tour: { place_id: number } }>(
|
||||
await api.post(`/api/trips/${tripId}/tours`, { data: spareBody(geo) }),
|
||||
'create the spare hike'
|
||||
);
|
||||
return created.tour.place_id;
|
||||
}
|
||||
|
||||
/** Someone else saves the spare: the same tour with another description, a revision further on. */
|
||||
export async function saveSpareElsewhere(api: Api): Promise<void> {
|
||||
const tripId = await tripIdOf(api);
|
||||
const id = await tourIdOf(api, tripId, PLANNER_TOURS.spare);
|
||||
if (!id) throw new Error('no spare hike to save elsewhere');
|
||||
const geo = await hutLine(api, tripId);
|
||||
await body(
|
||||
await api.put(`/api/trips/${tripId}/tours/${id}`, { data: spareBody(geo, REMOTE_DESCRIPTION) }),
|
||||
'save the spare elsewhere'
|
||||
);
|
||||
}
|
||||
|
||||
/** The copy the delete guide deletes, made where it is missing. */
|
||||
export async function ensureOldPlan(api: Api): Promise<void> {
|
||||
const tripId = await tripIdOf(api);
|
||||
if (await tourIdOf(api, tripId, PLANNER_TOURS.old)) return;
|
||||
const spare = await ensureSpare(api);
|
||||
await body(
|
||||
await api.post(`/api/trips/${tripId}/tours/${spare}/copy`, { data: { name: PLANNER_TOURS.old } }),
|
||||
'copy the old plan'
|
||||
);
|
||||
}
|
||||
|
||||
/** Take a tour off one day, as the fixtures had it before a guide put it there. */
|
||||
export async function offDay(api: Api, tripId: number, dayIndex: number, placeId: number): Promise<void> {
|
||||
const { days } = await body<{ days: { id: number; date?: string | null; day_number?: number }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/days`),
|
||||
'days'
|
||||
);
|
||||
const sorted = [...days].sort(
|
||||
(a, b) => (a.date ?? '').localeCompare(b.date ?? '') || (a.day_number ?? 0) - (b.day_number ?? 0) || a.id - b.id
|
||||
);
|
||||
const dayId = sorted[dayIndex]?.id;
|
||||
if (!dayId) return;
|
||||
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
|
||||
'assignments'
|
||||
);
|
||||
for (const a of assignments.filter((row) => row.place_id === placeId)) {
|
||||
await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the planner's unsaved draft: on the account, where the planner keeps
|
||||
* it now, and the copy older builds left in the browser, and the folds of the
|
||||
* rail cards, so the next guide starts from the defaults.
|
||||
*/
|
||||
export async function forgetPlannerState(page: Page): Promise<void> {
|
||||
const tripId = await tripIdOf(page.request);
|
||||
await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => undefined);
|
||||
await page
|
||||
.evaluate((id) => {
|
||||
try {
|
||||
for (const key of Object.keys(window.localStorage)) {
|
||||
if (
|
||||
key === `tour-draft-${id}` ||
|
||||
key.startsWith('trek.tours.fold.') ||
|
||||
key === 'trek.tours.planner.detailsOpen'
|
||||
) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No storage, nothing kept.
|
||||
}
|
||||
}, tripId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The right rail, "Tours of this trip", and a tour's row in its list (the option is named after the tour). */
|
||||
export const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
|
||||
export const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
|
||||
/** The editor rail; its section is named after the planner while a tour is drawn or edited. */
|
||||
export const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
|
||||
export const waypointsCard = (page: Page) => page.getByRole('region', { name: 'Waypoints', exact: true });
|
||||
export const waypointRows = (page: Page) => page.getByTestId('tour-planner-waypoint-row');
|
||||
export const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
|
||||
export const layerPill = (page: Page) => page.getByRole('group', { name: 'Map Style' });
|
||||
export const mapSearch = (page: Page) => page.getByRole('combobox', { name: 'Search place or address' });
|
||||
/** A numbered pin of the open tour on the map, by its number; a shaping point carries none. */
|
||||
export const pin = (page: Page, n: number) =>
|
||||
page.locator('.tour-planner-waypoint-marker').filter({ hasText: new RegExp(`^${n}$`) });
|
||||
export const shapingPin = (page: Page) => page.locator('.tour-planner-waypoint-marker').filter({ hasNotText: /\d/ });
|
||||
/** The one context menu that is open; its entries are plain buttons. */
|
||||
export const openMenu = (page: Page) => page.getByRole('menu');
|
||||
export const menuItem = (page: Page, name: string) => openMenu(page).getByRole('button', { name, exact: true });
|
||||
|
||||
/** The row's own controls show on hover or once it is selected: select it, then open its menu. */
|
||||
export async function openRowMenu(page: Page, row: Locator): Promise<void> {
|
||||
await row.hover();
|
||||
await row.getByRole('button', { name: 'More actions', exact: true }).click();
|
||||
await expect(openMenu(page)).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
const rawKey = (page: Page) => page.getByText(/^(common|planner|places|trip|tours|map|undo)\.[A-Za-z0-9.]+$/);
|
||||
|
||||
/**
|
||||
* The Dolomites trip on its Tours tab, as the reader finds it: nothing open in
|
||||
* the editor, the trip's tours listed on the right. Waits for the Trip
|
||||
* settings pill, as `tripReady` does (Share moved into it in v5).
|
||||
*/
|
||||
export async function openPlanner(page: Page): Promise<number> {
|
||||
const tripId = await tripIdOf(page.request);
|
||||
await forgetPlannerState(page);
|
||||
await page.goto(`/trips/${tripId}?tab=tour-planner`);
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
|
||||
await page.waitForTimeout(4000);
|
||||
await page.reload();
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Plan new tour', exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tourRow(page, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(1200);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the map's camera land. Leaflet times a pan from `+new Date()`, which the
|
||||
* pictures pin to one instant, so a pan starts and never gets anywhere; moving
|
||||
* the pinned clock on ends it on the next frame. The clock only moves on, so
|
||||
* the same instant never comes twice.
|
||||
*/
|
||||
export async function landMap(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400);
|
||||
await nudgeClock(page);
|
||||
await page.waitForTimeout(1200);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The route is planned and its heights are in: the summary is up and the status dot has nothing to say. */
|
||||
export async function waitRouted(page: Page): Promise<void> {
|
||||
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
|
||||
await landMap(page);
|
||||
}
|
||||
|
||||
/** The Topo map, whose raster tiles show the paths and let a spot be found on the screen. */
|
||||
export async function useTopo(page: Page): Promise<void> {
|
||||
const topo = layerPill(page).getByRole('button', { name: 'Topo', exact: true });
|
||||
await expect(topo).toBeVisible({ timeout: 15_000 });
|
||||
if ((await topo.getAttribute('aria-pressed')) !== 'true') await topo.click();
|
||||
await expect(page.locator('img.leaflet-tile[src*="opentopomap"]').first()).toBeAttached({ timeout: 20_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Open a saved tour of the list in the editor and wait for its route, on the Topo map. */
|
||||
export async function openSaved(page: Page, name: string): Promise<void> {
|
||||
const row = tourRow(page, name);
|
||||
await expect(row, `no tour "${name}" in the Tours tab`).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page.getByRole('heading', { name: 'Edit tour', exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await waitRouted(page);
|
||||
await useTopo(page);
|
||||
}
|
||||
|
||||
/** Close the editor, throwing the unsaved changes away, so the next guide finds the rail as the reader does. */
|
||||
export async function closeEditor(page: Page): Promise<void> {
|
||||
const back = page.getByRole('button', { name: 'Back to Tours', exact: true });
|
||||
if (await back.isVisible().catch(() => false)) {
|
||||
await back.click();
|
||||
const discard = page.getByRole('button', { name: 'Discard changes', exact: true });
|
||||
if (await discard.isVisible({ timeout: 2_000 }).catch(() => false)) await discard.click();
|
||||
}
|
||||
await forgetPlannerState(page);
|
||||
}
|
||||
|
||||
// ── From the earth to the screen ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pixel of a spot on the map. Read off a raster tile: its address names
|
||||
* its zoom and its column and row, its box says where it sits, and Web
|
||||
* Mercator does the rest. Exact at any zoom and through any pan, and it
|
||||
* needs nothing of Leaflet's that the page does not show anyway.
|
||||
*/
|
||||
export async function screenOf(page: Page, spot: LatLng): Promise<{ x: number; y: number }> {
|
||||
const at = await page.evaluate(([lat, lng]) => {
|
||||
for (const img of Array.from(
|
||||
document.querySelectorAll('.leaflet-tile-pane img.leaflet-tile')
|
||||
) as HTMLImageElement[]) {
|
||||
const m = /\/(\d+)\/(\d+)\/(\d+)(?:@\dx)?\.(?:png|jpe?g|webp)/.exec(img.src);
|
||||
const box = img.getBoundingClientRect();
|
||||
if (!m || box.width === 0) continue;
|
||||
const [z, tx, ty] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
const n = 2 ** z;
|
||||
const s = Math.sin((lat * Math.PI) / 180);
|
||||
const wx = ((lng + 180) / 360) * n;
|
||||
const wy = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n;
|
||||
return { x: box.left + (wx - tx) * box.width, y: box.top + (wy - ty) * box.height };
|
||||
}
|
||||
return null;
|
||||
}, spot);
|
||||
if (!at) throw new Error('no raster tile on the map to place a spot by: is the Topo map on?');
|
||||
// Between the two rails and under the search box, or a click would land on a panel.
|
||||
const rails = await page.evaluate(() => {
|
||||
const left = document.querySelector('[aria-label="Plan a tour"]')?.getBoundingClientRect();
|
||||
const right = document.querySelector('aside[aria-label="Tours of this trip"]')?.getBoundingClientRect();
|
||||
return { left: left?.right ?? 0, right: right?.left ?? window.innerWidth };
|
||||
});
|
||||
if (at.x < rails.left + 24 || at.x > rails.right - 24 || at.y < 120 || at.y > 1000) {
|
||||
throw new Error(`spot ${spot.join(', ')} lands at ${Math.round(at.x)}, ${Math.round(at.y)}, outside the open map`);
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer movement in even steps with a frame each, for a loop: from where
|
||||
* the mouse is to `to`, eased like a hand.
|
||||
*/
|
||||
export async function glide(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
frame: () => Promise<void>,
|
||||
steps = 10
|
||||
): Promise<void> {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const k = i / steps;
|
||||
const ease = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
|
||||
await page.mouse.move(from.x + (to.x - from.x) * ease, from.y + (to.y - from.y) * ease, { steps: 2 });
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
|
||||
/** The centre of an element on the screen. */
|
||||
export async function centreOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error('the element has no box');
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot on the drawn route, `share` of the way along its widest hit band:
|
||||
* the invisible band Leaflet lays along the line to take the click that adds
|
||||
* a shaping point.
|
||||
*/
|
||||
export async function spotOnRoute(page: Page, share = 0.3): Promise<{ x: number; y: number }> {
|
||||
const at = await page.evaluate((k) => {
|
||||
const bands = Array.from(document.querySelectorAll('.leaflet-overlay-pane path.cursor-copy')) as SVGPathElement[];
|
||||
const band = bands.sort((a, b) => b.getTotalLength() - a.getTotalLength())[0];
|
||||
const ctm = band?.getScreenCTM();
|
||||
if (!band || !ctm) return null;
|
||||
const p = band.getPointAtLength(band.getTotalLength() * k);
|
||||
const s = new DOMPoint(p.x, p.y).matrixTransform(ctm);
|
||||
return { x: s.x, y: s.y };
|
||||
}, share);
|
||||
if (!at) throw new Error('no drawn route to click on');
|
||||
return at;
|
||||
}
|
||||
|
||||
/** The drawn route's click band, for the ring: the widest one. */
|
||||
export const routeBand = (page: Page) => page.locator('.leaflet-overlay-pane path.cursor-copy').first();
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { SKI_TRIP, SKI_TRIP_DATES, TOUR_NAMES } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the Ski & winter help pictures need beyond `ensureToursFixtures`: a
|
||||
* recorded ski track to import through the UI, and the names of what the
|
||||
* guides make, so their cleanup can take it away again.
|
||||
*
|
||||
* The track follows the line of the "Vallunga winter walk" tour, which
|
||||
* `ensureToursFixtures` draws along the real Vallunga path (by the piste
|
||||
* router where it answers, else along the OpenStreetMap way), so the recording
|
||||
* lies on the valley's cross-country trail and not on a line drawn here. It is
|
||||
* recorded a year before the ski week, at a classic skier's pace.
|
||||
*/
|
||||
|
||||
/** The track the import guide brings in, by the name in its file. */
|
||||
export const SKI_TRACK = { name: 'Vallunga cross-country loop', file: 'vallunga-cross-country-loop.gpx' };
|
||||
/** The tour the planning guide saves, deleted again by its cleanup. */
|
||||
export const PLANNED_SKI_TOUR = 'Selva to Corvara on skis';
|
||||
|
||||
/**
|
||||
* The points the planner guides click, the first part of the Sella Ronda:
|
||||
* Selva at the Dantercepies I valley station, the top of the Gardena run at
|
||||
* Passo Gardena, and Corvara at the Borest and Boè gondolas. Each lies within
|
||||
* a few metres of the piste data, because the router only takes a point
|
||||
* within 150 m of a run or lift: the old Corvara point sat 360 m off the
|
||||
* Sodlisia run and failed as not_on_piste, and the old Selva point snapped to
|
||||
* the run down into the village, so the first leg went round by Ciampinoi
|
||||
* instead of up the Dantercepies gondola. On the Places build of 2026-10-20
|
||||
* the first leg rides Dantercepies I and II and skis the Sellaronda run to
|
||||
* the pass, and the second skis Gardena, Frara and Sodlisia down to Colfosco
|
||||
* and takes the Borest gondola over to Corvara. With every lift left out, the
|
||||
* first leg fails as blocked_by_lift_avoid on Dantercepies I.
|
||||
*/
|
||||
export const SKI_POINTS: [number, number][] = [
|
||||
[46.5556, 11.768],
|
||||
[46.5512, 11.8077],
|
||||
[46.5482, 11.871],
|
||||
];
|
||||
|
||||
type Point = [number, number, number];
|
||||
|
||||
/** The trip's id by its title. */
|
||||
export async function skiTripId(api: APIRequestContext): Promise<number> {
|
||||
const res = await api.get('/api/trips');
|
||||
const { trips = [] } = (await res.json()) as { trips?: { id: number; title: string }[] };
|
||||
const trip = trips.find((t) => t.title === SKI_TRIP);
|
||||
if (!trip) throw new Error(`no trip "${SKI_TRIP}": call ensureToursFixtures first`);
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
/** The tours of the ski week, name and place id. */
|
||||
async function skiTours(api: APIRequestContext, tripId: number): Promise<{ place_id: number; name: string }[]> {
|
||||
const res = await api.get(`/api/trips/${tripId}/tours`);
|
||||
if (!res.ok()) return [];
|
||||
return ((await res.json()) as { tours?: { place_id: number; name: string }[] }).tours ?? [];
|
||||
}
|
||||
|
||||
/** The line of a saved tour, from the trip's places or the tour itself. */
|
||||
async function lineOf(api: APIRequestContext, tripId: number, placeId: number): Promise<Point[]> {
|
||||
const parse = (raw: unknown): Point[] => {
|
||||
const value = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
||||
return Array.isArray(value)
|
||||
? (value as number[][]).filter((p) => p.length >= 2).map((p) => [p[0], p[1], p[2] ?? NaN])
|
||||
: [];
|
||||
};
|
||||
const places = await api.get(`/api/trips/${tripId}/places`);
|
||||
if (places.ok()) {
|
||||
const body = (await places.json()) as
|
||||
{ places?: { id: number; route_geometry?: unknown }[] } | { id: number; route_geometry?: unknown }[];
|
||||
const row = (Array.isArray(body) ? body : (body.places ?? [])).find((p) => p.id === placeId);
|
||||
const line = parse(row?.route_geometry);
|
||||
if (line.length > 1) return line;
|
||||
}
|
||||
const tour = await api.get(`/api/trips/${tripId}/tours/${placeId}`);
|
||||
if (tour.ok()) {
|
||||
const body = (await tour.json()) as { route_geometry?: unknown; tour?: { route_geometry?: unknown } };
|
||||
return parse(body.route_geometry ?? body.tour?.route_geometry);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const metres = (a: Point, b: Point): number => {
|
||||
const r = 6_371_000;
|
||||
const dLat = ((b[0] - a[0]) * Math.PI) / 180;
|
||||
const dLng = ((b[1] - a[1]) * Math.PI) / 180;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * r * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
/** A small seeded random, so the recording is the same on every run. */
|
||||
function random(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A GPS recording of `line`: a point every 15 m, a little scatter, heights
|
||||
* where the line has none (the Vallunga climbs from about 1610 m to 1760 m),
|
||||
* classic cross-country pace, slower uphill, and a stop at the turn.
|
||||
*/
|
||||
function recordedSkiGpx(line: Point[], startUtcMs: number): string {
|
||||
const rnd = random(4219);
|
||||
const known = line.every((p) => Number.isFinite(p[2]));
|
||||
const total = line.slice(1).reduce((s, p, i) => s + metres(line[i], p), 0) || 1;
|
||||
let run = 0;
|
||||
const withHeights: Point[] = line.map((p, i) => {
|
||||
if (i > 0) run += metres(line[i - 1], p);
|
||||
if (known) return p;
|
||||
// Out and back: up to the turn, then down the same way.
|
||||
const f = run / total;
|
||||
return [p[0], p[1], 1610 + 150 * (f <= 0.5 ? f * 2 : (1 - f) * 2)];
|
||||
});
|
||||
const pts: Point[] = [withHeights[0]];
|
||||
for (let i = 1; i < withHeights.length; i++) {
|
||||
const a = withHeights[i - 1];
|
||||
const b = withHeights[i];
|
||||
const n = Math.max(1, Math.round(metres(a, b) / 15));
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const s = k / n;
|
||||
pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, a[2] + (b[2] - a[2]) * s]);
|
||||
}
|
||||
}
|
||||
let t = startUtcMs;
|
||||
const turn = Math.floor(pts.length / 2);
|
||||
const rows = pts.map((p, i) => {
|
||||
if (i > 0) {
|
||||
const d = metres(pts[i - 1], p);
|
||||
const slope = d > 0 ? (p[2] - pts[i - 1][2]) / d : 0;
|
||||
const kmh = slope > 0 ? Math.max(5, 11 - slope * 120) : Math.min(16, 11 - slope * 80);
|
||||
t += (d / 1000 / kmh) * 3_600_000;
|
||||
}
|
||||
if (i === turn) t += 12 * 60_000;
|
||||
const lat = p[0] + (rnd() - 0.5) * 0.00005;
|
||||
const lng = p[1] + (rnd() - 0.5) * 0.00007;
|
||||
const ele = p[2] + (rnd() - 0.5) * 3;
|
||||
const time = new Date(Math.round(t / 1000) * 1000).toISOString().replace('.000', '');
|
||||
return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}"><ele>${ele.toFixed(1)}</ele><time>${time}</time></trkpt>`;
|
||||
});
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Garmin Connect" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>${SKI_TRACK.name}</name><time>${new Date(startUtcMs).toISOString().replace('.000', '')}</time></metadata>
|
||||
<trk><name>${SKI_TRACK.name}</name><type>cross_country_skiing</type><trkseg>
|
||||
${rows.join('\n')}
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Where the Vallunga winter walk runs when no line can be read: the car park, the chapel and back. */
|
||||
const VALLUNGA: Point[] = [
|
||||
[46.5622, 11.7795, 1610],
|
||||
[46.5652, 11.7858, 1640],
|
||||
[46.5688, 11.7925, 1680],
|
||||
[46.5712, 11.7984, 1720],
|
||||
[46.5735, 11.804, 1760],
|
||||
[46.5712, 11.7984, 1720],
|
||||
[46.5688, 11.7925, 1680],
|
||||
[46.5652, 11.7858, 1640],
|
||||
[46.5622, 11.7795, 1610],
|
||||
];
|
||||
|
||||
/**
|
||||
* The path of the recorded ski track in `e2e/.tmp/`, written on every call
|
||||
* from the winter walk's line, so it follows whatever line that tour has.
|
||||
*/
|
||||
export async function skiTrackFixture(api: APIRequestContext): Promise<string> {
|
||||
const tripId = await skiTripId(api);
|
||||
const walk = (await skiTours(api, tripId)).find((t) => t.name === TOUR_NAMES.winterWalk);
|
||||
let line = walk ? await lineOf(api, tripId, walk.place_id) : [];
|
||||
if (line.length < 2) {
|
||||
console.warn('[ski fixtures] no line for the Vallunga winter walk; the track follows the built-in points');
|
||||
line = VALLUNGA;
|
||||
}
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, SKI_TRACK.file);
|
||||
// A year before the ski week's fourth day, 10:20 in Val Gardena (UTC+1).
|
||||
const day = new Date(`${SKI_TRIP_DATES.start}T00:00:00Z`);
|
||||
day.setUTCDate(day.getUTCDate() + 3 - 364);
|
||||
const start = day.getTime() + (10 * 60 + 20 - 60) * 60_000;
|
||||
writeFileSync(file, recordedSkiGpx(line, start));
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Delete every tour of the ski week whose name is one of `names` (a guide's own, never the fixture's). */
|
||||
export async function deleteSkiTours(page: Page, ...names: string[]): Promise<void> {
|
||||
const tripId = await skiTripId(page.request);
|
||||
const base = SKI_TRACK.file.replace(/\.gpx$/, '');
|
||||
for (const tour of await skiTours(page.request, tripId)) {
|
||||
if (!names.includes(tour.name) && !(names.includes(SKI_TRACK.name) && tour.name === base)) continue;
|
||||
const res = await page.request.delete(`/api/trips/${tripId}/places/${tour.place_id}`);
|
||||
if (!res.ok()) console.warn(`[ski fixtures] could not delete "${tour.name}": ${res.status()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the editor's unsaved draft of the ski week. The planner keeps it on
|
||||
* the account and opens it again on the next page load, so a draft one guide
|
||||
* leaves behind would greet the next one in place of Plan new tour. Older
|
||||
* builds kept it in this browser as `tour-draft-<tripId>`, which goes too.
|
||||
*/
|
||||
export async function forgetSkiDraft(page: Page): Promise<void> {
|
||||
const tripId = await skiTripId(page.request);
|
||||
const res = await page.request.delete(`/api/trips/${tripId}/tour-draft`).catch(() => null);
|
||||
if (res && !res.ok() && res.status() !== 404)
|
||||
console.warn(`[ski fixtures] could not forget the tour draft: ${res.status()}`);
|
||||
await page
|
||||
.evaluate((id) => {
|
||||
try {
|
||||
localStorage.removeItem(`tour-draft-${id}`);
|
||||
} catch {
|
||||
// Storage blocked: nothing was kept there either.
|
||||
}
|
||||
}, tripId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { seededTrip } from './fixtures';
|
||||
import { TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* What the city walk pictures need beyond `ensureToursFixtures`: opening hours
|
||||
* on one stop of the Bolzano walk, so the walk's day shows what a stay that
|
||||
* runs past closing time looks like, and the Google Maps link the import
|
||||
* guide pastes. Nothing here is run by importing it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stop that gets opening hours, and the hours. The South Tyrol Museum of
|
||||
* Archaeology is open every day in July from ten to six; the walk starts at
|
||||
* 15:00 on day 1 and reaches it after the arcades and the market, so its
|
||||
* hour and a half runs past closing time.
|
||||
*/
|
||||
export const WALK_HOURS = { stop: 'South Tyrol Museum of Archaeology', hours: 'Mo-Su 10:00-18:00' } as const;
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type Point = [number, number, number | null];
|
||||
|
||||
async function body<T>(
|
||||
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
|
||||
what: string
|
||||
): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the museum stop of the Bolzano walk its opening hours, once. A walk's
|
||||
* stops only carry hours where they were found on OpenStreetMap, and the
|
||||
* fixture draws the walk by hand, so they are written here with a save of
|
||||
* the tour as it stands: the same line, the same stops, one field more. A
|
||||
* failure warns and leaves the walk as it was; the pictures then show the
|
||||
* arrivals without a warning.
|
||||
*/
|
||||
export async function ensureWalkHours(api: APIRequestContext): Promise<void> {
|
||||
try {
|
||||
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
const tripId = trips.find((t) => t.title === TOUR_TRIP)?.id;
|
||||
if (!tripId) throw new Error(`no trip "${TOUR_TRIP}"`);
|
||||
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/tours`),
|
||||
'tours'
|
||||
);
|
||||
const placeId = tours.find((t) => t.name === TOUR_NAMES.cityWalk)?.place_id;
|
||||
if (!placeId) throw new Error(`no tour "${TOUR_NAMES.cityWalk}"`);
|
||||
const detail = await body<{ tour: Json; waypoints: Json[] }>(
|
||||
await api.get(`/api/trips/${tripId}/tours/${placeId}`),
|
||||
'the walk'
|
||||
);
|
||||
const museum = detail.waypoints.find((w) => w.name === WALK_HOURS.stop);
|
||||
if (!museum) throw new Error(`the walk has no stop "${WALK_HOURS.stop}"`);
|
||||
if (museum.opening_hours === WALK_HOURS.hours) return;
|
||||
|
||||
const placesBody = await body<{ places?: Json[] } | Json[]>(await api.get(`/api/trips/${tripId}/places`), 'places');
|
||||
const place = (Array.isArray(placesBody) ? placesBody : (placesBody.places ?? [])).find((p) => p.id === placeId);
|
||||
const raw = place?.route_geometry;
|
||||
const line = (typeof raw === 'string' ? JSON.parse(raw) : (raw ?? [])) as Point[];
|
||||
if (line.length < 2 || line.some((p) => typeof p[2] !== 'number' || !Number.isFinite(p[2]))) {
|
||||
throw new Error('the walk has no line with heights to save again');
|
||||
}
|
||||
const tour = detail.tour;
|
||||
const minutes = typeof tour.duration === 'number' ? tour.duration : null;
|
||||
const res = await api.put(`/api/trips/${tripId}/tours/${placeId}`, {
|
||||
data: {
|
||||
name: tour.name,
|
||||
tour_type: 'walk',
|
||||
route_geometry: line,
|
||||
waypoints: detail.waypoints.map((w) => (w === museum ? { ...w, opening_hours: WALK_HOURS.hours } : w)),
|
||||
duration_seconds: minutes === null ? null : minutes * 60,
|
||||
break_additional_minutes: tour.break_additional_minutes ?? null,
|
||||
planned_duration_minutes: tour.planned_duration_minutes ?? null,
|
||||
description: tour.description ?? null,
|
||||
website: tour.website ?? null,
|
||||
expected_revision: tour.revision ?? null,
|
||||
},
|
||||
});
|
||||
if (!res.ok()) throw new Error(`save the walk: ${res.status()} ${await res.text()}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[walk-fixtures] the museum stop has no opening hours: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The Google Maps link ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A walking route through Ortisei, in Val Gardena where the trip stays, in
|
||||
* the shape Google Maps shares one: the stops as path segments, then the
|
||||
* viewport, then the data blob with one block per stop holding its position
|
||||
* (`!2m2!1d<lng>!2d<lat>`) and `!3e2`, on foot. The server reads it without
|
||||
* asking Google (`directions-url.helpers.ts`), so the picture needs no
|
||||
* network for the preview. The place ids in the blocks are opaque to TREK.
|
||||
*/
|
||||
const ORTISEI_STOPS = [
|
||||
{
|
||||
name: 'Piazza S. Antonio, 39046 Ortisei BZ, Italy',
|
||||
lat: 46.57478,
|
||||
lng: 11.67228,
|
||||
cid: '0x4778f4c0d1a2b3c5:0x2f6a1e0b9c4d7e13',
|
||||
},
|
||||
{
|
||||
name: 'Museum Gherdëina, Strada Rezia 83, 39046 Ortisei BZ, Italy',
|
||||
lat: 46.57589,
|
||||
lng: 11.67428,
|
||||
cid: '0x4778f4c2a9e1d0b7:0x6b3c2d1e0f9a8b75',
|
||||
},
|
||||
{
|
||||
name: 'Chiesa parrocchiale di Sant’Ulrico, 39046 Ortisei BZ, Italy',
|
||||
lat: 46.57673,
|
||||
lng: 11.6708,
|
||||
cid: '0x4778f4bd6c5e4f31:0x1d9e8f7a6b5c4d29',
|
||||
},
|
||||
{
|
||||
name: 'Promenade Luis Trenker, 39046 Ortisei BZ, Italy',
|
||||
lat: 46.5741,
|
||||
lng: 11.6686,
|
||||
cid: '0x4778f4b9e8d7c6a3:0x7c6b5a4f3e2d1c08',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** A path segment the way Google writes one: commas kept, spaces as plus signs. */
|
||||
const segment = (name: string) => encodeURIComponent(name).replace(/%2C/g, ',').replace(/%20/g, '+');
|
||||
|
||||
function googleWalkLink(): string {
|
||||
const blocks = ORTISEI_STOPS.map((s) => `!1m5!1m1!1s${s.cid}!2m2!1d${s.lng}!2d${s.lat}`).join('');
|
||||
// The inner route message holds six tokens per stop and the mode; the outer one that and itself.
|
||||
const inner = ORTISEI_STOPS.length * 6 + 1;
|
||||
return (
|
||||
`https://www.google.com/maps/dir/${ORTISEI_STOPS.map((s) => segment(s.name)).join('/')}` +
|
||||
`/@46.5755,11.6715,17z/data=!4m${inner + 1}!4m${inner}${blocks}!3e2`
|
||||
);
|
||||
}
|
||||
|
||||
export const GOOGLE_WALK = {
|
||||
link: googleWalkLink(),
|
||||
/** The first and the last stop as the preview names them. */
|
||||
first: ORTISEI_STOPS[0].name,
|
||||
last: ORTISEI_STOPS[ORTISEI_STOPS.length - 1].name,
|
||||
count: ORTISEI_STOPS.length,
|
||||
} as const;
|
||||
|
||||
// ── Visits ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take every stop of the Higashiyama walk on Japan day 9 back to planned,
|
||||
* so the check-off guide finds nothing marked and leaves nothing marked.
|
||||
*/
|
||||
export async function resetKyotoVisits(api: APIRequestContext): Promise<void> {
|
||||
const japan = seededTrip();
|
||||
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
|
||||
await api.get(`/api/trips/${japan.tripId}/tours`),
|
||||
'Japan tours'
|
||||
);
|
||||
const walkId = tours.find((t) => t.name === TOUR_NAMES.kyotoWalk)?.place_id;
|
||||
if (!walkId) return;
|
||||
const { assignments } = await body<{ assignments: { id: number; place_id: number }[] }>(
|
||||
await api.get(`/api/trips/${japan.tripId}/days/${japan.dayIds[8]}/assignments`),
|
||||
'Japan day 9'
|
||||
);
|
||||
const assignment = assignments.find((a) => a.place_id === walkId);
|
||||
if (!assignment) return;
|
||||
const { visits } = await body<{ visits: { assignment_id: number; station_key: string; status: string }[] }>(
|
||||
await api.get(`/api/trips/${japan.tripId}/tour-visits`),
|
||||
'visits'
|
||||
);
|
||||
for (const visit of visits.filter((v) => v.assignment_id === assignment.id)) {
|
||||
await api.put(`/api/trips/${japan.tripId}/tour-visits/${assignment.id}/${visit.station_key}`, {
|
||||
data: { status: 'planned' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import { dismissReleaseNotice, settle } from './guide';
|
||||
import { TOUR_TRIP } from './tour-fixtures';
|
||||
import { closeDayDetails, selectDay, tripReady } from './trip-shared';
|
||||
|
||||
/**
|
||||
* What the Plan-tab tour pictures need beyond `ensureToursFixtures`: the way
|
||||
* into the Dolomites trip with the right column in Tours mode, a timed stop
|
||||
* after the horse ride for the overrun line, and the writes the guides take
|
||||
* back in their `cleanup`. Not a `.guide.ts`, so importing it registers no tests.
|
||||
*/
|
||||
|
||||
type Api = APIRequestContext;
|
||||
|
||||
interface AssignmentRow {
|
||||
id: number;
|
||||
place_id: number;
|
||||
assignment_time?: string | null;
|
||||
place_time?: string | null;
|
||||
}
|
||||
|
||||
/** The timed stop after the horse ride on day 2, made by the times guide and removed again. */
|
||||
export const LUNCH = {
|
||||
name: 'Lunch at Gostner Schwaige',
|
||||
lat: 46.5546,
|
||||
lng: 11.6347,
|
||||
address: 'Compatsch, Seiser Alm',
|
||||
time: '12:30',
|
||||
};
|
||||
|
||||
async function body<T>(
|
||||
res: { ok(): boolean; status(): number; text(): Promise<string>; json(): Promise<unknown> },
|
||||
what: string
|
||||
): Promise<T> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function tripIdOf(api: Api, title = TOUR_TRIP): Promise<number> {
|
||||
const { trips } = await body<{ trips: { id: number; title: string }[] }>(await api.get('/api/trips'), 'trips');
|
||||
const trip = trips.find((t) => t.title === title);
|
||||
if (!trip) throw new Error(`no trip "${title}": ensureToursFixtures has not run`);
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
/** A tour's place id by its name, or null when it is not in the trip. */
|
||||
export async function tourIdOf(api: Api, tripId: number, name: string): Promise<number | null> {
|
||||
const { tours } = await body<{ tours: { place_id: number; name: string }[] }>(
|
||||
await api.get(`/api/trips/${tripId}/tours`),
|
||||
'tours'
|
||||
);
|
||||
return tours.find((t) => t.name === name)?.place_id ?? null;
|
||||
}
|
||||
|
||||
export async function assignmentsOf(api: Api, tripId: number, dayId: number): Promise<AssignmentRow[]> {
|
||||
return (
|
||||
await body<{ assignments: AssignmentRow[] }>(
|
||||
await api.get(`/api/trips/${tripId}/days/${dayId}/assignments`),
|
||||
'assignments'
|
||||
)
|
||||
).assignments;
|
||||
}
|
||||
|
||||
/** Take a place off every day it is on. */
|
||||
export async function offAllDays(api: Api, tripId: number, dayIds: number[], placeId: number): Promise<void> {
|
||||
for (const dayId of dayIds) {
|
||||
for (const a of await assignmentsOf(api, tripId, dayId)) {
|
||||
if (a.place_id === placeId) await api.delete(`/api/trips/${tripId}/days/${dayId}/assignments/${a.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Put a tour back on a day with its start time, as the fixtures had it. */
|
||||
export async function backOnDay(
|
||||
api: Api,
|
||||
tripId: number,
|
||||
dayId: number,
|
||||
placeId: number,
|
||||
time: string | null
|
||||
): Promise<void> {
|
||||
let row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
|
||||
if (!row) {
|
||||
await body(
|
||||
await api.post(`/api/trips/${tripId}/days/${dayId}/assignments`, { data: { place_id: placeId } }),
|
||||
'put back on its day'
|
||||
);
|
||||
row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
|
||||
}
|
||||
if (row)
|
||||
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
|
||||
}
|
||||
|
||||
/** Give a tour's day entry its start again (and no end), as the fixtures had it. */
|
||||
export async function resetTime(api: Api, tripId: number, dayId: number, placeId: number, time: string): Promise<void> {
|
||||
const row = (await assignmentsOf(api, tripId, dayId)).find((a) => a.place_id === placeId);
|
||||
if (row)
|
||||
await api.put(`/api/trips/${tripId}/assignments/${row.id}/time`, { data: { place_time: time, end_time: null } });
|
||||
}
|
||||
|
||||
/** The lunch stop on `dayId`, at the end of the day and timed, made once. */
|
||||
export async function ensureLunch(api: Api, tripId: number, dayId: number): Promise<void> {
|
||||
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
|
||||
await api.get(`/api/trips/${tripId}/places`),
|
||||
'places'
|
||||
);
|
||||
const list = Array.isArray(places) ? places : (places.places ?? []);
|
||||
let id = list.find((p) => p.name === LUNCH.name)?.id;
|
||||
if (!id) {
|
||||
const created = await body<{ place: { id: number } }>(
|
||||
await api.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: LUNCH.name, lat: LUNCH.lat, lng: LUNCH.lng, address: LUNCH.address },
|
||||
}),
|
||||
'create the lunch stop'
|
||||
);
|
||||
id = created.place.id;
|
||||
}
|
||||
await backOnDay(api, tripId, dayId, id, LUNCH.time);
|
||||
}
|
||||
|
||||
export async function removeByName(api: Api, tripId: number, name: string): Promise<void> {
|
||||
const places = await body<{ places?: { id: number; name: string }[] } | { id: number; name: string }[]>(
|
||||
await api.get(`/api/trips/${tripId}/places`),
|
||||
'places'
|
||||
);
|
||||
const list = Array.isArray(places) ? places : (places.places ?? []);
|
||||
for (const place of list.filter((p) => p.name === name)) await api.delete(`/api/trips/${tripId}/places/${place.id}`);
|
||||
}
|
||||
|
||||
/** Forget the tours hidden on the map; the switch is kept in this browser only. */
|
||||
export async function clearHiddenTours(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
for (const key of Object.keys(window.localStorage)) {
|
||||
if (key.startsWith('trek:hidden-tours:')) window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// No storage, nothing hidden.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rawKey = (page: Page) =>
|
||||
page.getByText(/^(common|dayplan|planner|places|trip|day|tours|packing|inspector)\.[A-Za-z0-9.]+$/);
|
||||
|
||||
/** The Places | Tours switch at the top of the right column. */
|
||||
export const modeSwitch = (page: Page) => page.getByRole('tablist', { name: 'Add view' });
|
||||
export const modeTab = (page: Page, name: 'Places' | 'Tours') =>
|
||||
modeSwitch(page).getByRole('tab', { name, exact: true });
|
||||
|
||||
/**
|
||||
* Open the Dolomites trip on the Plan tab the way `openTrip` opens the Japan
|
||||
* one: notices cleared, waiting for the page and its texts, day 1 selected and
|
||||
* its details panel closed again. With `tours`, the right column is switched
|
||||
* to Tours and the list has loaded.
|
||||
*/
|
||||
export async function openToursPlan(page: Page, opts: { tours?: boolean } = {}): Promise<number> {
|
||||
const tripId = await tripIdOf(page.request);
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
|
||||
await page.waitForTimeout(4000);
|
||||
await page.reload();
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await tripReady(page);
|
||||
await settle(page);
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0);
|
||||
await selectDay(page, 1);
|
||||
await closeDayDetails(page);
|
||||
if (opts.tours) {
|
||||
await modeTab(page, 'Tours').click();
|
||||
await expect(modeTab(page, 'Tours')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByRole('listbox', { name: 'Tours', exact: true }).getByRole('option').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
}
|
||||
// The map tiles land after the page says it is idle.
|
||||
await page.waitForTimeout(1200);
|
||||
return tripId;
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { request as apiRequest, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { E2E_BASE_URL } from '../../playwright.config';
|
||||
|
||||
/**
|
||||
* The Vacay state the guides set up through the API: the seeded member mira
|
||||
* fused into the admin's plan, a fusion request from her, a public-holiday
|
||||
* calendar to edit. Every helper is idempotent, so a retried or a single
|
||||
* guide finds what it needs, and each has its way back to the seed.
|
||||
*/
|
||||
|
||||
const VACAY = '/api/addons/vacay';
|
||||
|
||||
/**
|
||||
* The seeded member the fusion and sharing guides play with. Not jonas: the
|
||||
* share guide shares with jonas, and two rows of the same name would hide
|
||||
* which one is which.
|
||||
*/
|
||||
export const PARTNER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' };
|
||||
|
||||
interface PlanData {
|
||||
plan: { id: number; holiday_calendars: { id: number; type?: string | null; color: string; label: string | null }[] };
|
||||
users: { id: number; username: string; color: string }[];
|
||||
pendingInvites: { user_id: number }[];
|
||||
incomingInvites: { plan_id: number; username: string }[];
|
||||
isFused: boolean;
|
||||
}
|
||||
|
||||
async function ok(res: { ok(): boolean; status(): number; text(): Promise<string> }, what: string): Promise<void> {
|
||||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A request context acting as the member. Its own context with a bearer
|
||||
* token: a login on the page's context would put the member's session cookie
|
||||
* on the admin's requests.
|
||||
*/
|
||||
export async function asMember(member: typeof PARTNER = PARTNER): Promise<APIRequestContext> {
|
||||
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined });
|
||||
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } });
|
||||
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined;
|
||||
await anon.dispose();
|
||||
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`);
|
||||
return apiRequest.newContext({
|
||||
baseURL: E2E_BASE_URL,
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function meId(api: APIRequestContext): Promise<number> {
|
||||
return ((await (await api.get('/api/auth/me')).json()) as { user: { id: number } }).user.id;
|
||||
}
|
||||
|
||||
/** The admin's active plan, as the page loads it. */
|
||||
export async function adminPlan(page: Page): Promise<PlanData> {
|
||||
const res = await page.request.get(`${VACAY}/plan`);
|
||||
await ok(res, 'could not load the vacay plan');
|
||||
return (await res.json()) as PlanData;
|
||||
}
|
||||
|
||||
/** The admin's own id. */
|
||||
export const adminId = (page: Page): Promise<number> => meId(page.request);
|
||||
|
||||
/**
|
||||
* Mira fused into the admin's plan, the admin its owner. The invite guide may
|
||||
* have left an invite pending already; otherwise the admin sends one.
|
||||
*/
|
||||
export async function ensureFused(page: Page): Promise<void> {
|
||||
const data = await adminPlan(page);
|
||||
if (data.isFused && data.users.some((u) => u.username === PARTNER.username)) return;
|
||||
const member = await asMember();
|
||||
try {
|
||||
const partnerId = await meId(member);
|
||||
if (!data.pendingInvites.some((i) => i.user_id === partnerId)) {
|
||||
await ok(
|
||||
await page.request.post(`${VACAY}/invite`, { data: { user_id: partnerId } }),
|
||||
`could not invite ${PARTNER.username}`
|
||||
);
|
||||
}
|
||||
await ok(
|
||||
await member.post(`${VACAY}/invite/accept`, { data: { plan_id: data.plan.id } }),
|
||||
`${PARTNER.username} could not accept`
|
||||
);
|
||||
} finally {
|
||||
await member.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to separate plans. The admin owns the plan, so everyone's entries go home. */
|
||||
export async function ensureSeparate(page: Page): Promise<void> {
|
||||
if (!(await adminPlan(page)).isFused) return;
|
||||
await ok(await page.request.post(`${VACAY}/dissolve`), 'could not dissolve the fusion');
|
||||
}
|
||||
|
||||
/** Clear what the partner has logged in the admin's plan on these dates. */
|
||||
export async function clearPartnerDays(page: Page, year: number, dates: string[]): Promise<void> {
|
||||
const res = await page.request.get(`${VACAY}/entries/${year}`);
|
||||
await ok(res, 'could not load the entries');
|
||||
const { entries } = (await res.json()) as {
|
||||
entries: { user_id: number; date: string; fraction: number; kind: string | null }[];
|
||||
};
|
||||
const member = await asMember();
|
||||
try {
|
||||
const partnerId = await meId(member);
|
||||
for (const e of entries.filter((x) => x.user_id === partnerId && dates.includes(x.date))) {
|
||||
// The same day with the same fraction and kind again is what clears it.
|
||||
await ok(
|
||||
await member.post(`${VACAY}/entries/toggle`, {
|
||||
data: { date: e.date, fraction: e.fraction, kind: e.kind ?? 'vacation' },
|
||||
}),
|
||||
`could not clear ${e.date}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await member.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fusion request from mira to the admin, which opens the Fusion Request
|
||||
* dialog on the admin's next visit. Both have to be on their own plans first,
|
||||
* or her invite would come from the plan the admin already owns.
|
||||
*/
|
||||
export async function requestFusionFromPartner(page: Page): Promise<void> {
|
||||
await ensureSeparate(page);
|
||||
const target = await adminId(page);
|
||||
const member = await asMember();
|
||||
try {
|
||||
const res = await member.post(`${VACAY}/invite`, { data: { user_id: target } });
|
||||
if (!res.ok() && !(await res.text()).includes('Invite already pending')) {
|
||||
throw new Error(`${PARTNER.username} could not invite the admin: ${res.status()}`);
|
||||
}
|
||||
} finally {
|
||||
await member.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Take back what requestFusionFromPartner did: the request and the notification it sent. */
|
||||
export async function clearFusionRequest(page: Page): Promise<void> {
|
||||
for (const inv of (await adminPlan(page)).incomingInvites) {
|
||||
await ok(
|
||||
await page.request.post(`${VACAY}/invite/decline`, { data: { plan_id: inv.plan_id } }),
|
||||
'could not decline the request'
|
||||
);
|
||||
}
|
||||
await deleteNotifications(page, 'notif.vacay_invite.title', PARTNER.username);
|
||||
}
|
||||
|
||||
/** Remove the in-app notifications of one kind that a member sent the admin. */
|
||||
export async function deleteNotifications(page: Page, titleKey: string, sender: string): Promise<void> {
|
||||
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
|
||||
notifications: { id: number; title_key: string; sender_username: string | null }[];
|
||||
};
|
||||
for (const n of inbox.notifications.filter((x) => x.title_key === titleKey && x.sender_username === sender)) {
|
||||
await page.request.delete(`/api/notifications/in-app/${n.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The member shares her calendar with the admin, as she would from her own Vacay. */
|
||||
export async function shareWithAdmin(page: Page): Promise<void> {
|
||||
const target = await adminId(page);
|
||||
const member = await asMember();
|
||||
try {
|
||||
const res = await member.post(`${VACAY}/shares`, { data: { user_id: target } });
|
||||
// A retried guide finds the share from its first attempt still there.
|
||||
if (!res.ok() && !(await res.text()).includes('Already shared')) {
|
||||
throw new Error(`could not share ${PARTNER.username}'s calendar with the admin: ${res.status()}`);
|
||||
}
|
||||
} finally {
|
||||
await member.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
|
||||
export async function unshareWithAdmin(page: Page): Promise<void> {
|
||||
const shares = (await (await page.request.get(`${VACAY}/shares`)).json()) as {
|
||||
incoming: { id: number; username: string }[];
|
||||
};
|
||||
for (const s of shares.incoming.filter((x) => x.username === PARTNER.username)) {
|
||||
await ok(await page.request.delete(`${VACAY}/shares/${s.id}`), `could not remove ${PARTNER.username}'s share`);
|
||||
}
|
||||
await deleteNotifications(page, 'notif.vacay_share.title', PARTNER.username);
|
||||
}
|
||||
|
||||
/** The admin's own colour, to put back after the colour guide. */
|
||||
export async function ownColor(page: Page): Promise<string> {
|
||||
const me = await adminId(page);
|
||||
const user = (await adminPlan(page)).users.find((u) => u.id === me);
|
||||
if (!user) throw new Error('the admin is not on their own plan');
|
||||
return user.color;
|
||||
}
|
||||
|
||||
export async function setOwnColor(page: Page, color: string): Promise<void> {
|
||||
await ok(await page.request.put(`${VACAY}/color`, { data: { color } }), 'could not set the colour');
|
||||
}
|
||||
|
||||
/**
|
||||
* At least one public-holiday calendar, for the guide that edits one. The
|
||||
* public-holidays guide adds it in a full run; on its own the guide adds the
|
||||
* same Berlin calendar here.
|
||||
*/
|
||||
export async function ensurePublicCalendar(page: Page): Promise<void> {
|
||||
const { plan } = await adminPlan(page);
|
||||
await ok(
|
||||
await page.request.put(`${VACAY}/plan`, { data: { holidays_enabled: true } }),
|
||||
'could not switch public holidays on'
|
||||
);
|
||||
if (plan.holiday_calendars.some((c) => (c.type ?? 'public_holiday') === 'public_holiday')) return;
|
||||
await ok(
|
||||
await page.request.post(`${VACAY}/plan/holiday-calendars`, {
|
||||
data: { region: 'DE-BE', label: 'Berlin', type: 'public_holiday' },
|
||||
}),
|
||||
'could not add the Berlin calendar'
|
||||
);
|
||||
}
|
||||
|
||||
/** The colour of every holiday calendar, by id, to put back after the edit guide. */
|
||||
export async function calendarColors(page: Page): Promise<Map<number, string>> {
|
||||
return new Map((await adminPlan(page)).plan.holiday_calendars.map((c) => [c.id, c.color]));
|
||||
}
|
||||
|
||||
export async function restoreCalendarColors(page: Page, colors: Map<number, string>): Promise<void> {
|
||||
for (const cal of (await adminPlan(page)).plan.holiday_calendars) {
|
||||
const was = colors.get(cal.id);
|
||||
if (was && was !== cal.color) {
|
||||
await ok(
|
||||
await page.request.put(`${VACAY}/plan/holiday-calendars/${cal.id}`, { data: { color: was } }),
|
||||
'could not restore the colour'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The fraction of the company holiday on a date, or null when there is none. */
|
||||
export async function companyHolidayFraction(page: Page, year: number, date: string): Promise<number | null> {
|
||||
const res = await page.request.get(`${VACAY}/entries/${year}`);
|
||||
await ok(res, 'could not load the entries');
|
||||
const { companyHolidays } = (await res.json()) as { companyHolidays: { date: string; fraction: number }[] };
|
||||
return companyHolidays.find((c) => c.date === date)?.fraction ?? null;
|
||||
}
|
||||
|
||||
/** Put a company holiday back to a size, or remove it (null). The same size again clears it. */
|
||||
export async function setCompanyHoliday(
|
||||
page: Page,
|
||||
year: number,
|
||||
date: string,
|
||||
fraction: number | null
|
||||
): Promise<void> {
|
||||
const now = await companyHolidayFraction(page, year, date);
|
||||
if (now === fraction) return;
|
||||
// Clearing sends the size it has; converting sends the size it should get.
|
||||
const send = fraction === null ? now : fraction;
|
||||
await ok(
|
||||
await page.request.post(`${VACAY}/entries/company-holiday`, { data: { date, fraction: send } }),
|
||||
`could not reset ${date}`
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,603 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { PICTURE_DAY_ISO } from '../dates'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Runner for the help-center media (`npm run help:media`).
|
||||
*
|
||||
* A guide is defined once, in `src/help/contexts/<screen>.ts`; the file next to
|
||||
* this one gives every step of it an action against the real app. From that
|
||||
* single script this runner produces everything the panel shows:
|
||||
*
|
||||
* - one framed picture per step, with the element the reader is told to use
|
||||
* ringed and numbered (`step-<n>.png`),
|
||||
* - the state after the last step (`result.png`).
|
||||
*
|
||||
* And because every step has to succeed for the picture to exist, the same run
|
||||
* is the test that the guide still matches the UI.
|
||||
*
|
||||
* Output lands in a staging directory; `promote.mjs` converts and moves it into
|
||||
* `public/help-media/`, so a bad run never clobbers good artwork.
|
||||
*/
|
||||
|
||||
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
|
||||
/**
|
||||
* A common desktop. Smaller frames squeeze the trip cards until their badges
|
||||
* overlap the action buttons, which is not what a reader on a normal screen
|
||||
* sees; the wiki shots use 1440×900 for the docs' narrower column.
|
||||
*/
|
||||
export const VIEWPORT = { width: 1920, height: 1080 }
|
||||
|
||||
/**
|
||||
* The instant every picture is taken at: nine in the morning, UTC, on the
|
||||
* picture day (`e2e/dates.ts`), which is the seeded trip's last day.
|
||||
*
|
||||
* A picture has to look the same whoever runs this and whenever, and the seed
|
||||
* is relative to that day, so the browser's clock is pinned to it: were it
|
||||
* left on the wall clock, a run late in the evening would see the trip's last
|
||||
* stops in the past, and What's Next would have nothing left to list. Only
|
||||
* the browser's `Date` is pinned, so timers keep running and nothing that
|
||||
* waits stops waiting; the server keeps its own clock, which none of the
|
||||
* pictures read.
|
||||
*/
|
||||
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
|
||||
|
||||
/** 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> {
|
||||
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 {
|
||||
/** Bring the screen to where the step starts (runs after the previous step's `act`). */
|
||||
prepare?: (page: Page) => Promise<void>
|
||||
/** The element the reader is told to use; ringed and framed in the picture. */
|
||||
target?: (page: Page) => Locator
|
||||
/**
|
||||
* Where to put the pointer for the picture when the target's centre is not on
|
||||
* it (an SVG region whose bounding-box centre lies in a neighbour). Default:
|
||||
* hover the target.
|
||||
*/
|
||||
hover?: (page: Page) => Promise<void>
|
||||
/**
|
||||
* A drag: the target is what the reader picks up, this is where it goes.
|
||||
* Both are ringed and an arrow joins them, so one still shows the gesture.
|
||||
*/
|
||||
dropTo?: (page: Page) => Locator
|
||||
/** What the step does. */
|
||||
act?: (page: Page) => Promise<void>
|
||||
/**
|
||||
* 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 {
|
||||
guide: HelpGuide
|
||||
/** Where the guide starts: navigate, dismiss notices, wait for data. */
|
||||
start: (page: Page) => Promise<void>
|
||||
/** Exactly one entry per registered step. */
|
||||
steps: StepAction[]
|
||||
/** Undo what the guide changed, so the next one starts from the seed again. */
|
||||
cleanup?: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 'still' takes the pictures at full speed. The mode stays as a switch so a
|
||||
* recorder that plays the same scripts at a watchable pace can be added
|
||||
* without touching them; nothing sets 'paced' today.
|
||||
*/
|
||||
let mode: 'still' | 'paced' = 'still'
|
||||
|
||||
/** A pause a person would make; nothing in still mode. */
|
||||
export async function beat(page: Page, ms = 700): Promise<void> {
|
||||
if (mode === 'paced') await page.waitForTimeout(ms)
|
||||
}
|
||||
|
||||
/** Fill instantly for the pictures; type like a person when paced. */
|
||||
export async function typeInto(page: Page, locator: Locator, text: string): Promise<void> {
|
||||
if (mode === 'paced') {
|
||||
await locator.click()
|
||||
await locator.pressSequentially(text, { delay: 55 })
|
||||
} else {
|
||||
await locator.fill(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stills ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */
|
||||
export async function captureGuide(page: Page, script: GuideScript): Promise<void> {
|
||||
mode = 'still'
|
||||
await pinClock(page)
|
||||
const { guide } = script
|
||||
if (script.steps.length !== guide.steps) {
|
||||
throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`)
|
||||
}
|
||||
const dir = path.join(OUT_DIR, guide.id)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
await script.start(page)
|
||||
await settle(page)
|
||||
|
||||
for (let i = 0; i < script.steps.length; i++) {
|
||||
const n = i + 1
|
||||
const step = script.steps[i]
|
||||
await step.prepare?.(page)
|
||||
if (step.target && guide.media.steps) {
|
||||
const target = step.target(page)
|
||||
await expect(target, `guide "${guide.id}" step ${n}: target`).toBeVisible()
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
let box = await target.boundingBox()
|
||||
if (!box) throw new Error(`guide "${guide.id}" step ${n}: target has no box`)
|
||||
if (box.height > VIEWPORT.height - 2 * PAD) {
|
||||
// A block taller than the frame (a whole settings section): show its top
|
||||
// rather than its middle, which is where the heading is.
|
||||
await target.evaluate(el => el.scrollIntoView({ block: 'start' }))
|
||||
await page.evaluate(() => {
|
||||
const nav = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64
|
||||
window.scrollBy(0, -nav - 24)
|
||||
})
|
||||
} else {
|
||||
// Playwright scrolls only as far as it must, which leaves a target at the
|
||||
// bottom edge and the frame full of whatever sits above it. Centre it so
|
||||
// the picture shows the element in its surroundings. Works on whichever
|
||||
// ancestor scrolls (the dashboard has its own pane, not the document).
|
||||
await target.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
||||
}
|
||||
if (step.hover) await step.hover(page)
|
||||
else await target.hover()
|
||||
await settle(page)
|
||||
box = (await target.boundingBox()) ?? box
|
||||
let shot: Box
|
||||
if (step.dropTo) {
|
||||
const dest = step.dropTo(page)
|
||||
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
|
||||
const to = await dest.boundingBox()
|
||||
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
|
||||
await ringDrag(page, box, to, n)
|
||||
shot = frameFor(union(box, to))
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: shot })
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
if (guide.media.result) {
|
||||
await settle(page)
|
||||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) // nothing hovered in the after-picture
|
||||
await page.waitForTimeout(250)
|
||||
await page.screenshot({ path: path.join(dir, 'result.png') })
|
||||
}
|
||||
await script.cleanup?.(page)
|
||||
}
|
||||
|
||||
/** The screen overview picture for a context. */
|
||||
export async function captureHero(page: Page, contextId: string, start: (page: Page) => Promise<void>): Promise<void> {
|
||||
mode = 'still'
|
||||
await pinClock(page)
|
||||
const dir = path.join(OUT_DIR, 'ctx')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
await start(page)
|
||||
await settle(page)
|
||||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4)
|
||||
await page.waitForTimeout(250)
|
||||
await page.screenshot({ path: path.join(dir, `${contextId}.png`) })
|
||||
}
|
||||
|
||||
// ── Framing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAD = 64
|
||||
const MIN_W = 960
|
||||
const RATIO = 16 / 10
|
||||
|
||||
/**
|
||||
* A 16:10 window around the target: at least MIN_W wide, big enough to hold the
|
||||
* element with breathing room, kept inside the viewport. A target too large for
|
||||
* the largest 16:10 frame that fits gets that frame, anchored at its top, so a
|
||||
* long settings section shows its heading rather than its middle.
|
||||
*/
|
||||
export function frameFor(box: { x: number; y: number; width: number; height: number }) {
|
||||
const { width: vw, height: vh } = VIEWPORT
|
||||
const maxH = Math.min(vh, vw / RATIO)
|
||||
const maxW = maxH * RATIO
|
||||
let w = Math.max(MIN_W, box.width + 2 * PAD)
|
||||
let h = Math.max(w / RATIO, box.height + 2 * PAD)
|
||||
w = Math.max(w, h * RATIO)
|
||||
const tall = h > maxH
|
||||
if (w > maxW || tall) {
|
||||
w = maxW
|
||||
h = maxH
|
||||
}
|
||||
const x = clamp(box.x + box.width / 2 - w / 2, 0, vw - w)
|
||||
const y = tall ? clamp(box.y - PAD / 2, 0, vh - h) : clamp(box.y + box.height / 2 - h / 2, 0, vh - h)
|
||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) }
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
|
||||
|
||||
/**
|
||||
* Ring the target, dim everything else, number it.
|
||||
*
|
||||
* The ring is the app's accent, which stands out on nearly every surface the
|
||||
* guides walk over. Over the lightbox and the other near-black overlays it does
|
||||
* not: a dark accent on a dark ground is a ring nobody can see. So the surface
|
||||
* behind the ring is measured, and only when it has too little contrast against
|
||||
* the accent does a white hairline go around the outside of the ring and the
|
||||
* badge. Everywhere else the ring keeps the single line it always had.
|
||||
*/
|
||||
async function ring(
|
||||
page: Page,
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
n: number,
|
||||
frame: { x: number; y: number; width: number; height: number },
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ box, n, frame }) => {
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||||
const pad = 6
|
||||
|
||||
const parse = (value: string): [number, number, number, number] | null => {
|
||||
const text = value.trim()
|
||||
if (text.startsWith('#')) {
|
||||
const hex = text.length < 7 ? text.slice(1, 4).replace(/./g, c => c + c) : text.slice(1, 7)
|
||||
const n = Number.parseInt(hex, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]
|
||||
}
|
||||
const inside = /rgba?\(([^)]+)\)/.exec(text)
|
||||
if (!inside) return null
|
||||
const parts = inside[1]
|
||||
.split(/[\s,/]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]
|
||||
}
|
||||
const luminance = (colour: [number, number, number, number]): number => {
|
||||
const channel = (v: number) => {
|
||||
const c = v / 255
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * channel(colour[0]) + 0.7152 * channel(colour[1]) + 0.0722 * channel(colour[2])
|
||||
}
|
||||
// What lies just outside the ring, through any transparent layer.
|
||||
const behind = (x: number, y: number): [number, number, number, number] => {
|
||||
let node = document.elementFromPoint(
|
||||
Math.max(1, Math.min(window.innerWidth - 2, x)),
|
||||
Math.max(1, Math.min(window.innerHeight - 2, y)),
|
||||
)
|
||||
while (node) {
|
||||
const colour = parse(getComputedStyle(node).backgroundColor)
|
||||
if (colour && colour[3] > 0.2) return colour
|
||||
node = node.parentElement
|
||||
}
|
||||
return [255, 255, 255, 1]
|
||||
}
|
||||
// The dim layer darkens that surface before the ring is drawn over it.
|
||||
const ground = luminance(behind(box.x - pad - 4, box.y + box.height / 2)) * 0.7
|
||||
const ink = luminance(parse(accent) || [17, 24, 39, 1])
|
||||
const hairline = (Math.max(ground, ink) + 0.05) / (Math.min(ground, ink) + 0.05) < 3
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.id = 'trek-help-ring'
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
left: `${box.x - pad}px`,
|
||||
top: `${box.y - pad}px`,
|
||||
width: `${box.width + 2 * pad}px`,
|
||||
height: `${box.height + 2 * pad}px`,
|
||||
borderRadius: '12px',
|
||||
boxShadow: `0 0 0 3px ${accent}${hairline ? ', 0 0 0 5px rgba(255,255,255,0.92)' : ''}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2147483647',
|
||||
})
|
||||
// Outside the corner where that is in shot, and otherwise inside, far
|
||||
// enough in to clear the frame's edge: a row wider than the picture has
|
||||
// no outside left of it, and the ring's own corner is off the picture too.
|
||||
const ringLeft = box.x - pad
|
||||
const ringTop = box.y - pad
|
||||
const left = ringLeft - 14 >= frame.x + 2 ? -14 : Math.max(10, frame.x + 10 - ringLeft)
|
||||
const top = ringTop - 14 >= frame.y + 2 ? -14 : Math.max(10, frame.y + 10 - ringTop)
|
||||
const badge = document.createElement('div')
|
||||
Object.assign(badge.style, {
|
||||
position: 'absolute',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '999px',
|
||||
background: accent,
|
||||
color: accentText,
|
||||
font: '700 13px/28px Poppins, system-ui, sans-serif',
|
||||
textAlign: 'center',
|
||||
boxShadow: hairline
|
||||
? '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)'
|
||||
: '0 2px 8px rgba(0,0,0,0.25)',
|
||||
})
|
||||
badge.textContent = String(n)
|
||||
el.appendChild(badge)
|
||||
document.body.appendChild(el)
|
||||
},
|
||||
{ box, n, frame },
|
||||
)
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** The smallest box around two. */
|
||||
function union(a: Box, b: Box): Box {
|
||||
const x = Math.min(a.x, b.x)
|
||||
const y = Math.min(a.y, b.y)
|
||||
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }
|
||||
}
|
||||
|
||||
/**
|
||||
* The drag picture: a dim over everything but the two boxes, the dragged one
|
||||
* ringed and numbered, the destination ringed in a dashed line, and an arrow
|
||||
* from the one to the other, bowed a little so it reads as a movement.
|
||||
*/
|
||||
async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ from, to, n }) => {
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||||
const pad = 6
|
||||
const W = window.innerWidth
|
||||
const H = window.innerHeight
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
const el = (tag: string, attrs: Record<string, string | number>) => {
|
||||
const node = document.createElementNS(ns, tag)
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v))
|
||||
return node
|
||||
}
|
||||
const svg = el('svg', { id: 'trek-help-ring', width: W, height: H, viewBox: `0 0 ${W} ${H}` })
|
||||
Object.assign(svg.style, { position: 'fixed', left: '0', top: '0', pointerEvents: 'none', zIndex: '2147483647' })
|
||||
const grow = (b: typeof from) => ({ x: b.x - pad, y: b.y - pad, w: b.width + 2 * pad, h: b.height + 2 * pad })
|
||||
const a = grow(from)
|
||||
const b = grow(to)
|
||||
const defs = el('defs', {})
|
||||
const mask = el('mask', { id: 'trek-help-mask' })
|
||||
mask.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'white' }))
|
||||
mask.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'black' }))
|
||||
mask.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'black' }))
|
||||
defs.appendChild(mask)
|
||||
const marker = el('marker', { id: 'trek-help-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' })
|
||||
marker.appendChild(el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: accent }))
|
||||
defs.appendChild(marker)
|
||||
svg.appendChild(defs)
|
||||
svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'rgba(0,0,0,0.30)', mask: 'url(#trek-help-mask)' }))
|
||||
svg.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3 }))
|
||||
svg.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3, 'stroke-dasharray': '10 7' }))
|
||||
// From the edge of the dragged box that faces the destination to the facing edge there.
|
||||
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }
|
||||
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 }
|
||||
const horizontal = Math.abs(bc.x - ac.x) > Math.abs(bc.y - ac.y)
|
||||
const start = horizontal ? { x: bc.x > ac.x ? a.x + a.w : a.x, y: ac.y } : { x: ac.x, y: bc.y > ac.y ? a.y + a.h : a.y }
|
||||
const end = horizontal ? { x: bc.x > ac.x ? b.x : b.x + b.w, y: bc.y } : { x: bc.x, y: bc.y > ac.y ? b.y : b.y + b.h }
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const bow = Math.min(90, len * 0.18)
|
||||
const ctrl = { x: (start.x + end.x) / 2 - (dy / len) * bow, y: (start.y + end.y) / 2 + (dx / len) * bow }
|
||||
svg.appendChild(el('path', {
|
||||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||||
fill: 'none', stroke: 'rgba(255,255,255,0.9)', 'stroke-width': 8, 'stroke-linecap': 'round',
|
||||
}))
|
||||
svg.appendChild(el('path', {
|
||||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||||
fill: 'none', stroke: accent, 'stroke-width': 4, 'stroke-linecap': 'round', 'marker-end': 'url(#trek-help-arrow)',
|
||||
}))
|
||||
const badge = el('g', {})
|
||||
badge.appendChild(el('circle', { cx: a.x, cy: a.y, r: 14, fill: accent }))
|
||||
const text = el('text', { x: a.x, y: a.y + 5, 'text-anchor': 'middle', fill: accentText, 'font-family': 'Poppins, system-ui, sans-serif', 'font-size': 13, 'font-weight': 700 })
|
||||
text.textContent = String(n)
|
||||
badge.appendChild(text)
|
||||
svg.appendChild(badge)
|
||||
document.body.appendChild(svg)
|
||||
},
|
||||
{ from, to, n },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* The release-notes modal greets every user once per version and its overlay
|
||||
* swallows every click underneath. clearNotices handles the plain notices; this
|
||||
* one has its own close button.
|
||||
*/
|
||||
export async function dismissReleaseNotice(page: Page): Promise<void> {
|
||||
const close = page.locator('.rn-close')
|
||||
await close.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {})
|
||||
for (let i = 0; i < 3 && (await close.isVisible().catch(() => false)); i++) {
|
||||
await close.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
await expect(page.locator('.rn-overlay')).toHaveCount(0)
|
||||
}
|
||||
|
||||
/** Fonts loaded, images decoded, transitions landed, network quiet (bounded: /ws never idles). */
|
||||
export async function settle(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
|
||||
await page.evaluate(async () => { await document.fonts.ready })
|
||||
// Bounded: a picture whose request never answers (a tile, a CDN flag) must
|
||||
// not hold the run; three seconds is longer than anything that does load.
|
||||
await page.evaluate(async () => {
|
||||
const pending = Array.from(document.images)
|
||||
.filter(img => !img.complete)
|
||||
.map(img => new Promise(res => { img.onload = img.onerror = res }))
|
||||
await Promise.race([Promise.all(pending), new Promise(res => setTimeout(res, 3000))])
|
||||
})
|
||||
await showLoadedTiles(page)
|
||||
await page.waitForTimeout(450)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the raster tiles that have arrived.
|
||||
*
|
||||
* Leaflet fades a tile in over 200 ms and works out how far it has got from
|
||||
* `+new Date()`. The clock is pinned to the picture day (see PICTURE_DAY), so
|
||||
* that difference never grows, the tile keeps the opacity 0 it was created
|
||||
* with, and the map is a grey rectangle in the picture even though every tile
|
||||
* came back with a 200. Nothing else on the page fades from the wall clock, so
|
||||
* this only ever touches tiles that are already loaded.
|
||||
*/
|
||||
async function showLoadedTiles(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
// A rule, not an inline style: Leaflet re-runs the fade on every frame and
|
||||
// writes the opacity back, so the only thing that holds is a stylesheet
|
||||
// that outranks it. `leaflet-tile-loaded` is Leaflet's own class, put on a
|
||||
// tile the moment its image arrives, so a tile still in flight stays hidden.
|
||||
if (!document.getElementById('trek-help-tiles')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'trek-help-tiles'
|
||||
style.textContent = '.leaflet-tile-loaded { opacity: 1 !important; }'
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
for (const tile of document.querySelectorAll('img.leaflet-tile')) {
|
||||
const img = tile as HTMLImageElement
|
||||
if (img.complete && img.naturalWidth > 0) img.classList.add('leaflet-tile-loaded')
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
# Copy to e2e/help/media.env (not committed). Only the guides that show
|
||||
# another service need these; every other guide runs without the file.
|
||||
#
|
||||
# The addresses must be the machine's LAN address: TREK's SSRF guard refuses
|
||||
# loopback whatever the flags say, and the two lines at the bottom let the
|
||||
# media backend reach a private network and find the booking extractor.
|
||||
|
||||
# AirTrail (Transports: Import from AirTrail). Key from Settings > Security.
|
||||
HELP_MEDIA_AIRTRAIL_URL=http://192.168.1.10:3020
|
||||
HELP_MEDIA_AIRTRAIL_API_KEY=
|
||||
|
||||
# Dawarich (Map: recorded route; Atlas: countries and wishes). Key from Account > API key.
|
||||
HELP_MEDIA_DAWARICH_URL=http://192.168.1.10:3000
|
||||
HELP_MEDIA_DAWARICH_API_KEY=
|
||||
|
||||
# Nextcloud (Files: Document sync). An app password, not the account password.
|
||||
HELP_MEDIA_NEXTCLOUD_URL=http://192.168.1.10:8803
|
||||
HELP_MEDIA_NEXTCLOUD_USER=admin
|
||||
HELP_MEDIA_NEXTCLOUD_PASSWORD=
|
||||
|
||||
# For the media backend (client/e2e/server-launch.mjs passes the environment on).
|
||||
ALLOW_INTERNAL_NETWORK=true
|
||||
# The booking extractor. On Linux the package's binary; on Windows a small
|
||||
# program that runs it in the TREK Docker image (see README).
|
||||
KITINERARY_EXTRACTOR_PATH=/usr/lib/x86_64-linux-gnu/libexec/kf6/kitinerary-extractor
|
||||
|
||||
# Optional: the day the seed and the pictures are relative to (YYYY-MM-DD).
|
||||
# Unset, every run takes today. Pin it while a picture set is made over
|
||||
# several runs, so every run shows the same calendar.
|
||||
# E2E_PICTURE_DAY=
|
||||
@@ -1,245 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { notificationsContext, notificationsGuides } from '../../src/help/contexts/notifications';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import { clearNotices } from '../screenshots/shot';
|
||||
import {
|
||||
BOOKING_TITLE,
|
||||
ensureNotificationFixtures,
|
||||
markUnread,
|
||||
removeNotificationFixtures,
|
||||
SHARE_TITLE,
|
||||
unreadIds,
|
||||
} from './fixtures-notifications';
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, settle, VIEWPORT, type GuideScript } from './guide';
|
||||
|
||||
/**
|
||||
* Actions for the notification guides, keyed by the ids in
|
||||
* `src/help/contexts/notifications.ts`. The admin's inbox holds the seed's
|
||||
* "New Message" notifications from the Japan trip's chat plus the two
|
||||
* `fixtures-notifications.ts` adds: jonas saved a booking, mira shared her
|
||||
* Vacay calendar. A guide that reads or deletes one of those gets it back
|
||||
* unread in its cleanup, and the file ends by taking both away, so the bell
|
||||
* every later picture shows counts what the seed left.
|
||||
*
|
||||
* Delete all is ringed but never clicked: it would also empty the seed's
|
||||
* notifications, and the bell of every later screen would lose its number.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = notificationsGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bell in the navbar. Found by its title: with unread notifications its
|
||||
* accessible name is the number on its badge, not "Notifications".
|
||||
*/
|
||||
const bell = (page: Page): Locator => page.getByTitle('Notifications', { exact: true }).first();
|
||||
/** The panel the bell opens, found through its footer button. */
|
||||
const panel = (page: Page): Locator => page.getByRole('button', { name: 'Show all notifications' }).locator('xpath=..');
|
||||
/** The column of the /notifications page: header, filter pills and the list. */
|
||||
const pageColumn = (page: Page): Locator => page.locator('.max-w-2xl').first();
|
||||
/** One notification (InAppNotificationItem's root) by its title, in the panel or the page. */
|
||||
const item = (scope: Locator, title: string): Locator =>
|
||||
scope.locator('div.relative.px-4.py-3').filter({ hasText: title }).first();
|
||||
/** The user menu button at the top right: avatar, name and chevron. */
|
||||
const userMenu = (page: Page): Locator =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.locator('span.max-w-40') })
|
||||
.first();
|
||||
/** The Settings sidebar, found by its first tab. */
|
||||
const settingsSidebar = (page: Page): Locator =>
|
||||
page
|
||||
.locator('nav')
|
||||
.filter({ has: page.getByRole('button', { name: 'General' }) })
|
||||
.first();
|
||||
|
||||
async function openDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard');
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await expect(bell(page)).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function openNotifications(page: Page): Promise<void> {
|
||||
await page.goto('/notifications');
|
||||
await clearNotices(page);
|
||||
await dismissReleaseNotice(page);
|
||||
await expect(item(pageColumn(page), SHARE_TITLE)).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function openBell(page: Page): Promise<void> {
|
||||
await bell(page).click();
|
||||
await expect(item(panel(page), SHARE_TITLE)).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The unread ones the mark-read guide found, to give back in its cleanup. */
|
||||
let unreadBefore: number[] = [];
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'read-notifications': {
|
||||
guide: guide('read-notifications'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{ target: bell, act: openBell },
|
||||
{
|
||||
target: panel,
|
||||
// Over the newest one, so its unread tint is not covered by the pointer.
|
||||
hover: async (p) => {
|
||||
await item(panel(p), SHARE_TITLE).hover();
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }),
|
||||
act: async (p) => {
|
||||
await item(panel(p), SHARE_TITLE).getByRole('button', { name: 'View Vacay' }).click();
|
||||
await expect(p).toHaveURL(/\/vacay/);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
// Following the share marked it read; the next guide wants it unread.
|
||||
cleanup: async (p) => {
|
||||
await ensureNotificationFixtures(p.request);
|
||||
},
|
||||
},
|
||||
|
||||
'filter-notifications': {
|
||||
guide: guide('filter-notifications'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{ target: bell, act: openBell },
|
||||
{
|
||||
target: (p) => p.getByRole('button', { name: 'Show all notifications' }),
|
||||
act: async (p) => {
|
||||
await p.getByRole('button', { name: 'Show all notifications' }).click();
|
||||
await expect(p).toHaveURL(/\/notifications/);
|
||||
await expect(item(pageColumn(p), SHARE_TITLE)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => pageColumn(p).getByRole('button', { name: 'Unread', exact: true }),
|
||||
act: async (p) => {
|
||||
await pageColumn(p).getByRole('button', { name: 'Unread', exact: true }).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'mark-notifications-read': {
|
||||
guide: guide('mark-notifications-read'),
|
||||
start: async (p) => {
|
||||
unreadBefore = await unreadIds(p.request);
|
||||
await openNotifications(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read'),
|
||||
act: async (p) => {
|
||||
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read').click();
|
||||
await expect(item(pageColumn(p), BOOKING_TITLE).getByTitle('Mark as read')).toHaveCount(0);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => pageColumn(p).getByRole('button', { name: 'Mark all read' }),
|
||||
act: async (p) => {
|
||||
await pageColumn(p).getByRole('button', { name: 'Mark all read' }).click();
|
||||
await expect(pageColumn(p).getByRole('button', { name: 'Mark all read' })).toHaveCount(0);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
await markUnread(p.request, unreadBefore);
|
||||
await ensureNotificationFixtures(p.request);
|
||||
},
|
||||
},
|
||||
|
||||
'delete-notifications': {
|
||||
guide: guide('delete-notifications'),
|
||||
start: openNotifications,
|
||||
steps: [
|
||||
{
|
||||
target: (p) => item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }),
|
||||
act: async (p) => {
|
||||
await item(pageColumn(p), BOOKING_TITLE).getByTitle('Delete', { exact: true }).click();
|
||||
await expect(pageColumn(p).locator('div.relative.px-4.py-3').filter({ hasText: BOOKING_TITLE })).toHaveCount(
|
||||
0
|
||||
);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
// Ringed only, see the file's comment.
|
||||
{ target: (p) => pageColumn(p).getByRole('button', { name: 'Delete all' }) },
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
await ensureNotificationFixtures(p.request);
|
||||
},
|
||||
},
|
||||
|
||||
'notification-settings': {
|
||||
guide: guide('notification-settings'),
|
||||
start: openNotifications,
|
||||
steps: [
|
||||
{
|
||||
target: userMenu,
|
||||
act: async (p) => {
|
||||
await userMenu(p).click();
|
||||
await expect(p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' })).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }),
|
||||
act: async (p) => {
|
||||
await p.locator('.trek-menu-enter').getByRole('link', { name: 'Settings' }).click();
|
||||
await expect(settingsSidebar(p)).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }),
|
||||
act: async (p) => {
|
||||
await settingsSidebar(p).getByRole('button', { name: 'Notifications', exact: true }).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureNotificationFixtures(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await removeNotificationFixtures(request);
|
||||
});
|
||||
|
||||
test('every registered notifications guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(notificationsContext.guides.slice().sort());
|
||||
});
|
||||
|
||||
test('hero: notifications', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, notificationsContext.id, openNotifications);
|
||||
});
|
||||
|
||||
for (const id of notificationsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Moves the help-center media from the staging directory into public/help-media/,
|
||||
// re-encoding the PNG captures to WebP on the way.
|
||||
//
|
||||
// Captures are 2x (3840px wide for a full frame). The panel shows a step
|
||||
// picture at ~600px and a full frame at ~1000px, on HiDPI displays, so 1440px
|
||||
// and 1920px keep them sharp at a fraction of the bytes. WebP rather than PNG
|
||||
// on purpose: the service worker precaches `png`, and help pictures must not
|
||||
// ride along in every visitor's first download.
|
||||
//
|
||||
// Usage: node e2e/help/promote.mjs [--dry]
|
||||
import sharp from 'sharp'
|
||||
import { readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
|
||||
const DEST = path.join(process.cwd(), 'public', 'help-media')
|
||||
const STEP_WIDTH = 1440
|
||||
const FRAME_WIDTH = 1920
|
||||
const dry = process.argv.includes('--dry')
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
console.error(`No media in ${SRC} — run \`npm run help:media\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
let before = 0
|
||||
let after = 0
|
||||
let files = 0
|
||||
|
||||
async function picture(src, dest, width) {
|
||||
const bytes = statSync(src).size
|
||||
before += bytes
|
||||
if (dry) {
|
||||
console.log(` ${path.relative(SRC, src)} → ${path.relative(DEST, dest)} (${(bytes / 1024).toFixed(0)} KB)`)
|
||||
return
|
||||
}
|
||||
const img = sharp(src)
|
||||
const meta = await img.metadata()
|
||||
const pipeline = (meta.width ?? 0) > width ? img.resize({ width }) : img
|
||||
await pipeline.webp({ quality: 84, effort: 5 }).toFile(dest)
|
||||
after += statSync(dest).size
|
||||
files++
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
const from = path.join(SRC, dir)
|
||||
if (!statSync(from).isDirectory() || dir.startsWith('.')) continue
|
||||
const to = path.join(DEST, dir)
|
||||
mkdirSync(to, { recursive: true })
|
||||
for (const file of readdirSync(from)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const mb = (n) => (n / 1024 / 1024).toFixed(1)
|
||||
console.log(dry ? `dry run: ${mb(before)} MB in staging` : `promoted ${files} files: ${mb(before)} MB → ${mb(after)} MB in ${path.relative(process.cwd(), DEST)}`)
|
||||
@@ -1,58 +0,0 @@
|
||||
// `npm run help:media`: the help-center media run on its own port pair, so it
|
||||
// can record while `npm run dev` keeps serving 5173/3001 to whoever is
|
||||
// watching. The ports travel as environment variables because Playwright's
|
||||
// workers re-read the config, and only the environment reaches all of them.
|
||||
//
|
||||
// Extra arguments go straight to Playwright, e.g.
|
||||
// npm run help:media -- --grep "pictures"
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
// The guides that show another service (AirTrail, Dawarich, a document store,
|
||||
// the booking extractor) take its address and key from e2e/help/media.env,
|
||||
// KEY=VALUE per line, which is not committed. media.env.example lists them.
|
||||
// The file is read here so the backend inherits it too (server-launch.mjs
|
||||
// spreads this environment): ALLOW_INTERNAL_NETWORK and the extractor path
|
||||
// live in the same place.
|
||||
function mediaEnv() {
|
||||
const file = path.join(process.cwd(), 'e2e', 'help', 'media.env')
|
||||
if (!existsSync(file)) return {}
|
||||
const out = {}
|
||||
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq < 1) continue
|
||||
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"(.*)"$/, '$1')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The day the pictures are taken on, fixed once for the whole run so the seed
|
||||
// project and every worker agree even across midnight (see e2e/dates.ts).
|
||||
function today() {
|
||||
const d = new Date()
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const fromFile = mediaEnv()
|
||||
const env = {
|
||||
...fromFile,
|
||||
...process.env,
|
||||
E2E_WEB_PORT: process.env.E2E_WEB_PORT || '5183',
|
||||
E2E_API_PORT: process.env.E2E_API_PORT || '3011',
|
||||
// The shell wins, then media.env (a day pinned there keeps a picture set
|
||||
// consistent across several runs), then today.
|
||||
E2E_PICTURE_DAY: process.env.E2E_PICTURE_DAY || fromFile.E2E_PICTURE_DAY || today(),
|
||||
}
|
||||
// The CLI's own entry, run by this node: no shell in between, so a --grep
|
||||
// pattern with a `|` reaches Playwright untouched.
|
||||
const cli = createRequire(import.meta.url).resolve('@playwright/test/cli')
|
||||
const res = spawnSync(process.execPath, [cli, 'test', '--project=help-media', ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
})
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -1,563 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ensureAirtrailFlights, ensureDawarichRecording, disconnectAirtrail, disconnectDawarich, toggleAddon } from './external'
|
||||
import {
|
||||
mediaEnv, wandererUrl, wandererToken, allowWanderer, ensureWandererConnection, disconnectWanderer,
|
||||
ensureActivePlugin, removeSideloadedPlugin, parkOfflineChanges, clearParkedChanges,
|
||||
} from './fixtures-settings'
|
||||
import { settingsContext, settingsTabContexts, settingsGuides } from '../../src/help/contexts/settings'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the settings screens, keyed by the ids in
|
||||
* `src/help/contexts/settings.ts`. One route, one tab per screen; most guides
|
||||
* only frame a card or a row, and the few that change something (theme, an API
|
||||
* key, two-factor authentication) leave the account in a state the run can live
|
||||
* 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> = {
|
||||
'settings-display': 'General',
|
||||
'settings-appearance': 'Appearance',
|
||||
'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 => {
|
||||
const g = settingsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'General' }) }).first()
|
||||
/** A settings card by the heading of its section. */
|
||||
const section = (page: Page, title: string | 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')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
|
||||
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const opener = (contextId: string) => (page: Page) => openSettings(page, TAB_OF[contextId])
|
||||
|
||||
/** RFC 6238 code for the secret the setup shows, so the run can enable 2FA for real. */
|
||||
function totp(secretBase32: string): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const clean = secretBase32.toUpperCase().replace(/[^A-Z2-7]/g, '')
|
||||
let bits = ''
|
||||
for (const ch of clean) bits += alphabet.indexOf(ch).toString(2).padStart(5, '0')
|
||||
const bytes = Buffer.from((bits.match(/.{8}/g) ?? []).map(b => parseInt(b, 2)))
|
||||
const counter = Buffer.alloc(8)
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)))
|
||||
const mac = createHmac('sha1', bytes).update(counter).digest()
|
||||
const offset = mac[mac.length - 1] & 0x0f
|
||||
const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]
|
||||
return String(code % 1_000_000).padStart(6, '0')
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* 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 ──────────────────────────────────────────────────────────────
|
||||
startup: {
|
||||
guide: guide('startup'),
|
||||
start: opener('settings-display'),
|
||||
steps: [
|
||||
{
|
||||
target: p => field(p, 'Startup', 'Start page').locator('xpath=..'),
|
||||
// The start tab only exists once the app opens on the active trip.
|
||||
act: async p => { await section(p, 'Startup').getByRole('button', { name: 'Active trip' }).click(); await settle(p) },
|
||||
},
|
||||
only(p => field(p, 'Startup', 'Start tab').locator('xpath=..')),
|
||||
],
|
||||
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
|
||||
},
|
||||
'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': {
|
||||
guide: guide('theme-scheme'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
{
|
||||
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 => row(p, 'Theme', 'Color scheme'),
|
||||
// Custom brings up the accent row the next step is about.
|
||||
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => 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 => {
|
||||
await section(p, 'Theme').getByRole('button', { name: 'Default', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
readability: {
|
||||
guide: guide('readability'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => rows(p, 'Readability', 'Transparency')),
|
||||
only(p => row(p, 'Readability', 'Text size')),
|
||||
],
|
||||
},
|
||||
'dashboard-widgets': {
|
||||
guide: guide('dashboard-widgets'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => label(p, 'Dashboard widgets', 'Desktop').locator('xpath=ancestor::section[1]')),
|
||||
only(p => label(p, 'Dashboard widgets', 'Mobile').locator('xpath=ancestor::section[1]')),
|
||||
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Map ──────────────────────────────────────────────────────────────────
|
||||
'map-provider': {
|
||||
guide: guide('map-provider'),
|
||||
start: opener('settings-map'),
|
||||
steps: [
|
||||
only(p => row(p, /^Map$/, 'Map Provider')),
|
||||
only(p => section(p, /^Map$/).getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
|
||||
{
|
||||
// The Mapbox rows only exist with Mapbox GL picked; nothing is saved, the reload in cleanup drops it.
|
||||
prepare: async p => { await section(p, /^Map$/).getByRole('button', { name: /Mapbox GL/ }).click(); await settle(p) },
|
||||
target: p => rows(p, /^Map$/, '3D Buildings & Terrain'),
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.reload(); await settle(p) },
|
||||
},
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
'notification-channels': {
|
||||
guide: guide('notification-channels'),
|
||||
start: opener('settings-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Webhook URL')),
|
||||
only(p => section(p, 'Ntfy')),
|
||||
// The per-event matrix is the first card, headed Notifications.
|
||||
only(p => section(p, /^Notifications$/)),
|
||||
],
|
||||
},
|
||||
'web-push-device': {
|
||||
guide: guide('web-push-device'),
|
||||
start: opener('settings-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Push notifications on this device')),
|
||||
only(p => section(p, /^Notifications$/)),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Integrations ─────────────────────────────────────────────────────────
|
||||
'photo-providers': {
|
||||
guide: guide('photo-providers'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
only(p => section(p, 'Immich')),
|
||||
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=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: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
|
||||
await expect(dialog(p).getByText('Create API key')).toBeVisible()
|
||||
await typeInto(p, dialog(p).getByPlaceholder('e.g. Home dashboard'), 'Home Assistant')
|
||||
// A key for a dashboard only needs the trips and their days.
|
||||
for (const area of ['Bookings', 'Accommodation', 'Who is coming', 'Wishlist', 'Totals']) {
|
||||
await dialog(p).getByRole('checkbox', { name: area }).click()
|
||||
}
|
||||
await settle(p)
|
||||
},
|
||||
target: p => dialog(p),
|
||||
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 => dialog(p),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(p.getByRole('dialog')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'mcp-oauth': {
|
||||
guide: guide('mcp-oauth'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
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 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')),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Offline ──────────────────────────────────────────────────────────────
|
||||
'offline-prepare': {
|
||||
guide: guide('offline-prepare'),
|
||||
start: opener('settings-offline'),
|
||||
steps: [
|
||||
only(p => section(p, 'What to store offline')),
|
||||
only(p => p.getByRole('button', { name: 'Download for offline use' })),
|
||||
only(p => row(p, 'Offline mode', 'Force offline mode')),
|
||||
],
|
||||
},
|
||||
'offline-conflicts': {
|
||||
guide: guide('offline-conflicts'),
|
||||
// The conflicts card only exists while a change waits; two are parked in this browser's queue first.
|
||||
start: async p => {
|
||||
await openSettings(p, 'General')
|
||||
await parkOfflineChanges(p)
|
||||
await sidebar(p).getByRole('button', { name: 'Offline', exact: true }).click()
|
||||
await expect(section(p, 'Sync conflicts')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
only(p => section(p, 'Sync conflicts').getByRole('button', { name: 'Keep mine' }).locator('xpath=ancestor::div[contains(@class,"divide-y")][1]')),
|
||||
only(p => row(p, 'Sync conflicts', 'When a conflict happens')),
|
||||
only(p => section(p, 'Offline cache').getByRole('button', { name: 'Try again' }).locator('xpath=../..')),
|
||||
],
|
||||
cleanup: async p => { await clearParkedChanges(p); await p.reload(); await settle(p) },
|
||||
},
|
||||
|
||||
// ── Account ──────────────────────────────────────────────────────────────
|
||||
profile: {
|
||||
guide: guide('profile'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
// The identity strip: the picture with its camera, the name and the role.
|
||||
only(p => section(p, 'Account').locator('div[class*="rounded-[14px]"]').first()),
|
||||
only(p => fieldPair(p, 'account-username')),
|
||||
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
|
||||
],
|
||||
},
|
||||
password: {
|
||||
guide: guide('password'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
// A few characters, so the checklist shows rules met and rules still open.
|
||||
await typeInto(p, p.locator('#account-new-password'), 'Kyoto2026')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'Change Password'),
|
||||
},
|
||||
only(p => section(p, 'Change Password').getByRole('button', { name: 'Update password' })),
|
||||
],
|
||||
cleanup: async p => { await p.reload(); await settle(p) },
|
||||
},
|
||||
mfa: {
|
||||
guide: guide('mfa'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }),
|
||||
act: async p => {
|
||||
await subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }).click()
|
||||
await expect(subsection(p, 'Two-factor authentication (2FA)').locator('code')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByPlaceholder('6-digit code').locator('xpath=../..'),
|
||||
act: async p => {
|
||||
const mfa = subsection(p, 'Two-factor authentication (2FA)')
|
||||
const secret = (await mfa.locator('code').first().innerText()).trim()
|
||||
await typeInto(p, mfa.getByPlaceholder('6-digit code'), totp(secret))
|
||||
await mfa.getByRole('button', { name: 'Enable 2FA' }).click()
|
||||
await expect(mfa.getByText('Backup codes').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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=../..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
passkeys: {
|
||||
guide: guide('passkeys'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
// A passkey needs a device prompt the run cannot answer, so this guide has no pictures.
|
||||
{},
|
||||
{},
|
||||
],
|
||||
},
|
||||
'delete-account': {
|
||||
guide: guide('delete-account'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => p.getByRole('button', { name: 'Delete account' }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const ALL_CONTEXTS = [settingsContext, ...settingsTabContexts]
|
||||
|
||||
/** Whether this file sideloaded a plugin for the Plugins tab and has to take it away again. */
|
||||
let sideloadedPlugin = false
|
||||
|
||||
// The photo-provider sections exist only for providers the admin switched on, the ntfy,
|
||||
// webhook and push cards only for channels the admin turned on, and the Plugins tab only
|
||||
// while a plugin is active.
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const res = await request.put('/api/admin/addons/immich', { data: { enabled: true } })
|
||||
if (!res.ok()) throw new Error(`could not enable the Immich provider: ${res.status()} ${await res.text()}`)
|
||||
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook,push' } })
|
||||
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 () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
|
||||
})
|
||||
|
||||
test('hero: settings', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, settingsContext.id, p => openSettings(p, 'General'))
|
||||
})
|
||||
|
||||
for (const ctx of settingsTabContexts) {
|
||||
test(`hero: ${ctx.id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, ctx.id, opener(ctx.id))
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { at, day } from '../dates';
|
||||
import { setTripCover, type CoverPhoto } from '../screenshots/photos';
|
||||
import { EXTRA_TRIPS, SPARE_TRIP, ensureExtraTrips } from './fixtures';
|
||||
|
||||
/**
|
||||
* The trips around the seeded one, made to look lived in.
|
||||
*
|
||||
* "Autumn in Japan" is the trip every guide works on, but the dashboard, the
|
||||
* journey and the collections pictures show the others too, and a grid of
|
||||
* grey cards with no plan in them reads as an empty demo. So the other trips
|
||||
* get a cover photo, a few real places on their days and the bookings a
|
||||
* traveller makes months ahead: the flight and the hotel for Lisbon, the
|
||||
* ferry and the car for Norway. Those are also what the dashboard's Upcoming
|
||||
* reservations widget lists, which stayed empty while the only bookings were
|
||||
* on a trip that ends today.
|
||||
*
|
||||
* Runs once after the seed. Everything is looked up by title or name first,
|
||||
* so a second run changes nothing.
|
||||
*/
|
||||
|
||||
interface ShowcasePlace {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
address: string;
|
||||
description: string;
|
||||
/** Index of the trip day, or none for an idea not planned yet. */
|
||||
day?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ShowcaseBooking {
|
||||
title: string;
|
||||
type: string;
|
||||
status: 'confirmed' | 'pending';
|
||||
/** Days from the picture day and a local time, through `at`. */
|
||||
starts: [number, string];
|
||||
ends?: [number, string];
|
||||
confirmation_number?: string;
|
||||
location?: string;
|
||||
metadata?: Record<string, string>;
|
||||
endpoints?: Array<{
|
||||
role: 'from' | 'to';
|
||||
name: string;
|
||||
code?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string;
|
||||
date: number;
|
||||
time: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ShowcaseTrip {
|
||||
title: string;
|
||||
cover: CoverPhoto;
|
||||
places: ShowcasePlace[];
|
||||
bookings: ShowcaseBooking[];
|
||||
}
|
||||
|
||||
const LISBON_START = 25;
|
||||
const NORWAY_START = 257;
|
||||
|
||||
const SHOWCASE: ShowcaseTrip[] = [
|
||||
{
|
||||
title: EXTRA_TRIPS[0].title,
|
||||
cover: 'lisbon',
|
||||
places: [
|
||||
{
|
||||
name: 'Torre de Belém',
|
||||
lat: 38.6916,
|
||||
lng: -9.216,
|
||||
address: 'Av. Brasília, Lisbon',
|
||||
description: 'Sixteenth-century tower on the Tagus, the start of the voyages of discovery.',
|
||||
day: 1,
|
||||
start: '10:00',
|
||||
end: '11:00',
|
||||
category: 'Attraction',
|
||||
},
|
||||
{
|
||||
name: 'Pastéis de Belém',
|
||||
lat: 38.6975,
|
||||
lng: -9.2032,
|
||||
address: 'R. de Belém 84, Lisbon',
|
||||
description: 'The original custard tarts since 1837, eaten warm with cinnamon.',
|
||||
day: 1,
|
||||
start: '11:15',
|
||||
end: '11:45',
|
||||
category: 'Bar/Cafe',
|
||||
},
|
||||
{
|
||||
name: 'Miradouro da Senhora do Monte',
|
||||
lat: 38.7193,
|
||||
lng: -9.1327,
|
||||
address: 'Largo Monte, Lisbon',
|
||||
description: 'The highest viewpoint over the old town, best at sunset.',
|
||||
day: 0,
|
||||
start: '19:00',
|
||||
end: '20:00',
|
||||
category: 'Attraction',
|
||||
},
|
||||
{
|
||||
name: 'Palácio da Pena',
|
||||
lat: 38.7876,
|
||||
lng: -9.3906,
|
||||
address: 'Estrada da Pena, Sintra',
|
||||
description: 'The painted palace on the hill above Sintra, a day trip by train.',
|
||||
day: 2,
|
||||
start: '10:30',
|
||||
end: '13:00',
|
||||
category: 'Attraction',
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
title: 'TP579 FRA → LIS',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
starts: [LISBON_START, '09:40'],
|
||||
ends: [LISBON_START, '11:35'],
|
||||
confirmation_number: 'K4R7TW',
|
||||
location: 'Frankfurt Airport',
|
||||
metadata: {
|
||||
airline: 'TAP Air Portugal',
|
||||
flight_number: 'TP579',
|
||||
departure_airport: 'FRA',
|
||||
arrival_airport: 'LIS',
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
name: 'Frankfurt Airport',
|
||||
code: 'FRA',
|
||||
lat: 50.0379,
|
||||
lng: 8.5622,
|
||||
timezone: 'Europe/Berlin',
|
||||
date: LISBON_START,
|
||||
time: '09:40',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
name: 'Lisbon Humberto Delgado',
|
||||
code: 'LIS',
|
||||
lat: 38.7742,
|
||||
lng: -9.1342,
|
||||
timezone: 'Europe/Lisbon',
|
||||
date: LISBON_START,
|
||||
time: '11:35',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Memmo Alfama Hotel',
|
||||
type: 'hotel',
|
||||
status: 'confirmed',
|
||||
starts: [LISBON_START, '15:00'],
|
||||
ends: [LISBON_START + 2, '11:00'],
|
||||
confirmation_number: 'MA-88213',
|
||||
location: 'Travessa das Merceeiras 27, Lisbon',
|
||||
},
|
||||
{
|
||||
title: 'Train to Sintra',
|
||||
type: 'train',
|
||||
status: 'pending',
|
||||
starts: [LISBON_START + 2, '09:11'],
|
||||
ends: [LISBON_START + 2, '09:52'],
|
||||
location: 'Rossio Station',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: EXTRA_TRIPS[1].title,
|
||||
cover: 'norway',
|
||||
places: [
|
||||
{
|
||||
name: 'Bryggen',
|
||||
lat: 60.3976,
|
||||
lng: 5.3247,
|
||||
address: 'Bryggen, Bergen',
|
||||
description: 'The Hanseatic wharf with its leaning wooden warehouses.',
|
||||
day: 0,
|
||||
start: '16:00',
|
||||
end: '17:30',
|
||||
category: 'Attraction',
|
||||
},
|
||||
{
|
||||
name: 'Trollstigen',
|
||||
lat: 62.4549,
|
||||
lng: 7.6699,
|
||||
address: 'Trollstigen, Rauma',
|
||||
description: 'Eleven hairpins up the mountain road, with a viewing platform at the top.',
|
||||
day: 4,
|
||||
start: '11:00',
|
||||
end: '12:00',
|
||||
category: 'Nature',
|
||||
},
|
||||
{
|
||||
name: 'Reine',
|
||||
lat: 67.9324,
|
||||
lng: 13.0886,
|
||||
address: 'Reine, Moskenes',
|
||||
description: 'Red fishing cabins under granite peaks in the Lofoten.',
|
||||
category: 'Nature',
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
title: 'Rental car, Bergen airport',
|
||||
type: 'car',
|
||||
status: 'confirmed',
|
||||
starts: [NORWAY_START, '13:00'],
|
||||
ends: [NORWAY_START + 14, '10:00'],
|
||||
confirmation_number: 'HZ-5521907',
|
||||
location: 'Bergen Airport Flesland',
|
||||
},
|
||||
{
|
||||
title: 'Ferry Bodø → Moskenes',
|
||||
type: 'ferry',
|
||||
status: 'pending',
|
||||
starts: [NORWAY_START + 10, '15:00'],
|
||||
ends: [NORWAY_START + 10, '18:30'],
|
||||
location: 'Bodø ferry terminal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ title: SPARE_TRIP.title, cover: 'alps', places: [], bookings: [] },
|
||||
];
|
||||
|
||||
type Trip = { id: number; title: string; cover_image?: string | null };
|
||||
|
||||
async function list<T>(api: APIRequestContext, url: string, key: string): Promise<T[]> {
|
||||
const res = await api.get(url);
|
||||
if (!res.ok()) throw new Error(`GET ${url}: ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as Record<string, T[]> | T[];
|
||||
return Array.isArray(body) ? body : (body[key] ?? []);
|
||||
}
|
||||
|
||||
async function post(api: APIRequestContext, url: string, data: unknown): Promise<Record<string, unknown>> {
|
||||
const res = await api.post(url, { data });
|
||||
if (!res.ok()) throw new Error(`POST ${url}: ${res.status()} ${await res.text()}`);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function fillTrip(
|
||||
api: APIRequestContext,
|
||||
trip: Trip,
|
||||
spec: ShowcaseTrip,
|
||||
categories: { id: number; name: string }[]
|
||||
): Promise<void> {
|
||||
if (!trip.cover_image) await setTripCover(api, trip.id, spec.cover);
|
||||
|
||||
const days = await list<{ id: number }>(api, `/api/trips/${trip.id}/days`, 'days');
|
||||
const places = await list<{ name: string }>(api, `/api/trips/${trip.id}/places`, 'places');
|
||||
for (const place of spec.places) {
|
||||
if (places.some((p) => p.name === place.name)) continue;
|
||||
const { day: dayIndex, start, end, category, ...payload } = place;
|
||||
const categoryId = categories.find((c) => c.name === category)?.id;
|
||||
const created = (await post(api, `/api/trips/${trip.id}/places`, {
|
||||
...payload,
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
})) as { place: { id: number } };
|
||||
const dayId = dayIndex === undefined ? undefined : days[dayIndex]?.id;
|
||||
if (!dayId) continue;
|
||||
const assigned = (await post(api, `/api/trips/${trip.id}/days/${dayId}/assignments`, {
|
||||
place_id: created.place.id,
|
||||
})) as { assignment: { id: number } };
|
||||
if (start) {
|
||||
const res = await api.put(`/api/trips/${trip.id}/assignments/${assigned.assignment.id}/time`, {
|
||||
data: { place_time: start, end_time: end ?? null },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`time for ${place.name}: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const bookings = await list<{ title: string }>(api, `/api/trips/${trip.id}/reservations`, 'reservations');
|
||||
for (const booking of spec.bookings) {
|
||||
if (bookings.some((b) => b.title === booking.title)) continue;
|
||||
const { starts, ends, endpoints, ...rest } = booking;
|
||||
await post(api, `/api/trips/${trip.id}/reservations`, {
|
||||
...rest,
|
||||
reservation_time: at(starts[0], starts[1]),
|
||||
...(ends ? { reservation_end_time: at(ends[0], ends[1]) } : {}),
|
||||
...(endpoints
|
||||
? {
|
||||
endpoints: endpoints.map(({ date, time, ...e }, sequence) => ({
|
||||
...e,
|
||||
sequence,
|
||||
local_date: day(date),
|
||||
local_time: time,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureShowcase(api: APIRequestContext): Promise<void> {
|
||||
await ensureExtraTrips(api);
|
||||
let trips = await list<Trip>(api, '/api/trips', 'trips');
|
||||
if (!trips.some((t) => t.title === SPARE_TRIP.title)) {
|
||||
await post(api, '/api/trips', SPARE_TRIP);
|
||||
trips = await list<Trip>(api, '/api/trips', 'trips');
|
||||
}
|
||||
const categories = await list<{ id: number; name: string }>(api, '/api/categories', 'categories');
|
||||
for (const spec of SHOWCASE) {
|
||||
const trip = trips.find((t) => t.title === spec.title);
|
||||
if (trip) await fillTrip(api, trip, spec, categories);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,906 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { day } from '../dates'
|
||||
import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures'
|
||||
import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
|
||||
import { requireExtractor, allowEmlUploads } from './external'
|
||||
import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Bookings tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with `ensureBookingsFixtures` in front of them, because the seed's only
|
||||
* reservation is a flight and a flight is a transport: without the fixture this
|
||||
* tab is its empty state. What a guide creates or changes it puts back in
|
||||
* `cleanup`, mostly by deleting the booking and letting the fixture build it
|
||||
* again, so the next one starts from the same seven cards.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripBookingsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The tab id in the address is the legacy German one; its label is Bookings. */
|
||||
const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' })
|
||||
|
||||
/** A booking's card in the Cards view: an article named after the booking. */
|
||||
const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
||||
/** The pencil and the bin in a card's head band, named by their tooltips. */
|
||||
const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') =>
|
||||
bookingCard(page, title).getByRole('button', { name, exact: true })
|
||||
|
||||
/** Open a booking's editor from its card, the way the texts tell the reader to. */
|
||||
async function editFromCard(page: Page, title: string): Promise<void> {
|
||||
await cardAction(page, title, 'Edit').click()
|
||||
await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── The bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The tab's one bar: its heading, then search, filter, the views and the add buttons. */
|
||||
const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..')
|
||||
const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true })
|
||||
/** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */
|
||||
const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ })
|
||||
const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true })
|
||||
/** The filter panel under the funnel: Status, Type and Travelers. */
|
||||
const filterMenu = (page: Page) => page.getByRole('menu')
|
||||
const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true })
|
||||
const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true })
|
||||
|
||||
// ── Timeline and detail ───────────────────────────────────────────────────────
|
||||
|
||||
/** 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. */
|
||||
const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) })
|
||||
/** A bar of the timeline, named after its booking. */
|
||||
const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true })
|
||||
/** The booking's detail popup, labelled by its title. */
|
||||
const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
|
||||
/** The booking the views guide opens: a tour on day 5, with a place and travellers. */
|
||||
const VIEWED = { title: 'Fushimi Inari night walk', day: 5 }
|
||||
|
||||
// ── The editor ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A field of the booking form, by its own label; the block around it is the label's parent. */
|
||||
const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first()
|
||||
const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..')
|
||||
/** The two-column or three-column row a field shares with its neighbours. */
|
||||
const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..')
|
||||
/** A labelled block of a dialog's body whose label is not a `<label>` (Who paid?). */
|
||||
const section = (page: Page, name: string) =>
|
||||
modal(page).locator('section').filter({ has: page.getByText(name, { exact: true }) }).first()
|
||||
|
||||
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
|
||||
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
|
||||
|
||||
/** The title is typed into the head band; the field keeps the old placeholder. */
|
||||
const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')
|
||||
const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345')
|
||||
const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00')
|
||||
const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true })
|
||||
|
||||
/** The card's delete question is its own portal, with no backdrop class to find it by. */
|
||||
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
|
||||
|
||||
/** The Booking Type is a pill in the head band; its name reads the type after the label. */
|
||||
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
|
||||
/** The pill's list is its own portal, named after the pill's label. */
|
||||
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
|
||||
/** The status pill beside it says what a click turns it into. */
|
||||
const statusPill = (page: Page) => modal(page).getByRole('button', { name: /^Set to / })
|
||||
|
||||
async function openTypeMenu(page: Page): Promise<void> {
|
||||
await typePill(page).click()
|
||||
await expect(typeMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
async function pickType(page: Page, type: RegExp): Promise<void> {
|
||||
await typeMenu(page).getByRole('button', { name: type }).first().click()
|
||||
await expect(typeMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function openManualBooking(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(page).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** The Travelers field opens its members as a list under it, inside the form. */
|
||||
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
|
||||
|
||||
/** Open the CustomSelect that belongs to a label and take one option out of it. */
|
||||
async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise<void> {
|
||||
await block(page, field).getByRole('button').first().click()
|
||||
await expect(selectMenu(page)).toBeVisible()
|
||||
if (search) await selectMenu(page).locator('input').fill(search)
|
||||
await beat(page, 300)
|
||||
await selectMenu(page).getByRole('button', { name: option }).first().click()
|
||||
await expect(selectMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a date without touching the calendar: every date field has a keyboard
|
||||
* button beside it that swaps in a 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).getByRole('textbox', { name: 'Enter date manually' })
|
||||
await box.fill(value)
|
||||
await box.press('Enter')
|
||||
await expect(box).toHaveCount(0)
|
||||
}
|
||||
|
||||
interface Booking {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
async function bookingsOf(page: Page): Promise<Booking[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
|
||||
const body = (await res.json()) as { reservations?: Booking[] } | Booking[]
|
||||
return Array.isArray(body) ? body : (body.reservations ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete bookings by title. The route takes the stay of an accommodation and
|
||||
* the expense linked to a booking with it, so this undoes those too.
|
||||
*/
|
||||
async function deleteBookings(page: Page, ...titles: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const booking of (await bookingsOf(page)).filter(b => titles.includes(b.title))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/reservations/${booking.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put a changed booking back: drop it, then let the fixture build it again. */
|
||||
async function restore(page: Page, ...titles: string[]): Promise<void> {
|
||||
await deleteBookings(page, ...titles)
|
||||
await ensureBookingsFixtures(page.request)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the tab remembers: the filters per trip for the browser tab, the view,
|
||||
* its grouping, sorting and timeline zoom, and the folded sections in the
|
||||
* browser. Every test has a context of its own, so this is only tidiness for a
|
||||
* guide that runs another after it in the same page.
|
||||
*/
|
||||
async function clearPanelState(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
await page.evaluate(id => {
|
||||
try {
|
||||
sessionStorage.removeItem(`trek-bookings-filters-bookings-${id}`)
|
||||
for (const name of ['view', 'group', 'sort', 'timeline', 'transitApart']) localStorage.removeItem(`trek:bookings-bookings-${name}`)
|
||||
localStorage.removeItem(`trek:bookings-bookings-collapsed:${id}`)
|
||||
localStorage.removeItem('trek.bg-import-tasks')
|
||||
} catch {
|
||||
/* a browser that refuses storage has nothing to clear */
|
||||
}
|
||||
}, tripId)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'booking-views': {
|
||||
guide: guide('booking-views'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
// The three icons are the subject; the pointer rests on List so its name shows.
|
||||
target: viewSwitch,
|
||||
hover: async p => { await viewButton(p, 'List').hover() },
|
||||
act: async p => {
|
||||
await viewButton(p, 'List').click()
|
||||
await expect(viewButton(p, 'List')).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(p.getByRole('button', { name: VIEWED.title, exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => viewButton(p, 'Timeline'),
|
||||
act: async p => {
|
||||
await viewButton(p, 'Timeline').click()
|
||||
await expect(zoom(p)).toBeVisible()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Trip', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => dayHeading(p, VIEWED.day),
|
||||
act: async p => {
|
||||
await dayHeading(p, VIEWED.day).click()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Day', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(timelineBar(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(zoom),
|
||||
{
|
||||
// Hovered for the picture: the bar's card of facts stands beside it.
|
||||
target: p => timelineBar(p, VIEWED.title),
|
||||
act: async p => {
|
||||
await timelineBar(p, VIEWED.title).click()
|
||||
await expect(detail(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => detail(p, VIEWED.title)),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await clearPanelState(p)
|
||||
},
|
||||
},
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Manual Booking' }),
|
||||
act: openManualBooking,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await pickType(p, /^Event$/)
|
||||
await expect(typePill(p)).toHaveAccessibleName(/^Booking Type:\s*Event$/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') },
|
||||
target: titleBox,
|
||||
act: async p => {
|
||||
await expect(saveButton(p, 'Add')).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await setDate(p, 0, day(-2))
|
||||
await timeBox(p).nth(0).fill('16:30')
|
||||
await setDate(p, 1, day(-2))
|
||||
await timeBox(p).nth(1).fill('20:00')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, /^Date$/),
|
||||
act: async p => {
|
||||
await expect(saveButton(p, 'Add')).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, codeBox(p), 'MZ-5521')
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
},
|
||||
target: p => row(p, /^Booking Code$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Add'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kabuki at the Minamiza')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteBookings(p, 'Kabuki at the Minamiza'),
|
||||
},
|
||||
'booking-hotel': {
|
||||
guide: guide('booking-hotel'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await openManualBooking(p)
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await pickType(p, /^Accommodation$/)
|
||||
await expect(label(p, /^Check-in until$/)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await chooseIn(p, /^Accommodation$/, /^Hotel Granvia Kyoto$/, 'Granvia') },
|
||||
target: p => block(p, /^Accommodation$/),
|
||||
act: async p => {
|
||||
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await chooseIn(p, /^From$/, /^Day 5 /)
|
||||
await chooseIn(p, /^To$/, /^Day 8 /)
|
||||
},
|
||||
target: p => row(p, /^To$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await timeBox(p).nth(0).fill('15:00')
|
||||
await timeBox(p).nth(1).fill('23:00')
|
||||
await timeBox(p).nth(2).fill('11:00')
|
||||
await typeInto(p, codeBox(p), 'GRK-40218')
|
||||
},
|
||||
target: p => row(p, /^Check-in$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Add'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteBookings(p, HOTEL_TO_BOOK.name),
|
||||
},
|
||||
'link-booking': {
|
||||
guide: guide('link-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'teamLab Planets timed entry', 'Edit'),
|
||||
act: p => editFromCard(p, 'teamLab Planets timed entry'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Link to day assignment$/).getByRole('button').first().click()
|
||||
await expect(selectMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: selectMenu,
|
||||
act: async p => {
|
||||
await selectMenu(p).getByRole('button', { name: /^2\. teamLab Planets/ }).click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(block(p, /^Link to day assignment$/).getByRole('button', { name: /teamLab Planets/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await chooseIn(p, /^Place \/ Activity$/, /^teamLab Planets$/, 'teamLab') },
|
||||
target: p => block(p, /^Place \/ Activity$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Linked to', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => restore(p, 'teamLab Planets timed entry'),
|
||||
},
|
||||
'booking-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,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'Fushimi Inari night walk')
|
||||
await expect(label(p, /^Travelers$/)).toBeVisible()
|
||||
},
|
||||
target: p => block(p, /^Travelers$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Travelers$/).getByRole('button', { expanded: false }).click()
|
||||
await expect(travelerList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: travelerList,
|
||||
act: async p => {
|
||||
const person = travelerList(p).getByRole('button', { name: /jonas/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
// A click beside the field folds the list away; Escape would close the whole form.
|
||||
await label(p, /^Travelers$/).click()
|
||||
await expect(travelerList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The people are a row of chips under the panel's Travelers caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: /jonas$/ }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
const person = filterMenu(p).getByRole('button', { name: /jonas$/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await clearPanelState(p)
|
||||
await restore(p, 'Fushimi Inari night walk')
|
||||
},
|
||||
},
|
||||
'booking-files': {
|
||||
guide: guide('booking-files'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'teamLab Planets timed entry')
|
||||
await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible()
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Attach file' }),
|
||||
act: async p => {
|
||||
// Set the hidden input directly: clicking the button opens the file
|
||||
// picker of the operating system, which Playwright cannot photograph.
|
||||
await modal(p).locator('input[type="file"]').setInputFiles(bookingPdfFixture('teamlab-tickets'))
|
||||
await expect(modal(p).getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('teamlab-tickets.pdf').locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Link existing file' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Link existing file' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true }).click()
|
||||
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toHaveCount(0)
|
||||
await expect(modal(p).getByText(UNATTACHED_FILE)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// Deleting the booking takes the link row to the voucher with it, so the
|
||||
// voucher is unattached again and Link existing file offers it next time.
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/files`)
|
||||
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of files.filter(f => f.original_name === 'teamlab-tickets.pdf')) {
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
}
|
||||
await restore(p, 'teamLab Planets timed entry')
|
||||
},
|
||||
},
|
||||
'booking-cost': {
|
||||
guide: guide('booking-cost'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible()
|
||||
},
|
||||
target: p => block(p, /^Costs$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create expense' }).click()
|
||||
await expect(p.getByRole('heading', { name: 'Add expense' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Total amount$/).locator('input').first().fill('12000')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, /^Total amount$/),
|
||||
act: settle,
|
||||
},
|
||||
only(p => section(p, 'Who paid?')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }),
|
||||
act: async p => {
|
||||
const save = modal(p).getByRole('button', { name: 'Add expense', exact: true })
|
||||
// The name comes prefilled from the booking; without it the button is dead.
|
||||
await expect(save).toBeEnabled()
|
||||
await save.click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
// The block only reads Linked expenses once the booking is opened again.
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await restore(p, 'Kyoto Cycling Tour')
|
||||
},
|
||||
},
|
||||
'filter-bookings': {
|
||||
guide: guide('filter-bookings'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, searchBox(p), 'Kyoto')
|
||||
await expect(resultsChip(p)).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
// The box around the field, with its magnifier.
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await searchBox(p).press('Escape')
|
||||
await expect(searchBox(p)).toHaveValue('')
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: filterButton,
|
||||
act: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The three choices sit on one grey track under the Status caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => filterMenu(p).getByRole('button', { name: /^Tour\b/ }),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: /^Tour\b/ }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: resultsChip,
|
||||
act: async p => {
|
||||
await resultsChip(p).click()
|
||||
await expect(resultsChip(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: clearPanelState,
|
||||
},
|
||||
'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
|
||||
// no-ai and no model is asked. The mail has to be an allowed file type or
|
||||
// the review would save the booking and drop the document without a word.
|
||||
start: async p => {
|
||||
await requireExtractor(p.request)
|
||||
await allowEmlUploads(p.request, true)
|
||||
await openBookings(p)
|
||||
},
|
||||
steps: [
|
||||
...importSteps(bookingEmlFixture, BOOKING_EML),
|
||||
{
|
||||
prepare: async p => {
|
||||
// The card is there at once, spinning. The picture wants it finished:
|
||||
// the tick and the Import it offers. The extractor has 30 s in the
|
||||
// backend, and on a Windows media host that is a docker run, so the
|
||||
// wait is generous rather than tight.
|
||||
await expect(importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => importTask(p, BOOKING_EML),
|
||||
act: async p => {
|
||||
await importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
|
||||
await expect(codeBox(p)).toHaveValue('GRK-40218')
|
||||
await expect(modal(p).getByText(BOOKING_EML)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await dismissImportTask(p, BOOKING_EML)
|
||||
await allowEmlUploads(p.request, false)
|
||||
await clearPanelState(p)
|
||||
// The route takes the stay and the expense with the booking; the mail it
|
||||
// attached stays behind in Files like any unlinked document.
|
||||
await deleteBookings(p, HOTEL_TO_BOOK.name)
|
||||
await deleteTripFiles(p, BOOKING_EML)
|
||||
},
|
||||
},
|
||||
'edit-booking': {
|
||||
guide: guide('edit-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'Kyoto Cycling Tour', 'Edit'),
|
||||
act: p => editFromCard(p, 'Kyoto Cycling Tour'),
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') },
|
||||
target: p => block(p, /^Booking Code$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: statusPill,
|
||||
act: async p => {
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Confirmed')
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
await expect(statusPill(p)).toHaveText('Confirmed')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// A confirmed card's status dot offers the way back to Pending.
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Set to Pending' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => restore(p, 'Kyoto Cycling Tour'),
|
||||
},
|
||||
'delete-booking': {
|
||||
guide: guide('delete-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'Haneda Airport P4', 'Delete'),
|
||||
act: async p => {
|
||||
await cardAction(p, 'Haneda Airport P4', 'Delete').click()
|
||||
await expect(deleteAsk(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(deleteAsk),
|
||||
{
|
||||
target: p => deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async p => {
|
||||
await deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => ensureBookingsFixtures(p.request),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureBookingsFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered bookings guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripBookingsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-bookings', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripBookingsContext.id, openBookings)
|
||||
})
|
||||
|
||||
for (const id of tripBookingsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ordinal, short } from '../dates'
|
||||
import { SEED_CHAT } from '../screenshots/seed'
|
||||
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
|
||||
import { openTrip } from './trip-shared'
|
||||
import { 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'
|
||||
|
||||
/**
|
||||
* Actions for the Collab tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan",
|
||||
* whose collab addon already holds three notes, two polls and a seven message
|
||||
* conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll
|
||||
* for the one guide that closes a poll for good, and the two upcoming stops
|
||||
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
|
||||
* one deliberate exception: a deleted chat message cannot be undeleted, which is
|
||||
* why `trip-chat` is the last guide of the screen. 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 => {
|
||||
const g = tripCollabGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const NOTE = {
|
||||
title: 'Luggage forwarding',
|
||||
body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
|
||||
category: 'Transport',
|
||||
website: 'https://www.global-yamato.com/en/hc/',
|
||||
}
|
||||
/** An .example host never resolves, so the chip shows its own glyph offline too. */
|
||||
const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' }
|
||||
const POLL = {
|
||||
// Plain text on purpose: a bold run in the question would wrap the paragraph
|
||||
// in one more element and move the card away from `pollCard`'s ancestor depth.
|
||||
question: 'Which evening do we keep for the kaiseki dinner?',
|
||||
options: [`${short(-5).split(',')[0]} ${ordinal(-5)}, after Fushimi Inari`, `${short(-4).split(',')[0]} ${ordinal(-4)}, after Arashiyama`, 'Either, decide on the day'],
|
||||
}
|
||||
/** The seeded poll nobody has voted on yet. */
|
||||
const SEEDED_POLL = 'Ryokan or city hotel in Hakone?'
|
||||
const CHAT = {
|
||||
mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
|
||||
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
|
||||
emoji: 'Two weeks to go ',
|
||||
flag: '🎌',
|
||||
theirs: 'Drop bags. I want to be at Senso-ji before the crowds.',
|
||||
answer: 'Agreed, bags at the hotel first, then Asakusa.',
|
||||
reactTo: SEED_CHAT.teamlab,
|
||||
reaction: '👍',
|
||||
}
|
||||
|
||||
// ── Locators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One of the five cards of the tab, found from the name in its head band. The
|
||||
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
|
||||
* the match ignores case; the card is the nearest rounded ancestor (the cards
|
||||
* inside a panel, notes and polls, are never an ancestor of that heading).
|
||||
*/
|
||||
const panel = (page: Page, name: RegExp) =>
|
||||
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
||||
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
|
||||
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
|
||||
const linksPanel = (page: Page) => panel(page, /^Links$/i)
|
||||
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
|
||||
|
||||
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
|
||||
/** The row that holds the emoji button, the image button, the box and the arrow. */
|
||||
const composer = (page: Page) => chatBox(page).locator('xpath=..')
|
||||
/**
|
||||
* The quoted message above the box while a reply is being written. With no reply
|
||||
* pending the same position is the composer row itself, which is what the step
|
||||
* after the send asserts against.
|
||||
*/
|
||||
const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first()
|
||||
/** The send button and the emoji button carry no text and no label at all. */
|
||||
const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)')
|
||||
const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)')
|
||||
/** The emoji picker is a portal; its three category tabs are the sure thing in it. */
|
||||
const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..')
|
||||
/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */
|
||||
const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last()
|
||||
/** One message, by the relative wrapper that also carries its Reply and Delete. */
|
||||
const bubble = (page: Page, text: string) =>
|
||||
page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]')
|
||||
/**
|
||||
* Put the pointer on a message's Reply or Delete. The row holding them is
|
||||
* `pointer-events: none` until its own bubble is hovered, and it sits at
|
||||
* `top: -14px`, outside that bubble's box: hovering the button alone would land
|
||||
* on whatever is behind it. `captureGuide` scrolls the target to the middle of
|
||||
* the frame between `prepare` and the shot, and that scroll can take the bubble
|
||||
* out from under the pointer, so the hover is made again here, bubble first.
|
||||
*/
|
||||
const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise<void> => {
|
||||
await page.getByText(text).first().hover()
|
||||
await beat(page, 300)
|
||||
await bubble(page, text).getByRole('button', { name }).hover()
|
||||
}
|
||||
|
||||
/**
|
||||
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
|
||||
* field only each of them has. The whole panel is the dialog: the head band
|
||||
* with the title typed into it, the body, and the footer with the buttons.
|
||||
*/
|
||||
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
|
||||
/** 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. */
|
||||
const field = (box: Locator) => box.locator('xpath=..')
|
||||
|
||||
/** A note card is an article named by the note's title. */
|
||||
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
||||
/** A poll card is an article too, without a name: found from its question. */
|
||||
const pollCard = (page: Page, question: string) =>
|
||||
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
|
||||
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
|
||||
/** A What's Next row, from the place name in it: name → details → row. */
|
||||
const nextRow = (page: Page, place: string) =>
|
||||
nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..')
|
||||
|
||||
// ── Start and cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
const start = async (page: Page): Promise<void> => {
|
||||
await openTrip(page, { tab: 'collab' })
|
||||
// The Collab panel is a lazy chunk: everything below needs it mounted.
|
||||
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
|
||||
|
||||
/** 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>
|
||||
return body[key] ?? []
|
||||
}
|
||||
|
||||
async function deleteNote(page: Page, title: string): Promise<void> {
|
||||
const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes')
|
||||
for (const note of notes.filter(n => n.title === title)) {
|
||||
await page.request.delete(`${collab()}/notes/${note.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLink(page: Page, title: string): Promise<void> {
|
||||
const links = await listOf<{ id: number; title: string }>(page, 'links', 'links')
|
||||
for (const link of links.filter(l => l.title === title)) {
|
||||
await page.request.delete(`${collab()}/links/${link.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePoll(page: Page, question: string): Promise<void> {
|
||||
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
||||
for (const poll of polls.filter(p => p.question === question)) {
|
||||
await page.request.delete(`${collab()}/polls/${poll.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voting for an option you already chose deletes the row, so one call puts the
|
||||
* seeded, unvoted poll back the way the next guide expects it.
|
||||
*/
|
||||
async function resetVote(page: Page, question: string, optionIndex: number): Promise<void> {
|
||||
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
||||
const poll = polls.find(p => p.question === question)
|
||||
if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } })
|
||||
}
|
||||
|
||||
const only = (target: (page: Page) => Locator) => ({ target })
|
||||
|
||||
// ── Scripts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'write-note': {
|
||||
guide: guide('write-note'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
|
||||
act: async page => {
|
||||
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
|
||||
await expect(noteDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The title is typed into the dialog's head band, under the New Note eyebrow.
|
||||
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
|
||||
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
|
||||
// The Content eyebrow and the box under it.
|
||||
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
|
||||
},
|
||||
{
|
||||
// Picked before the shot, so the pill and the head band already wear its colour.
|
||||
prepare: async page => {
|
||||
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
|
||||
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await beat(page, 300)
|
||||
},
|
||||
// The Category eyebrow and the row of pills: pill, row, field.
|
||||
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
|
||||
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
|
||||
},
|
||||
{
|
||||
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
|
||||
act: async page => {
|
||||
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deleteNote(page, NOTE.title),
|
||||
},
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
|
||||
act: async page => {
|
||||
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
// The title sits in the head band, the address in the Link field under it.
|
||||
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
|
||||
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: linkDialog,
|
||||
act: async page => {
|
||||
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => { await linkChip(page, LINK.title).hover() },
|
||||
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'),
|
||||
},
|
||||
{
|
||||
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'),
|
||||
act: async page => {
|
||||
// Exact: an accessible name is matched as a substring, and "Unpin link"
|
||||
// holds "Pin link", so the loose form would find both buttons.
|
||||
await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click()
|
||||
await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/)
|
||||
await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deleteLink(page, LINK.title),
|
||||
},
|
||||
'create-poll': {
|
||||
guide: guide('create-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
|
||||
// The Question eyebrow, the box and the Markdown supported hint under it.
|
||||
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
|
||||
},
|
||||
// The Options field: box, its row, the column of rows, the field with its eyebrow.
|
||||
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
|
||||
},
|
||||
{
|
||||
// The label drops the string's own "+"; the button draws a plus icon instead.
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
|
||||
act: async page => {
|
||||
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
|
||||
await beat(page, 300)
|
||||
},
|
||||
},
|
||||
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
|
||||
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
|
||||
{
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deletePoll(page, POLL.question),
|
||||
},
|
||||
'vote-poll': {
|
||||
guide: guide('vote-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }),
|
||||
act: async page => {
|
||||
await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click()
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
// Both bars and both percentages, so the step about reading them has them.
|
||||
only(page => pollCard(page, SEEDED_POLL).locator('> div').last()),
|
||||
{
|
||||
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }),
|
||||
act: async page => {
|
||||
await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click()
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
|
||||
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
|
||||
],
|
||||
cleanup: page => resetVote(page, SEEDED_POLL, 0),
|
||||
},
|
||||
'close-poll': {
|
||||
guide: guide('close-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
|
||||
{
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The last step normally already removed it; this is for a run that stopped short.
|
||||
cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question),
|
||||
},
|
||||
'whats-next': {
|
||||
guide: guide('whats-next'),
|
||||
start,
|
||||
steps: [
|
||||
only(nextPanel),
|
||||
// The time column is the first child of a row; the name, the address and the chips are its last.
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
|
||||
],
|
||||
// Read-only panel: the guide writes nothing.
|
||||
},
|
||||
'trip-chat': {
|
||||
guide: guide('trip-chat'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
prepare: page => typeInto(page, chatBox(page), CHAT.mine),
|
||||
target: composer,
|
||||
act: async page => {
|
||||
await chatBox(page).press('Enter')
|
||||
await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, chatBox(page), CHAT.emoji)
|
||||
await emojiButton(page).click()
|
||||
await expect(emojiPicker(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: emojiPicker,
|
||||
act: async page => {
|
||||
await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click()
|
||||
await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click()
|
||||
await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`)
|
||||
await sendButton(page).click()
|
||||
await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The hover row is pointer-events:none until the bubble under it is hovered,
|
||||
// so the bubble has to be hovered before the button can be reached at all.
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.theirs).first().hover()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }),
|
||||
hover: hoverMessageAction(CHAT.theirs, 'Reply'),
|
||||
act: async page => {
|
||||
await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click()
|
||||
await expect(replyQuote(page)).toContainText(CHAT.theirs)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, chatBox(page), CHAT.answer),
|
||||
target: replyQuote,
|
||||
act: async page => {
|
||||
await chatBox(page).press('Enter')
|
||||
await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(replyQuote(page)).not.toContainText(CHAT.theirs)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.reactTo).first().click({ button: 'right' })
|
||||
await expect(reactionMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: reactionMenu,
|
||||
act: async page => {
|
||||
await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click()
|
||||
await expect(
|
||||
bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.mine).first().hover()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }),
|
||||
hover: hoverMessageAction(CHAT.mine, 'Delete'),
|
||||
act: async page => {
|
||||
await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click()
|
||||
// The bubble shrinks for 400 ms before the request goes out.
|
||||
await page.waitForTimeout(1200)
|
||||
await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
// No cleanup, deliberately: deleting a message is a soft delete and there is
|
||||
// no undelete over the API, so undoing anything here would only add more
|
||||
// tombstones. This is why trip-chat is the last guide of the screen.
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureCollabFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered collab guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-collab', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripCollabContext.id, start)
|
||||
})
|
||||
|
||||
for (const id of tripCollabContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { day, long, short } from '../dates'
|
||||
import { seededTrip, ensureCostsFixtures } from './fixtures'
|
||||
import { openTrip, modal } from './trip-shared'
|
||||
import { RAMEN, RECEIPT_FILE, receiptFixture, removeReceiptFiles } from './fixtures-trip-costs'
|
||||
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Costs tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan",
|
||||
* which keeps its books in JPY with one expense in EUR and one transfer already
|
||||
* recorded, so the frozen rate and the settle-up column both have something
|
||||
* real to show. What a guide writes it takes away again in `cleanup`, the
|
||||
* settlements included, because the next guide reads the same balances.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripCostsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */
|
||||
const UNPAID = 'Airport transfer (Narita Express)'
|
||||
/** The seeded expense in euros, the one carrying a frozen rate. */
|
||||
const SEEDED_EUR = 'Flights FRA → HND'
|
||||
/** The seeded flight booking, on the Transports tab because its type is flight. */
|
||||
const BOOKING = 'LH716 FRA → HND'
|
||||
/** A seeded expense costs-table gives its days to. */
|
||||
const JR_PASS = 'JR Pass (14 days)'
|
||||
const DINNER = 'Kaiseki dinner in Gion'
|
||||
const UPGRADE = 'Shinkansen seat upgrade'
|
||||
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: day(-4) }
|
||||
/** The row costs-table adds to Food & drink; the table names a new row this until it is renamed. */
|
||||
const MATCHA = 'Matcha in Uji'
|
||||
const NEW_ROW = 'New Entry'
|
||||
/** Where the tab keeps the list/table choice (CostsPanel's COSTS_VIEW_KEY). */
|
||||
const VIEW_KEY = 'trek:costs-view'
|
||||
|
||||
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
|
||||
const openCosts = async (p: Page): Promise<void> => {
|
||||
await openTrip(p, { tab: 'finanzplan' })
|
||||
await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
}
|
||||
|
||||
/** A row of the ledger by the expense's name. */
|
||||
const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first()
|
||||
/**
|
||||
* The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child,
|
||||
* so a locator scoped to the row itself would never find it.
|
||||
*/
|
||||
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
|
||||
/**
|
||||
* The transfer settle-up records for jonas, in the display currency. The seed
|
||||
* already holds one from jonas, in euros, which carries the converted line;
|
||||
* leaving that one out keeps the guide off the seed's own payment.
|
||||
*/
|
||||
const newPayRow = (page: Page) =>
|
||||
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: 'jonas' }).filter({ hasNotText: '€' }).first()
|
||||
/**
|
||||
* A block of the expense editor, found by its own label: the <section> of
|
||||
* Who paid? and Split, the grey group that holds Total amount, Currency and Day.
|
||||
*/
|
||||
const block = (page: Page, label: string) =>
|
||||
modal(page)
|
||||
.getByText(label, { exact: true })
|
||||
.first()
|
||||
.locator('xpath=ancestor::*[self::section or contains(@class,"rounded-[16px]")][1]')
|
||||
/** A card of the right-hand column: each is a <section> named by its heading. */
|
||||
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
|
||||
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
|
||||
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
|
||||
|
||||
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
|
||||
/** The name, typed into the head of the expense editor. */
|
||||
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
|
||||
/** The category pill under the name, and the list it opens (a portal named after it). */
|
||||
const categoryPill = (page: Page) => modal(page).getByRole('button', { name: /^Category:/ })
|
||||
const categoryList = (page: Page) => page.getByRole('group', { name: 'Category', exact: true })
|
||||
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
|
||||
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
|
||||
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
|
||||
/** 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: / })
|
||||
/**
|
||||
* 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 settleJonas = (page: Page) => card(page, 'Settle up').getByRole('button', { name: /^Settle: jonas → / })
|
||||
|
||||
// ── The bar's filters ────────────────────────────────────────────────────────
|
||||
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
|
||||
const filterButton = (page: Page) => page.getByRole('button', { name: 'Filter', exact: true })
|
||||
/** The popover the funnel opens: the owner switch, the categories and the days. */
|
||||
const filterMenu = (page: Page) => page.getByRole('menu')
|
||||
async function openFilter(page: Page): Promise<void> {
|
||||
if (!(await filterMenu(page).isVisible().catch(() => false))) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
async function closeFilter(page: Page): Promise<void> {
|
||||
if (await filterMenu(page).isVisible().catch(() => false)) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
// ── The table view ───────────────────────────────────────────────────────────
|
||||
const table = (page: Page) => page.getByRole('table')
|
||||
/** A category's group of the table: a <tbody> opened by a button with its name and count. */
|
||||
const group = (page: Page, category: string) =>
|
||||
table(page)
|
||||
.locator('tbody')
|
||||
.filter({ has: page.getByRole('button', { name: new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\d+$`) }) })
|
||||
const tableRow = (page: Page, name: string) => table(page).getByRole('row').filter({ hasText: name }).first()
|
||||
const summary = (page: Page) => page.getByRole('region', { name: 'Summary', exact: true })
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* A dialog that closes hands the focus back to the button that opened it, and
|
||||
* a focused button wears its outline and its tooltip, the tooltip at wherever
|
||||
* the button stood before the next picture scrolled: both would stand in that
|
||||
* picture as if they were part of it.
|
||||
*/
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
await dropFocus(page)
|
||||
}
|
||||
}
|
||||
|
||||
type BudgetRow = { id: number; name: string; days?: number | null }
|
||||
|
||||
/** What the JR Pass's Days held before costs-table typed into it; cleanup puts it back. */
|
||||
let passDaysBefore: number | null = null
|
||||
|
||||
async function budgetItems(page: Page): Promise<BudgetRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget`)
|
||||
return ((await res.json()) as { items: BudgetRow[] }).items
|
||||
}
|
||||
|
||||
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const item of (await budgetItems(page)).filter(i => names.includes(i.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createExpense(
|
||||
page: Page,
|
||||
item: { name: string; category: string; total_price: number; currency: string; expense_date: string },
|
||||
): Promise<void> {
|
||||
const { tripId, memberIds } = seededTrip()
|
||||
const created = await page.request.post(`/api/trips/${tripId}/budget`, {
|
||||
data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] },
|
||||
})
|
||||
if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
|
||||
async function settlementIds(page: Page): Promise<number[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`)
|
||||
const { settlements } = (await res.json()) as { settlements: { id: number }[] }
|
||||
return settlements.map(s => s.id)
|
||||
}
|
||||
|
||||
/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */
|
||||
let transfersBefore: number[] = []
|
||||
|
||||
/** Open the expense editor's currency list, search for a code and take it. */
|
||||
async function pickCurrency(page: Page, code: string): Promise<void> {
|
||||
await modal(page).getByRole('button', { name: /^JPY/ }).first().click()
|
||||
await beat(page, 300)
|
||||
await dropdown(page).locator('input').first().fill(code)
|
||||
await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click()
|
||||
await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'add-expense': {
|
||||
guide: guide('add-expense'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
target: addExpense,
|
||||
act: async p => {
|
||||
await addExpense(p).click()
|
||||
await expect(nameBox(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, nameBox(p), DINNER)
|
||||
await settle(p)
|
||||
},
|
||||
target: nameBox,
|
||||
},
|
||||
{
|
||||
// The list open under the pill, so the picture shows what there is to pick.
|
||||
prepare: async p => {
|
||||
await categoryPill(p).click()
|
||||
await expect(categoryList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: categoryPill,
|
||||
act: async p => {
|
||||
await categoryList(p).getByRole('button', { name: 'Food & drink' }).click()
|
||||
await expect(categoryList(p)).toHaveCount(0)
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Food & drink/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await amounts(p).first().fill('18600')
|
||||
await expect(saveExpense(p)).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
// The grey group: Total amount, then Currency and Day beside it.
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
only(p => block(p, 'Who paid?')),
|
||||
{
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, DINNER),
|
||||
},
|
||||
|
||||
'expense-payers': {
|
||||
guide: guide('expense-payers'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(block(p, 'Who paid?')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Who paid?'),
|
||||
},
|
||||
// Ringed, not clicked: the Ryokan keeps its payer.
|
||||
only(p => modal(p).getByRole('radio', { name: 'No one paid yet' })),
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'Multiple people paid' }).click()
|
||||
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
|
||||
await modal(p).getByTestId('payer-toggle').nth(1).click()
|
||||
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Who paid?'),
|
||||
act: async p => {
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'One person paid' }).click()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => expRow(p, UNPAID),
|
||||
// On the flag, whose tooltip says what it means; the row's centre is the note.
|
||||
hover: async p => {
|
||||
await expRow(p, UNPAID).getByText('Unfinished').hover()
|
||||
},
|
||||
act: async p => {
|
||||
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'split-expense': {
|
||||
guide: guide('split-expense'),
|
||||
start: async p => {
|
||||
await createExpense(p, LUNCH)
|
||||
await openCosts(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(block(p, 'Split')).toBeVisible()
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'false')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split'),
|
||||
act: async p => {
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The badges under the list: how many share it, and what one share is.
|
||||
only(p => block(p, 'Split').getByText(/ per person$/).locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, 'Split').getByRole('button', { name: 'Custom', exact: true }).click()
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(0).fill('1000')
|
||||
await expect(block(p, 'Split').getByText(/^Sum of splits: /)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split').getByText(/^Sum of splits: /),
|
||||
act: async p => {
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(1).fill('1900')
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(2).fill('1900')
|
||||
await expect(block(p, 'Split').getByText('Split matches total')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const split = block(p, 'Split')
|
||||
await split.getByRole('button', { name: 'Ticket', exact: true }).click()
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').first(), 'Tamagoyaki')
|
||||
await split.locator('input[inputmode="decimal"]').nth(0).fill('1200')
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
|
||||
await split.locator('input[inputmode="decimal"]').nth(1).fill('900')
|
||||
// The second line is shared by two of the three travelers.
|
||||
await split
|
||||
.getByPlaceholder('Item name')
|
||||
.nth(1)
|
||||
.locator('xpath=ancestor::div[2]')
|
||||
.getByRole('checkbox', { name: /jonas/ })
|
||||
.click()
|
||||
await expect(amounts(p).first()).toHaveValue(/^2100(\.00)?$/)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByText('Individual shares', { exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, LUNCH.name),
|
||||
},
|
||||
|
||||
'expense-currency': {
|
||||
guide: guide('expense-currency'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await addExpense(p).click()
|
||||
await expect(nameBox(p)).toBeVisible()
|
||||
await typeInto(p, nameBox(p), UPGRADE)
|
||||
await amounts(p).first().fill('48')
|
||||
// The editor opens on Food & drink; a seat upgrade filed under it would
|
||||
// read wrong on the ledger row this guide's last picture is of.
|
||||
await categoryPill(p).click()
|
||||
await categoryList(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Transport/)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: /^JPY/ }).first().click()
|
||||
await beat(p, 300)
|
||||
await dropdown(p).locator('input').first().fill('EUR')
|
||||
await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible()
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(),
|
||||
act: async p => {
|
||||
await dropdown(p).getByRole('button', { name: /^EUR/ }).click()
|
||||
await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible()
|
||||
await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'),
|
||||
act: async p => {
|
||||
// Both sides have to be there: an offline run would show the amount unconverted.
|
||||
await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => expRow(p, UPGRADE),
|
||||
act: async p => {
|
||||
await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
||||
await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, UPGRADE),
|
||||
},
|
||||
|
||||
'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,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, searchBox(p), 'pass')
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await searchBox(p).fill('')
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The owner switch at the top of the popover, on Paid by me.
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).click()
|
||||
await expect(filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The category list, with the pick marked and the funnel's count above it.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: short(-9), exact: true }).click()
|
||||
// Folded away, or the popover would sit over the banner it produced.
|
||||
await closeFilter(p)
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByText(new RegExp(`^${long(-9)}$`)).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Reset filters' }).click()
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toHaveCount(0)
|
||||
await closeFilter(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Clicking it would start a download; the button is what the step is about.
|
||||
only(p => p.getByRole('button', { name: 'Export CSV' })),
|
||||
],
|
||||
},
|
||||
|
||||
'settle-up': {
|
||||
guide: guide('settle-up'),
|
||||
start: async p => {
|
||||
await openCosts(p)
|
||||
transfersBefore = await settlementIds(p)
|
||||
},
|
||||
steps: [
|
||||
only(p => card(p, 'Settle up')),
|
||||
{
|
||||
target: settleJonas,
|
||||
act: async p => {
|
||||
await settleJonas(p).click()
|
||||
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
|
||||
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(newPayRow),
|
||||
{
|
||||
target: p => newPayRow(p).locator('xpath=..').locator('.exp-actions'),
|
||||
act: async p => {
|
||||
await newPayRow(p).locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
|
||||
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
|
||||
await 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' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Ringed and left alone: one click would record every open transfer and
|
||||
// flatten the balances every later picture is taken against.
|
||||
only(p => p.getByRole('button', { name: 'Settle up', exact: true })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
for (const id of await settlementIds(p)) {
|
||||
if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'final-budget': {
|
||||
guide: guide('final-budget'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
only(p => card(p, 'Balances')),
|
||||
only(p => card(p, 'Final budget')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(p.getByText('Expenses paid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => card(p, 'Final budget').locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
|
||||
},
|
||||
{
|
||||
// The second "Expenses paid" is the heading of the list the figure is made of.
|
||||
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(card(p, 'Final budget').locator('[aria-expanded="true"]')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'expense-from-booking': {
|
||||
guide: guide('expense-from-booking'),
|
||||
start: async p => {
|
||||
await openTrip(p, { tab: 'transports' })
|
||||
await expect(p.getByRole('article', { name: BOOKING }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create expense' }).click()
|
||||
await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await amounts(p).first().fill('890')
|
||||
await pickCurrency(p, 'EUR')
|
||||
},
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await deleteExpenses(p, BOOKING)
|
||||
},
|
||||
},
|
||||
|
||||
'costs-table': {
|
||||
guide: guide('costs-table'),
|
||||
start: async p => {
|
||||
// What the pass's Days cell holds before the guide types into it.
|
||||
const pass = (await budgetItems(p)).find(i => i.name === JR_PASS)
|
||||
if (!pass) throw new Error(`the seed has no "${JR_PASS}"`)
|
||||
passDaysBefore = pass.days ?? null
|
||||
await openCosts(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Table', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Table', exact: true }).click()
|
||||
await expect(table(p)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(tableRow(p, SEEDED_EUR)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// One category as a whole: its head with the count and the subtotal, its rows, its add button.
|
||||
only(p => group(p, 'Transport')),
|
||||
{
|
||||
// The pass is good for fourteen days, and its Days cell is still empty:
|
||||
// typed in, Per Day and P. p / Day fill in beside it.
|
||||
prepare: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('button', { name: /^Days/ }).click()
|
||||
const field = tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' })
|
||||
await expect(field).toBeFocused()
|
||||
await field.fill('14')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }),
|
||||
act: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }).press('Enter')
|
||||
await expect(tableRow(p, JR_PASS).getByRole('button', { name: 'Days: 14' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hovered for the picture, so the lock's reason stands above it.
|
||||
target: p => tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }),
|
||||
act: async p => {
|
||||
await tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Edit expense' })).toBeVisible()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }),
|
||||
act: async p => {
|
||||
await group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }).click()
|
||||
const name = table(p).getByRole('textbox', { name: 'Name' })
|
||||
await expect(name.or(tableRow(p, NEW_ROW)).first()).toBeVisible({ timeout: 15_000 })
|
||||
if (!(await name.isVisible().catch(() => false))) {
|
||||
await tableRow(p, NEW_ROW).getByRole('button', { name: /^Name/ }).click()
|
||||
}
|
||||
await name.fill(MATCHA)
|
||||
await name.press('Enter')
|
||||
await expect(tableRow(p, MATCHA)).toBeVisible({ timeout: 15_000 })
|
||||
// A total too, the same way, so the row reads like a real one.
|
||||
await tableRow(p, MATCHA).getByRole('button', { name: /^Total/ }).click()
|
||||
const total = tableRow(p, MATCHA).getByRole('textbox', { name: 'Total' })
|
||||
await total.fill('1800')
|
||||
await total.press('Enter')
|
||||
await expect(tableRow(p, MATCHA).getByRole('button', { name: /^Total: .*1,800/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await summary(p).getByRole('button', { name: 'Payer', exact: true }).click()
|
||||
await expect(summary(p).getByText('No payer yet')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: summary,
|
||||
},
|
||||
],
|
||||
// Back to the ledger, so every other guide finds it, and the seed as it was.
|
||||
cleanup: async p => {
|
||||
const list = p.getByRole('button', { name: 'List', exact: true })
|
||||
if (await list.isVisible().catch(() => false)) await list.click()
|
||||
await p.evaluate(key => { try { localStorage.setItem(key, 'list') } catch { /* no storage: nothing to put back */ } }, VIEW_KEY)
|
||||
await deleteExpenses(p, MATCHA, NEW_ROW)
|
||||
const { tripId } = seededTrip()
|
||||
for (const item of (await budgetItems(p)).filter(i => i.name === JR_PASS)) {
|
||||
await p.request.put(`/api/trips/${tripId}/budget/${item.id}`, { data: { days: passDaysBefore } })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureCostsFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered costs guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-costs', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripCostsContext.id, openCosts)
|
||||
})
|
||||
|
||||
for (const id of tripCostsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures'
|
||||
import { openTrip } from './trip-shared'
|
||||
import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the day details panel of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in
|
||||
* Japan" with the day fixtures in place, so day 1 carries the flight and the
|
||||
* Tokyo hotel and day 5 the train and its evening walk; what a guide writes it
|
||||
* takes back in `cleanup`, so the next one starts from the same seed.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripDayDetailGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The name the rename guide gives day 5, and takes away again. */
|
||||
const DAY_NAME = 'Arrival in Kyoto'
|
||||
|
||||
/**
|
||||
* The panel itself. 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} `) })
|
||||
/**
|
||||
* The hotel picker is a portal of its own with neither `trek-modal-backdrop`
|
||||
* nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it;
|
||||
* the card is the second `role="presentation"` and holds the day range.
|
||||
*/
|
||||
const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' })
|
||||
/** The row of the stay's three times, with Confirmation right under it. */
|
||||
const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]')
|
||||
/**
|
||||
* A block of the panel by its heading. Every block is a <section> whose first
|
||||
* row carries the heading, so the section is the block with everything in it.
|
||||
*/
|
||||
const block = (page: Page, heading: string) =>
|
||||
panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
|
||||
/** The accommodation block: the stays and the add button. */
|
||||
const stays = (page: Page) => block(page, 'Accommodation')
|
||||
/** One booked stay: an <article> card inside that block. */
|
||||
const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first()
|
||||
/** The caption that opens the weather block. */
|
||||
const forecast = (page: Page) => panel(page).getByText(/^Forecast for /)
|
||||
/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */
|
||||
const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]')
|
||||
/** The summary row's numbers: the temperature, the low and high, the condition. */
|
||||
const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]')
|
||||
/** The hourly strip under its own heading. */
|
||||
const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..')
|
||||
/** The day's reservations block. */
|
||||
const bookings = (page: Page) => block(page, 'Reservations')
|
||||
/** One booking row: on the desktop a button that opens the booking, named with its title. */
|
||||
const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title })
|
||||
/** The booking a row opens, named by its title. */
|
||||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
/**
|
||||
* 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
|
||||
* a night booked over it carries a hotel button there, and that button opens the
|
||||
* place instead of selecting the day.
|
||||
*/
|
||||
const selectDay = async (page: Page, n: number): Promise<void> => {
|
||||
await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click()
|
||||
await expect(panel(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */
|
||||
const openDay = async (page: Page, n: number): Promise<void> => {
|
||||
await openTrip(page, { day: null })
|
||||
await selectDay(page, n)
|
||||
}
|
||||
|
||||
interface Stay {
|
||||
id: number
|
||||
place_id: number | null
|
||||
place_name?: string | null
|
||||
start_day_id: number
|
||||
end_day_id: number
|
||||
}
|
||||
|
||||
async function stayOf(page: Page, name: string): Promise<Stay | undefined> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/accommodations`)
|
||||
const { accommodations } = (await res.json()) as { accommodations: Stay[] }
|
||||
return accommodations.find(a => a.place_name === name)
|
||||
}
|
||||
|
||||
/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */
|
||||
async function removeStay(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const stay = await stayOf(page, name)
|
||||
if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`)
|
||||
}
|
||||
|
||||
/** The update route reads every field off the body, so the whole stay goes back. */
|
||||
async function restoreTokyoStay(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const stay = await stayOf(page, TOKYO_HOTEL)
|
||||
if (!stay) return
|
||||
await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, {
|
||||
data: {
|
||||
place_id: stay.place_id,
|
||||
start_day_id: stay.start_day_id,
|
||||
end_day_id: stay.end_day_id,
|
||||
check_in: '15:00',
|
||||
check_in_end: null,
|
||||
check_out: '11:00',
|
||||
confirmation: 'GRC-7731',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
async function resetDayTitle(page: Page, index: number): Promise<void> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } })
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'day-panel': {
|
||||
guide: guide('day-panel'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => dayHeader(p, 1),
|
||||
act: async p => {
|
||||
await selectDay(p, 1)
|
||||
await expect(stays(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
// The forecast is asked for when the panel opens, and it is a round trip
|
||||
// to Open-Meteo; without the wait the picture catches the spinner.
|
||||
prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) },
|
||||
target: p => forecast(p).locator('xpath=..'),
|
||||
},
|
||||
only(bookings),
|
||||
only(stays),
|
||||
{
|
||||
target: p => panel(p).getByRole('button', { name: 'Collapse' }),
|
||||
act: async p => {
|
||||
// Every day card carries a chevron with the same label, so the panel's
|
||||
// own is reached through the panel.
|
||||
await panel(p).getByRole('button', { name: 'Collapse' }).click()
|
||||
await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible()
|
||||
await expect(stays(p)).toBeHidden()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'day-weather': {
|
||||
guide: guide('day-weather'),
|
||||
start: async p => {
|
||||
await openDay(p, 5)
|
||||
// Day 5 has one located stop, so the forecast is that stop's.
|
||||
await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
steps: [
|
||||
only(forecast),
|
||||
only(summary),
|
||||
only(chips),
|
||||
only(hourly),
|
||||
],
|
||||
},
|
||||
'rename-day': {
|
||||
guide: guide('rename-day'),
|
||||
start: p => openDay(p, 5),
|
||||
steps: [
|
||||
only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
// Every booking row of the open day carries an Edit of its own in the
|
||||
// days column (DayPlanSidebar), so the header's pencil is reached
|
||||
// through the panel; inside it, it is the only one.
|
||||
target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async p => {
|
||||
await panel(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) },
|
||||
target: p => panel(p).getByPlaceholder('Day 5'),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Enter')
|
||||
await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })),
|
||||
],
|
||||
cleanup: p => resetDayTitle(p, 4),
|
||||
},
|
||||
'add-accommodation': {
|
||||
guide: guide('add-accommodation'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
// The fixture put the hotel in the trip as a place; the picker offers
|
||||
// nothing else, and the rows of the column are options.
|
||||
only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })),
|
||||
{
|
||||
prepare: async p => { await selectDay(p, 5) },
|
||||
target: p => p.getByRole('button', { name: 'Add accommodation' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add accommodation' }).click()
|
||||
await expect(picker(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The two day selects are named Start and End; their options are
|
||||
// portalled to the body, outside the picker.
|
||||
prepare: async p => {
|
||||
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
|
||||
await p.getByRole('button', { name: /^Day 7/ }).last().click()
|
||||
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
|
||||
await settle(p)
|
||||
},
|
||||
// The row of Start, End and All: the All button's parent.
|
||||
target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
// The three time fields normalise what is typed; the labels carry no
|
||||
// `for`, so every field is found by its placeholder.
|
||||
prepare: async p => {
|
||||
await picker(p).getByPlaceholder('14:00').fill('15:00')
|
||||
await picker(p).getByPlaceholder('22:00').fill('20:00')
|
||||
await picker(p).getByPlaceholder('11:00').fill('11:00')
|
||||
await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842')
|
||||
await settle(p)
|
||||
},
|
||||
target: times,
|
||||
},
|
||||
{
|
||||
// The category is a pill over the list; its choices open in a list of their own.
|
||||
prepare: async p => {
|
||||
await picker(p).getByRole('button', { name: /^Category:/ }).click()
|
||||
await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click()
|
||||
await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click()
|
||||
await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(picker(p)).toHaveCount(0)
|
||||
await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => removeStay(p, KYOTO_HOTEL.name),
|
||||
},
|
||||
'edit-accommodation': {
|
||||
guide: guide('edit-accommodation'),
|
||||
start: async p => {
|
||||
// The day fixtures book the Tokyo hotel over the first nights, so day 1
|
||||
// is its check-in day.
|
||||
await openDay(p, 1)
|
||||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
steps: [
|
||||
only(p => stayCard(p, TOKYO_HOTEL)),
|
||||
{
|
||||
// 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).getByRole('button', { name: 'Edit accommodation', exact: true }).click()
|
||||
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await picker(p).getByPlaceholder('11:00').fill('10:00')
|
||||
await picker(p).getByPlaceholder('ABC-12345').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: times,
|
||||
},
|
||||
{
|
||||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(picker(p)).toHaveCount(0)
|
||||
// Day 1 is the arrival: the card shows the check-in window, not the new check-out.
|
||||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Deliberately not performed: the × removes the stay without a question,
|
||||
// and with it the seed's hotel booking and the stop it put on the day.
|
||||
prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() },
|
||||
target: p => stayCard(p, TOKYO_HOTEL).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),
|
||||
steps: [
|
||||
only(bookings),
|
||||
only(p => bookingRow(p, EVENING_WALK)),
|
||||
only(p => bookingRow(p, 'Nozomi 21')),
|
||||
{
|
||||
// A row opens its booking over the plan; Edit at its foot is where it changes.
|
||||
prepare: async p => {
|
||||
await bookingRow(p, 'Nozomi 21').click()
|
||||
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDayDetailFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered day-detail guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-day-detail', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
// Day 1 is the one day that has all four blocks at once: the forecast for its
|
||||
// first stop, the flight, and the hotel it checks into.
|
||||
await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1))
|
||||
})
|
||||
|
||||
for (const id of tripDayDetailContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
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'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the days column of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripDays.ts`. The seeded trip gets a timed stop, a
|
||||
* reservation at a stop, a train and a hotel first, so the cards show every
|
||||
* kind of row; a guide that changes the plan puts it back in `cleanup`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripDaysGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
|
||||
/** The note the day-note guide writes: a name for the card, markdown underneath. */
|
||||
const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' }
|
||||
/** The day-fixtures train of day 5, the booking the move guide drags to day 4. */
|
||||
const TRAIN = 'Nozomi 21 Tokyo → Kyoto'
|
||||
|
||||
/**
|
||||
* The day the route guides read. Day 6 is the only one whose two stops sit next
|
||||
* to each other: day 1's are separated by the flight, so it has no leg of its
|
||||
* own and its only connector is the one to the booked night, which depends on a
|
||||
* fixture. Day 6 also carries a reservation at a stop, so one card shows every
|
||||
* row the guide talks about.
|
||||
*/
|
||||
const ROUTED_DAY = 6
|
||||
/** The pencil that renames the open day, in its details panel. */
|
||||
const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true })
|
||||
/** 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
|
||||
* picture, which is what tells it from the note and booking rows beside it.
|
||||
*/
|
||||
const stop = (page: Page, name: string) =>
|
||||
page
|
||||
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
|
||||
.filter({ hasText: name })
|
||||
.first()
|
||||
/** A booking row of a day: its 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 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' })
|
||||
/**
|
||||
* The context menu of a stop, a note or a leg. Tooltip renders into the same
|
||||
* `.trek-popover-enter` class and a click on a connector leaves its tooltip
|
||||
* standing, so the menu is the popover that is not one.
|
||||
*/
|
||||
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
|
||||
/** The route bar of the open day: Route, the 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
|
||||
* leg of their own, and the day fixtures put a hotel on the first four nights.
|
||||
*/
|
||||
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
|
||||
/** The note dialog, known by its body field. */
|
||||
const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…')
|
||||
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) })
|
||||
/** The question a timed stop asks before it moves; also a portal of its own. */
|
||||
const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?'))
|
||||
/** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */
|
||||
const reorderPopup = (page: Page) => page.locator('.trek-modal-enter').filter({ hasText: 'Reorder days' })
|
||||
/** 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()
|
||||
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
|
||||
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** teamLab Planets' slot on day 1 as the fixtures give it. */
|
||||
const TEAMLAB_SLOT = { place_time: '13:00', end_time: '15:00' }
|
||||
|
||||
/** Give teamLab Planets on day 1 a time, or none. */
|
||||
async function setTeamLabTime(page: Page, slot: { place_time: string; end_time: string } | null): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const teamLab = await placeIdOf(page, 'teamLab Planets')
|
||||
const stop = (await assignmentsOf(page, 0)).find(a => a.place_id === teamLab)
|
||||
if (!stop) return
|
||||
const res = await page.request.put(`/api/trips/${tripId}/assignments/${stop.id}/time`, {
|
||||
data: slot ?? { place_time: null, end_time: null },
|
||||
})
|
||||
if (!res.ok()) throw new Error(`could not set the time of teamLab Planets: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
|
||||
/** Day 1's stops as the seed orders them, read before the reorder guide moves them. */
|
||||
let day1Order: number[] = []
|
||||
|
||||
/** Put teamLab Planets back on day 1 and day 1 back in its seeded order. */
|
||||
async function restoreDay1(page: Page): Promise<void> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const teamLab = await placeIdOf(page, 'teamLab Planets')
|
||||
if (!(await assignmentsOf(page, 0)).some(a => a.place_id === teamLab)) {
|
||||
for (let i = 1; i < dayIds.length; i++) {
|
||||
const moved = (await assignmentsOf(page, i)).find(a => a.place_id === teamLab)
|
||||
if (!moved) continue
|
||||
await page.request.put(`/api/trips/${tripId}/assignments/${moved.id}/move`, { data: { new_day_id: dayIds[0], order_index: 0 } })
|
||||
break
|
||||
}
|
||||
}
|
||||
const day1 = await assignmentsOf(page, 0)
|
||||
const rank = (a: Assignment) => { const i = day1Order.indexOf(a.place_id); return i === -1 ? day1Order.length : i }
|
||||
const orderedIds = [...day1].sort((a, b) => rank(a) - rank(b)).map(a => a.id)
|
||||
await page.request.put(`/api/trips/${tripId}/days/${dayIds[0]}/assignments/reorder`, { data: { orderedIds } })
|
||||
}
|
||||
|
||||
/** The train's days as the fixtures left them, read before the move guide drags it. */
|
||||
let trainDays: { id: number; day_id: number | null; end_day_id: number | null } | null = null
|
||||
|
||||
async function readTrain(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
|
||||
const body = (await res.json()) as { reservations?: { id: number; title: string; day_id: number | null; end_day_id: number | null }[] }
|
||||
const train = (body.reservations ?? []).find(r => r.title === TRAIN)
|
||||
if (!train) throw new Error(`the day fixtures' train "${TRAIN}" is missing`)
|
||||
trainDays = { id: train.id, day_id: train.day_id, end_day_id: train.end_day_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip on the routed day and draw its route, so the connectors between
|
||||
* the stops exist. The day stays selected with its details panel up: the panel
|
||||
* is the selection, and the route tools render for the selected day only.
|
||||
*/
|
||||
async function openWithRoute(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, ROUTED_DAY)
|
||||
await page.getByRole('button', { name: 'Route', exact: true }).click()
|
||||
await expect(connector(page)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'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),
|
||||
only(routeBar),
|
||||
],
|
||||
},
|
||||
'place-onto-day': {
|
||||
guide: guide('place-onto-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
// Day 1 stays open: the round + of a place row and To day exist only while
|
||||
// a day is selected, and steps 2 and 4 are about those two.
|
||||
await openTripOnDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => placeRow(p, SPARE.name),
|
||||
dropTo: p => dayHeader(p, 2),
|
||||
clip: async (p, frame) => {
|
||||
await dragLoop(p, placeRow(p, SPARE.name), dayHeader(p, 2), frame)
|
||||
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
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.
|
||||
prepare: async p => { await placeRow(p, SPARE.name).hover() },
|
||||
target: p => toOpenDay(p, SPARE.name),
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Add place to this day' }).first()),
|
||||
only(p => p.getByRole('button', { name: 'Add to the open day' })),
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'reorder-stops': {
|
||||
guide: guide('reorder-stops'),
|
||||
start: 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: [
|
||||
{
|
||||
// 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('.reorder-buttons'),
|
||||
hover: async p => { await stop(p, 'teamLab Planets').hover() },
|
||||
},
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets'),
|
||||
dropTo: p => dayHeader(p, 2),
|
||||
clip: async (p, frame) => {
|
||||
await dragLoop(p, stop(p, 'teamLab Planets'), dayHeader(p, 2), frame)
|
||||
await expect(dayCard(p, 2).getByText('teamLab Planets').first()).toBeVisible({ timeout: 15_000 })
|
||||
await holdResult(p, frame)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Only a move that breaks the day's chronology asks, and the arrows
|
||||
// never do here: the timed stop is the first row of the day. Dropping
|
||||
// it below the 13:05 departure does, and Cancel leaves the day alone.
|
||||
const booking = bookingRow(p, /^LH716 FRA → HND .*departure/i)
|
||||
const box = await booking.boundingBox()
|
||||
if (!box) throw new Error('the departure row of day 1 has no box')
|
||||
await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } })
|
||||
await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: timeConfirm,
|
||||
act: async p => {
|
||||
await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByText('Remove time?')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await restoreDay1(p)
|
||||
await setTeamLabTime(p, TEAMLAB_SLOT)
|
||||
},
|
||||
},
|
||||
'set-stop-times': {
|
||||
guide: guide('set-stop-times'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: p => stopMenu(p, 'teamLab Planets', 'Edit'),
|
||||
target: menu,
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByText('Start', { exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The time pickers are text fields that parse on blur.
|
||||
const times = modal(p).getByPlaceholder(/^(00:00|2:30 PM)$/)
|
||||
await times.first().fill('13:00')
|
||||
await times.first().blur()
|
||||
await times.nth(1).fill('15:00')
|
||||
await times.nth(1).blur()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByText('Start', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(stop(p, 'teamLab Planets').getByText('13:00')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = 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 } })
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
'remove-from-day': {
|
||||
guide: guide('remove-from-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
// The + puts the place on the OPEN day, so day 1 stays selected.
|
||||
await openTripOnDay(p, 1)
|
||||
await placeRow(p, SPARE.name).hover()
|
||||
await toOpenDay(p, SPARE.name).click()
|
||||
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: 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()
|
||||
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => placeRow(p, SPARE.name)),
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'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),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
|
||||
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }),
|
||||
act: async p => {
|
||||
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }).click()
|
||||
await expect(stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }),
|
||||
act: async p => {
|
||||
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }).click()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'day-note': {
|
||||
guide: guide('day-note'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
// The + of the day's head band opens the menu; Add Note is its last entry.
|
||||
prepare: async p => {
|
||||
await addToDay(p, 2).click()
|
||||
await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => menu(p).getByRole('button', { name: 'Add Note' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Add Note' }).click()
|
||||
await expect(noteBody(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The name is typed into the head band and is what the day card shows,
|
||||
// the only required field; Daily Note is the markdown the toolbar formats.
|
||||
await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title)
|
||||
await typeInto(p, noteBody(p), NOTE.body)
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: noteDialog,
|
||||
},
|
||||
{
|
||||
target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(noteDialog(p)).toHaveCount(0)
|
||||
await expect(noteRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// 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 }[]
|
||||
const notes = Array.isArray(body) ? body : (body.notes ?? [])
|
||||
for (const note of notes.filter(n => n.text.startsWith(NOTE.title))) {
|
||||
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
'day-route': {
|
||||
guide: guide('day-route'),
|
||||
start: p => openTripOnDay(p, ROUTED_DAY),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Route', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Route', exact: true }).click()
|
||||
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// 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 => {
|
||||
await connector(p).click()
|
||||
await expect(menu(p).getByRole('button', { name: 'Use day default' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: menu,
|
||||
act: closeMenu,
|
||||
},
|
||||
only(p => 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 => routeBar(p).getByRole('button', { name: 'Open in Google Maps' })),
|
||||
],
|
||||
},
|
||||
'manage-days': {
|
||||
guide: guide('manage-days'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
only(p => dayHeader(p, 1)),
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Reorder days' }).click()
|
||||
await expect(reorderPopup(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The whole popup: the rows with their grips, arrows and bins, and the
|
||||
// two ways of adding a day in its footer.
|
||||
target: reorderPopup,
|
||||
},
|
||||
{
|
||||
// The question before a day goes, answered with Cancel: the picture shows
|
||||
// what would go with the second day, its places and its landing, and the
|
||||
// seed keeps all of its days.
|
||||
prepare: async p => {
|
||||
await reorderPopup(p).getByRole('button', { name: 'Delete day' }).nth(1).click()
|
||||
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: reorderPopup,
|
||||
act: async p => {
|
||||
await reorderPopup(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toHaveCount(0)
|
||||
await reorderPopup(p).getByRole('button', { name: 'Close', exact: true }).last().click()
|
||||
await expect(reorderPopup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The steps before leave the first day selected, and a click on a
|
||||
// selected day folds its panel away: a second one brings it back.
|
||||
await dayHeader(p, 1).click({ position: { x: 22, y: 20 } })
|
||||
await settle(p)
|
||||
if (!(await dayDetails(p).isVisible())) await selectDay(p, 1)
|
||||
await expect(renameDay(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// Scoped to the panel: a stop row carries an Edit of its own, so the
|
||||
// bare name matches two buttons as soon as any day is unfolded. The
|
||||
// ring goes round the panel's head band, title and pencil together: a
|
||||
// ring round the pencil alone sits on the day's name beside it.
|
||||
target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'),
|
||||
hover: p => renameDay(p).hover(),
|
||||
act: closeDayDetails,
|
||||
},
|
||||
only(p => p.getByRole('button', { name: /^(Expand all days|Collapse all days)$/ })),
|
||||
],
|
||||
},
|
||||
'bookings-in-plan': {
|
||||
guide: guide('bookings-in-plan'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: 5 })
|
||||
},
|
||||
steps: [
|
||||
// The flight, not the train: Departure and Arrival are what a booking
|
||||
// gets when it crosses days, and the train starts and ends on one. 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'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await selectDay(p, 1)
|
||||
await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
||||
act: closeDayDetails,
|
||||
},
|
||||
only(p => 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),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Export' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Export' }).click()
|
||||
await expect(modal(p).getByText('Document', { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'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 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDaysFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered days guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDaysContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-days', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
// The plan as it opens: the details panel is trip-day-detail's subject and the
|
||||
// route is day-route's, so the hero shows neither.
|
||||
await captureHero(page, tripDaysContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripDaysContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
|
||||
import { ensureNextcloudBinding } from './fixtures-trip-files'
|
||||
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Files tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so
|
||||
* `ensureFilesFixtures` puts a small set of drawn ones in before the first
|
||||
* picture; what a guide uploads, links, stars or throws away it undoes in
|
||||
* `cleanup`, so the next one starts from that same set.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripFilesGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The fixture documents each guide acts on, by the name their row carries. */
|
||||
const STARRED = 'JR-Pass-voucher.pdf'
|
||||
const LINKED = 'LH716-boarding-pass.pdf'
|
||||
const DOOMED = 'old-draft-itinerary.pdf'
|
||||
const PHOTO = 'nishiki-market.jpg'
|
||||
/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */
|
||||
const UPLOADED_PDF = 'kyoto-bus-pass.pdf'
|
||||
const UPLOADED_PHOTO = 'arashiyama.jpg'
|
||||
const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO]
|
||||
/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */
|
||||
const PLACE = 'teamLab Planets'
|
||||
const FLIGHT = 'LH716 FRA → HND'
|
||||
const NOTE = 'Seat 34K, boarding 12:20'
|
||||
|
||||
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
|
||||
|
||||
/**
|
||||
* The file rows, in the order the list shows them. A row is the `.group` card
|
||||
* whose last child holds the row's icon actions (`.file-actions`); matching on
|
||||
* that keeps any other hover group on the trip page out of the count.
|
||||
*/
|
||||
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
|
||||
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
|
||||
/** A row's actions are icons; each takes its accessible name from its tooltip. */
|
||||
const action = (page: Page, name: string, label: string) =>
|
||||
row(page, name).getByRole('button', { name: label, exact: true })
|
||||
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
|
||||
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
|
||||
/** The filter row, found through the one tab that is always there. */
|
||||
const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..')
|
||||
/** The star tab has no text: its accessible name is the count alone. */
|
||||
const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first()
|
||||
/**
|
||||
* The number on a filter tab. Reading it rather than counting the fixture keeps
|
||||
* the step honest about the one thing it is really claiming: that the tab shows
|
||||
* exactly as many rows as its badge promises, and it survives another screen's
|
||||
* fixture adding a file of its own.
|
||||
*/
|
||||
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
|
||||
/**
|
||||
* The trash icon at the right end of the bar, named by its tooltip. Trash and
|
||||
* Empty Trash both contain "Trash", so it needs `exact`.
|
||||
*/
|
||||
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
|
||||
/**
|
||||
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
|
||||
* its head band (Assign File, over the file's name) is what labels it.
|
||||
*/
|
||||
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
|
||||
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
|
||||
/**
|
||||
* The lightbox, found by the one header button only it and the document preview
|
||||
* carry, and they are never open at the same time. Its three direct children
|
||||
* are the header, the picture and the strip of thumbnails.
|
||||
*/
|
||||
const lightbox = (page: Page) =>
|
||||
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
|
||||
/**
|
||||
* The preview for everything that is not a picture: a DialogShell panel with
|
||||
* the file's name in the head band. For a PDF it holds the browser's embedded
|
||||
* viewer, the one `<object>` only it renders; the markdown preview
|
||||
* (`.collab-note-md`) has no step of its own.
|
||||
*/
|
||||
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
|
||||
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
|
||||
/** The round × in a dialog's head band, named Close by its tooltip. */
|
||||
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
|
||||
/**
|
||||
* A DialogShell hands the focus back to whatever opened it when it closes,
|
||||
* and a Tooltip opens on focus as well as on hover. The opener of the assign
|
||||
* dialog and of document sync is an icon with a tooltip, so without this the
|
||||
* after-picture shows "Assign" or "Document sync" floating under the pointer
|
||||
* that has long since moved away.
|
||||
*/
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
|
||||
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
|
||||
/** The head band's title; the trip's name stands under it as a line of its own. */
|
||||
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
|
||||
/**
|
||||
* A store row in the dialog's sidebar. Its accessible name runs on into the
|
||||
* subtitle (Files in a folder), so it is matched by its start.
|
||||
*/
|
||||
const storeRow = (page: Page, name: string) => modal(page).getByRole('button', { name: new RegExp(`^${name}\\b`) })
|
||||
/**
|
||||
* A box of the connection form, by the placeholder the store's field carries.
|
||||
* The labels are no good as locators here: the hint under Username says
|
||||
* "email address", so a lookup by the label Address finds two boxes.
|
||||
*/
|
||||
const formBox = (page: Page, placeholder: string) => dialog(page).getByPlaceholder(placeholder, { exact: true })
|
||||
/** The folder picker's row for the folder the fixture put in the store; the Create button is not in a list. */
|
||||
const folderRow = (page: Page) => dialog(page).locator('li button').filter({ hasText: NEXTCLOUD_FOLDER }).first()
|
||||
/** The binding card, the one <article> in the dialog once a store is bound. */
|
||||
const bindingCard = (page: Page) => dialog(page).locator('article').first()
|
||||
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
|
||||
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
|
||||
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
|
||||
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
|
||||
/** 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
|
||||
original_name: string
|
||||
starred?: number
|
||||
}
|
||||
|
||||
async function listFiles(page: Page, trash = false): Promise<ApiFile[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`)
|
||||
const body = (await res.json()) as { files?: ApiFile[] }
|
||||
return body.files ?? []
|
||||
}
|
||||
|
||||
/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */
|
||||
async function removeFiles(page: Page, names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
}
|
||||
for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the note and every link the guide made back to nothing. */
|
||||
async function unlinkFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||||
if (!file) return
|
||||
await page.request.put(`/api/trips/${tripId}/files/${file.id}`, {
|
||||
data: { place_id: null, reservation_id: null, description: '' },
|
||||
})
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`)
|
||||
const { links = [] } = (await res.json()) as { links?: { id: number }[] }
|
||||
for (const link of links) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The star route toggles, so it is only called back when the file really is starred. */
|
||||
async function unstarFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||||
if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`)
|
||||
}
|
||||
|
||||
/** Bring a file the guide threw away back out of the trash, if it is still in there. */
|
||||
async function untrashFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page, true)).find(f => f.original_name === name)
|
||||
if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`)
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/** 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'),
|
||||
// Starts on the plan, because the first step is reaching the tab.
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Files', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Files', exact: true }).click()
|
||||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(dropzone(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dropzone,
|
||||
// The hidden input takes the files directly: clicking the box would open
|
||||
// the operating system's picker, which Playwright cannot answer.
|
||||
act: async p => {
|
||||
await dropzone(p).locator('input[type="file"]').setInputFiles([
|
||||
filesPdfFixture('kyoto-bus-pass'),
|
||||
await filesImageFixture('arashiyama'),
|
||||
])
|
||||
await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole panel: the head band with the file's name, the note and
|
||||
// the two lists are what the step's text walks through.
|
||||
target: assignDialog,
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
await expect(assignDialog(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, UPLOADED_PHOTO),
|
||||
},
|
||||
],
|
||||
cleanup: p => removeFiles(p, UPLOADED),
|
||||
},
|
||||
'files-link': {
|
||||
guide: guide('files-link'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, LINKED, 'Assign'),
|
||||
act: async p => {
|
||||
await action(p, LINKED, 'Assign').click()
|
||||
await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, noteBox(p), NOTE)
|
||||
},
|
||||
// The label above the box belongs in the picture, so the step rings the
|
||||
// block rather than the input.
|
||||
target: p => noteBox(p).locator('xpath=..'),
|
||||
// The note is written on blur, and Enter blurs; the row behind the
|
||||
// dialog picks it up, which is the proof that it was saved.
|
||||
act: async p => {
|
||||
await noteBox(p).press('Enter')
|
||||
await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
// Exact: the bookings fixture names a booking after this place, and the
|
||||
// dialog lists both under the same opening words.
|
||||
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
|
||||
act: async p => {
|
||||
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
|
||||
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => assignDialog(p).getByRole('button', { name: FLIGHT }),
|
||||
act: async p => {
|
||||
await assignDialog(p).getByRole('button', { name: FLIGHT }).click()
|
||||
await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => closeX(assignDialog(p)),
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
await expect(assignDialog(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
// A badge carries the name alone now; what it links to is its tooltip.
|
||||
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unlinkFile(p, LINKED),
|
||||
},
|
||||
'files-star': {
|
||||
guide: guide('files-star'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, STARRED, 'Star'),
|
||||
act: async p => {
|
||||
await action(p, STARRED, 'Star').click()
|
||||
await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sorting is the server's (starred first, then newest first), so the
|
||||
// list is only right once the reload has come back.
|
||||
prepare: async p => {
|
||||
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => rows(p).first(),
|
||||
},
|
||||
{
|
||||
target: starTab,
|
||||
act: async p => {
|
||||
const starred = await tabCount(starTab(p))
|
||||
await starTab(p).click()
|
||||
await expect(rows(p)).toHaveCount(starred)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unstarFile(p, STARRED),
|
||||
},
|
||||
'files-filter': {
|
||||
guide: guide('files-filter'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
only(tabRow),
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^PDFs \d+$/ }),
|
||||
act: async p => {
|
||||
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
|
||||
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
|
||||
await expect(rows(p)).toHaveCount(pdfs)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click()
|
||||
await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^All \d+$/ }),
|
||||
act: async p => {
|
||||
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
|
||||
await p.getByRole('button', { name: /^All \d+$/ }).click()
|
||||
await expect(rows(p)).toHaveCount(all)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'files-preview': {
|
||||
guide: guide('files-preview'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
// Two buttons in the row carry the file's name: the thumbnail and the
|
||||
// name itself. The reader is told about the name, which comes second.
|
||||
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
|
||||
act: async p => {
|
||||
await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click()
|
||||
await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => lightbox(p).locator('> div').last(),
|
||||
// Which way the picture can move depends on where it sits in the strip,
|
||||
// and the right chevron is there only while there is a next one.
|
||||
act: async p => {
|
||||
const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0
|
||||
await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft')
|
||||
await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The header's button group, not the whole header bar: the bar runs the
|
||||
// full width of the frame, so ringing it rings mostly empty space.
|
||||
target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'),
|
||||
// The group's own centre falls between its buttons, where the picture
|
||||
// underneath takes the pointer, so hovering it never lands. The pointer
|
||||
// goes on a button instead; the ring is still round the group.
|
||||
hover: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).hover(),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// A PDF this time rather than the markdown file: the browser's own
|
||||
// viewer draws the page inside the card, which is what a reader sees
|
||||
// for a ticket. The card gets its source only once the signed download
|
||||
// link is minted (useFileManager.ts: getAuthUrl → setPreviewFileUrl),
|
||||
// and no DOM event says when the viewer has painted, so the step waits
|
||||
// for the file's own response and then a moment more, as the satellite
|
||||
// step of the map guide does for its tiles.
|
||||
prepare: async p => {
|
||||
const fetched = p
|
||||
.waitForResponse(r => r.url().includes('/download?token=') && r.ok(), { timeout: 30_000 })
|
||||
.catch(() => null)
|
||||
await row(p, LINKED).getByRole('button', { name: LINKED }).last().click()
|
||||
await expect(pdfPreview(p)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(pdfViewer(p)).toHaveAttribute('data', /\/download\?token=.+#view=FitH$/, { timeout: 30_000 })
|
||||
await fetched
|
||||
// The fallback link inside the <object> is drawn only where the
|
||||
// browser has no viewer; hidden, it proves the picture shows a page.
|
||||
await expect(pdfPreview(p).getByRole('button', { name: 'Download PDF' })).toBeHidden()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
target: pdfPreview,
|
||||
act: async p => {
|
||||
await closeX(pdfPreview(p)).click()
|
||||
await expect(pdfPreview(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => action(p, STARRED, 'Download'),
|
||||
// The button fetches the bytes and clicks a synthetic anchor, so the
|
||||
// browser reports a real download.
|
||||
act: async p => {
|
||||
const download = p.waitForEvent('download', { timeout: 30_000 })
|
||||
await action(p, STARRED, 'Download').click()
|
||||
await (await download).delete()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'files-trash': {
|
||||
guide: guide('files-trash'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, DOOMED, 'Delete'),
|
||||
act: async p => {
|
||||
await action(p, DOOMED, 'Delete').click()
|
||||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: trashTab,
|
||||
act: async p => {
|
||||
await trashTab(p).click()
|
||||
await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => row(p, DOOMED)),
|
||||
{
|
||||
target: p => action(p, DOOMED, 'Restore'),
|
||||
act: async p => {
|
||||
await action(p, DOOMED, 'Restore').click()
|
||||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Empty Trash is there because the fixture leaves one document in the
|
||||
// trash. It asks with the browser's own confirm, which Playwright
|
||||
// dismisses, so clicking it would do nothing at all; the step shows the
|
||||
// button and leaves the trash by the trash icon instead.
|
||||
target: p => p.getByRole('button', { name: 'Empty Trash' }),
|
||||
act: async p => {
|
||||
await trashTab(p).click()
|
||||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => untrashFile(p, DOOMED),
|
||||
},
|
||||
|
||||
'files-sync': {
|
||||
guide: guide('files-sync'),
|
||||
// The store's address and app password come from media.env (external.ts).
|
||||
// They are read inside the guide rather than at import, so a run without
|
||||
// the file fails this one guide and not the whole screen.
|
||||
start: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await resetDocSync(p.request, tripId)
|
||||
await ensureNextcloudFolder(drawPdf)
|
||||
await openFiles(p)
|
||||
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: syncButton,
|
||||
act: async p => {
|
||||
await syncButton(p).click()
|
||||
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
|
||||
// The stores arrive after a spinner; the heading over them says the list is in.
|
||||
await expect(modal(p).getByText('Connect a provider', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => storeRow(p, 'Nextcloud'),
|
||||
act: async p => {
|
||||
await storeRow(p, 'Nextcloud').click()
|
||||
// The connection dialog stacks on top, so `modal` is now that one.
|
||||
await expect(modal(p).getByRole('heading', { name: 'Nextcloud', exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(formBox(p, 'https://cloud.example.com')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const store = NEXTCLOUD_CONNECTION()
|
||||
await typeInto(p, formBox(p, 'https://cloud.example.com'), store.baseUrl)
|
||||
await typeInto(p, formBox(p, 'username'), store.credentials.login_name)
|
||||
await typeInto(p, formBox(p, 'app password'), store.credentials.app_password)
|
||||
await typeInto(p, formBox(p, '/TREK'), store.credentials.base_path)
|
||||
await beat(p, 300)
|
||||
},
|
||||
// The whole form, as the create-user step does: the labels and hints belong in the picture.
|
||||
target: dialog,
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Test connection' }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Test connection' }).click()
|
||||
await expect(
|
||||
dialog(p).getByText(`Reached it, signed in as ${NEXTCLOUD_CONNECTION().credentials.login_name}`),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Connect saves the credentials and opens the folder picker on top of
|
||||
// the dialog; the picture is the picker, with the folder the reader is
|
||||
// told to click, because that is the screen the step is about.
|
||||
prepare: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Connect', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Where should this trip live in Nextcloud?' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(folderRow(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: folderRow,
|
||||
act: async p => {
|
||||
await folderRow(p).click()
|
||||
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Binding starts a run of its own, which the button cannot join: a Sync
|
||||
// now while it is going answers busy and moves nothing, and the card
|
||||
// only learns of that first run from the ping it sends when it is
|
||||
// through. So the click is repeated until the card reports In sync,
|
||||
// which is a dot named by its tooltip once the state is good.
|
||||
prepare: async p => {
|
||||
await expect(async () => {
|
||||
await syncNow(p).click({ timeout: 15_000 })
|
||||
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
|
||||
}).toPass({ timeout: 120_000, intervals: [3_000] })
|
||||
await settle(p)
|
||||
},
|
||||
target: bindingCard,
|
||||
// Closed with the × in the head band, as the step's text says.
|
||||
act: async p => {
|
||||
await closeX(dialog(p)).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await dropFocus(p)
|
||||
// The run's file:created events put the pulled documents at the top of the list.
|
||||
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: 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 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,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureFilesFixtures(request)
|
||||
// A store on offer puts Document sync beside the trash icon for the trip's
|
||||
// owner, which is how bullet 6 of the screen text describes the bar; on for
|
||||
// the whole file, so the hero and every guide show the same bar.
|
||||
await toggleAddon(request, 'nextcloud', true)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, not trip-scoped: every store ships off, and the other
|
||||
// screens' pictures must not grow the button. Here rather than in the last
|
||||
// guide's cleanup, so a failed run puts it back too (as trip-roadtrip does).
|
||||
await toggleAddon(request, 'nextcloud', false)
|
||||
})
|
||||
|
||||
test('every registered files guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-files', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripFilesContext.id, openFiles)
|
||||
})
|
||||
|
||||
for (const id of tripFilesContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,897 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
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 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 => {
|
||||
const g = tripListsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** What the guides make and unmake. 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'
|
||||
const BAG = 'Cabin bag'
|
||||
const SAVED_TEMPLATE = 'Autumn in Japan'
|
||||
const NEW_TASK = 'Pick up the pocket wifi'
|
||||
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
|
||||
const TASK_LIST = 'On arrival'
|
||||
const OTHER_TASK_LIST = 'Before departure'
|
||||
/** 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 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 head band. */
|
||||
const cat = (page: Page, name: string): Locator =>
|
||||
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
|
||||
/**
|
||||
* The head band of a card. The ⋯ has to be taken from here: every item row
|
||||
* carries a ⋯ of its own, so the card as a whole holds several of them.
|
||||
*/
|
||||
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
|
||||
const catMenu = (page: Page, name: string): Locator =>
|
||||
catHeader(page, name).locator('button:has(svg.lucide-ellipsis, svg.lucide-more-horizontal)')
|
||||
/** The grip in a list's head band, before its colour dot. */
|
||||
const catGrip = (page: Page, name: string): Locator =>
|
||||
catHeader(page, name).getByRole('button', { name: 'Drag to reorder the category' })
|
||||
/** 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. */
|
||||
const rowMenu = (page: Page, name: string): Locator => item(page, name).locator('.packing-row-overflow button')
|
||||
/** The open item menu; Sharing is a whole line in it. */
|
||||
const rowMenuSharing = (page: Page): Locator =>
|
||||
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
|
||||
/**
|
||||
* The 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. */
|
||||
const shareMenu = (page: Page): Locator =>
|
||||
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
|
||||
/** Its click-catcher, the only way back out of the menu once a name is ticked. */
|
||||
const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last()
|
||||
/** The bulk-import dialog, a DialogShell whose head band carries its title. */
|
||||
const importCard = (page: Page): Locator =>
|
||||
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
|
||||
/**
|
||||
* The bag picker of a row and the member picker of a bag both hang off the
|
||||
* body, so they are found by their caption rather than inside the row.
|
||||
*/
|
||||
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
|
||||
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
|
||||
/** The calendar a date field opens, a portal of its own. */
|
||||
const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Date picker' })
|
||||
/** The Export button's menu: print, Markdown and CSV. */
|
||||
const exportMenu = (page: Page): Locator => page.getByRole('menu')
|
||||
/** The printable page inside the preview, a srcdoc frame of its own. */
|
||||
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
|
||||
/** The bag sidebar: the innermost box holding the guide's bag and the weight summary below the cards. */
|
||||
const bagSidebar = (page: Page): Locator =>
|
||||
page.locator('div')
|
||||
.filter({ has: page.getByText('Total weight', { exact: true }) })
|
||||
.filter({ has: page.getByText(BAG, { exact: true }) })
|
||||
.last()
|
||||
/** The detail pane on the right; its header reads Task. */
|
||||
const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]')
|
||||
/** A task row is a div with role=button whose name starts with the task's own. */
|
||||
const task = (page: Page, name: string): Locator =>
|
||||
page.getByRole('button', { name: new RegExp(`^${name}`) }).first()
|
||||
const sidebarFilter = (page: Page, name: string): Locator =>
|
||||
page.getByRole('button', { name: new RegExp(`^${name}`) })
|
||||
/**
|
||||
* 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 })
|
||||
|
||||
// ── Getting there ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The tab id in the address is the legacy 'listen'; the tab itself reads Lists. */
|
||||
async function openLists(page: Page): Promise<void> {
|
||||
await openTrip(page, { tab: 'listen' })
|
||||
await expect(page.getByRole('button', { name: /^Packing List/ })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-tab is remembered in sessionStorage, which Playwright never restores,
|
||||
* so every guide opens on Packing List and the to-do ones switch over.
|
||||
*/
|
||||
async function openTodos(page: Page): Promise<void> {
|
||||
await openLists(page)
|
||||
await page.getByRole('button', { name: /^To-Do/ }).click()
|
||||
await expect(page.getByRole('button', { name: 'Add new task' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── Putting the seed back ────────────────────────────────────────────────────
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
async function packingItems(page: Page): Promise<PackingRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/packing`)
|
||||
const body = (await res.json()) as { items?: PackingRow[] }
|
||||
return body.items ?? []
|
||||
}
|
||||
|
||||
async function deleteTemplate(page: Page, name: string): Promise<void> {
|
||||
const res = await page.request.get('/api/admin/packing-templates')
|
||||
const body = (await res.json()) as { templates?: { id: number; name: string }[] }
|
||||
for (const template of (body.templates ?? []).filter(row => row.name === name)) {
|
||||
await page.request.delete(`/api/admin/packing-templates/${template.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function 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`)
|
||||
const body = (await res.json()) as { items?: { id: number; name: string }[] }
|
||||
for (const row of (body.items ?? []).filter(x => x.name === name)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/todo/${row.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function unassignTodo(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/todo`)
|
||||
const body = (await res.json()) as { items?: { id: number; name: string }[] }
|
||||
const row = (body.items ?? []).find(x => x.name === name)
|
||||
if (row) await page.request.put(`/api/trips/${tripId}/todo/${row.id}`, { data: { assigned_user_id: null } })
|
||||
}
|
||||
|
||||
// ── Scripts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'packing-categories': {
|
||||
guide: guide('packing-categories'),
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add list' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add list' }).click()
|
||||
const field = p.getByPlaceholder('List name (e.g. Clothing)')
|
||||
await typeInto(p, field, NEW_LIST)
|
||||
await nameDialog(p, 'Add').click()
|
||||
await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }),
|
||||
act: async p => {
|
||||
await cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }).click()
|
||||
const field = p.getByPlaceholder('Item name...')
|
||||
await typeInto(p, field, FIRST_ITEM)
|
||||
// Enter renames the list's '...' row rather than adding a second one,
|
||||
// and leaves the field open; Escape is how it closes.
|
||||
await field.press('Enter')
|
||||
await expect(item(p, FIRST_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await field.press('Escape')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await onlyRow(p).hover() },
|
||||
// A click on the name renames it; the row has no pencil of its own.
|
||||
target: p => onlyRow(p).getByRole('button', { name: FIRST_ITEM }),
|
||||
act: async p => {
|
||||
await onlyRow(p).getByRole('button', { name: FIRST_ITEM }).click()
|
||||
// While editing, the name is the row's first input; the quantity and
|
||||
// the weight come after it.
|
||||
const field = onlyRow(p).locator('input').first()
|
||||
await typeInto(p, field, RENAMED_ITEM)
|
||||
await field.press('Enter')
|
||||
await expect(item(p, RENAMED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)'),
|
||||
act: async p => {
|
||||
const plus = catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)')
|
||||
await plus.click()
|
||||
await catHeader(p, NEW_LIST).getByRole('button', { name: MEMBER }).click()
|
||||
await expect(catHeader(p, NEW_LIST).locator('.assignee-chip')).toHaveCount(1, { timeout: 20_000 })
|
||||
await plus.click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, NEW_LIST).click()
|
||||
await expect(p.getByRole('button', { name: 'Delete List' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: catMenuCard,
|
||||
// The pointer rests on the three dots that opened it, not on an entry.
|
||||
// Moved by coordinates: the menu's click-catcher covers the button.
|
||||
hover: async p => {
|
||||
const b = await catMenu(p, NEW_LIST).boundingBox()
|
||||
if (b) await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2)
|
||||
},
|
||||
act: async p => {
|
||||
// The menu sits over a full-viewport catcher; a click in the corner
|
||||
// closes it without deleting the list the picture was just taken of.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(p.getByRole('button', { name: 'Delete List' })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await 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: startLists,
|
||||
steps: [
|
||||
{
|
||||
// The grip is a div, so the tick box is the row's first button.
|
||||
target: p => item(p, OPEN_ITEM).getByRole('button').first(),
|
||||
act: async p => {
|
||||
await item(p, OPEN_ITEM).getByRole('button').first().click()
|
||||
await expect(item(p, OPEN_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The count and the percentage, with the bar beside them: the whole card is
|
||||
// wider than any frame and would lose both at its edges.
|
||||
only(progressCount),
|
||||
{
|
||||
prepare: async p => {
|
||||
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(item(p, CHECK_ALL_OPEN).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Open', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Open', exact: true }).click()
|
||||
await expect(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 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 })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: resetPacking,
|
||||
},
|
||||
'count-packed-pieces': {
|
||||
guide: guide('count-packed-pieces'),
|
||||
start: async p => {
|
||||
await resetPacking(p)
|
||||
await untickPacking(p.request, PIECES_ITEM)
|
||||
await openLists(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await item(p, PIECES_ITEM).hover() },
|
||||
target: p => quantityField(p, PIECES_ITEM),
|
||||
act: async p => {
|
||||
const field = quantityField(p, PIECES_ITEM)
|
||||
await typeInto(p, field, String(PIECES))
|
||||
await field.press('Enter')
|
||||
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`0 of ${PIECES} packed`, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => piecesCounter(p, PIECES_ITEM),
|
||||
act: async p => {
|
||||
for (let n = 1; n <= 3; n++) {
|
||||
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
|
||||
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
|
||||
}
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Partly packed: the counter in the accent, the tick box outlined in it.
|
||||
target: p => item(p, PIECES_ITEM),
|
||||
act: async p => {
|
||||
for (let n = 4; n <= PIECES; n++) {
|
||||
await item(p, PIECES_ITEM).getByRole('button', { name: 'Pack one more' }).click()
|
||||
await expect(piecesCounter(p, PIECES_ITEM)).toHaveAccessibleName(`${n} of ${PIECES} packed`, { timeout: 20_000 })
|
||||
}
|
||||
await expect(item(p, PIECES_ITEM).locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => item(p, PIECES_ITEM)),
|
||||
],
|
||||
cleanup: resetPacking,
|
||||
},
|
||||
'apply-packing-template': {
|
||||
guide: guide('apply-packing-template'),
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Apply template' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Apply template' }).click()
|
||||
await expect(p.locator('.trek-menu-enter').last()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.trek-menu-enter').last(),
|
||||
act: async p => {
|
||||
await p.locator('.trek-menu-enter').last().getByRole('button', { name: new RegExp(`^${TEMPLATE.name}`) }).click()
|
||||
await expect(cat(p, TEMPLATE.category)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('button', { name: /^Shared/ })),
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Save as template' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Save as template' }).click()
|
||||
const field = p.getByPlaceholder('Template name')
|
||||
await typeInto(p, field, SAVED_TEMPLATE)
|
||||
await nameDialog(p, 'Save').click()
|
||||
await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await resetPacking(p)
|
||||
await deleteTemplate(p, SAVED_TEMPLATE)
|
||||
},
|
||||
},
|
||||
'import-packing-list': {
|
||||
guide: guide('import-packing-list'),
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
// An icon at the right end of the bar, named Import by its label and tooltip.
|
||||
target: p => p.getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importCard(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The step's text points at the grey sample, which is the textarea's
|
||||
// placeholder: it is only on screen while the box is empty, so the lines
|
||||
// are typed after the picture, not before it.
|
||||
target: importCard,
|
||||
act: async p => {
|
||||
await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES)
|
||||
await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 })
|
||||
// Out of the box again, or its dark focus ring reads as a second ring
|
||||
// in the next two pictures.
|
||||
await importCard(p).locator('textarea').blur()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// In the dialog's footer, on the left. Clicking it would open the operating
|
||||
// system's file chooser, which is not part of the picture; the button is
|
||||
// shown, not pressed.
|
||||
only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })),
|
||||
{
|
||||
target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }),
|
||||
act: async p => {
|
||||
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
|
||||
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(item(p, IMPORTED_LAST)).toBeVisible({ timeout: 30_000 })
|
||||
await item(p, IMPORTED_LAST).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: resetPacking,
|
||||
},
|
||||
'export-packing-list': {
|
||||
guide: guide('export-packing-list'),
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Export', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Export', exact: true }).click()
|
||||
await expect(exportMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The two file entries download at once; the step shows them, it does not press them.
|
||||
only(exportMenu),
|
||||
{
|
||||
target: p => exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }),
|
||||
act: async p => {
|
||||
await exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }).click()
|
||||
await expect(printFrame(p).locator('h1')).toBeVisible({ timeout: 15_000 })
|
||||
// The page carries the app's own Poppins; the result picture waits for it.
|
||||
await printFrame(p).locator('body').evaluate(async () => { await document.fonts.ready })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Pressing it opens the operating system's print dialog, which is not part
|
||||
// of the picture; the button is shown, not pressed. The result picture is the
|
||||
// preview itself.
|
||||
only(p => p.getByRole('button', { name: 'Print or save as PDF' })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await p.getByRole('button', { name: 'Close', exact: true }).first().click()
|
||||
},
|
||||
},
|
||||
'share-packing-item': {
|
||||
guide: guide('share-packing-item'),
|
||||
start: startLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^My list/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^My list/ }).click()
|
||||
await expect(p.getByText('No items match this filter')).toBeVisible({ timeout: 20_000 })
|
||||
await p.getByRole('button', { name: /^Shared/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await expect(rowMenuSharing(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: rowMenuSharing,
|
||||
act: async p => {
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: shareMenu,
|
||||
act: async p => {
|
||||
await shareMenu(p).getByRole('button', { name: /^Personal/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^My list/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^My list/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => shareMenu(p).getByRole('button', { name: MEMBER }),
|
||||
act: async p => {
|
||||
await shareMenu(p).getByRole('button', { name: MEMBER }).click()
|
||||
// Ticking a name leaves the menu open on purpose; its catcher closes it,
|
||||
// and the item menu under it has one of its own.
|
||||
await shareOverlay(p).click({ position: { x: 5, y: 5 } })
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(p.locator('.trek-menu-enter')).toHaveCount(0)
|
||||
await expect(item(p, SHARED_ITEM).getByLabel('shared with 1')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const row = (await packingItems(p)).find(x => x.name === SHARED_ITEM)
|
||||
if (row) {
|
||||
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
|
||||
}
|
||||
await resetPacking(p)
|
||||
},
|
||||
},
|
||||
'packing-bags': {
|
||||
guide: guide('packing-bags'),
|
||||
start: async p => {
|
||||
// A run that died half way leaves its bag behind, and the Bags card with it.
|
||||
await deleteBag(p, BAG)
|
||||
await startLists(p)
|
||||
},
|
||||
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('—'),
|
||||
act: async p => {
|
||||
const field = item(p, BAG_ITEM).getByPlaceholder('—')
|
||||
await typeInto(p, field, '620')
|
||||
await expect(field).toHaveValue('620', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await item(p, BAG_ITEM).hover() },
|
||||
// The circle is dashed with a parcel in it while the item is in no bag.
|
||||
target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'),
|
||||
act: async p => {
|
||||
await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click()
|
||||
await expect(bagPicker(p).getByRole('button', { name: 'Add bag' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => bagPicker(p).getByRole('button', { name: 'Add bag' }),
|
||||
act: async p => {
|
||||
await bagPicker(p).getByRole('button', { name: 'Add bag' }).click()
|
||||
const field = bagPicker(p).getByPlaceholder('Bag name...')
|
||||
await typeInto(p, field, BAG)
|
||||
await field.press('Enter')
|
||||
await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(bagPicker(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// 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' }),
|
||||
act: async p => {
|
||||
await bagSidebar(p).getByRole('button', { name: 'Set limit' }).click()
|
||||
// Entered in kilograms, stored in grams.
|
||||
// The sidebar locator needs Set limit, which the open editor replaces: look page-wide.
|
||||
const field = p.locator('input[aria-label="Weight limit"]')
|
||||
await typeInto(p, field, '8')
|
||||
await field.press('Enter')
|
||||
await expect(p.getByText('/ 8.0 kg').first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The card's member picker is the dashed plus beside the bag's name; the
|
||||
// column's other plus, + Bags in its head, adds a bag.
|
||||
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
|
||||
act: async p => {
|
||||
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
|
||||
await bagMemberPicker(p).getByRole('button', { name: MEMBER }).click()
|
||||
// The picker closes on a click anywhere beside it.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
|
||||
// A bag with someone on it fills Per person at the foot of the card.
|
||||
await expect(p.getByText('Per person', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await deleteBag(p, BAG)
|
||||
await resetPacking(p)
|
||||
},
|
||||
},
|
||||
'create-todo': {
|
||||
guide: guide('create-todo'),
|
||||
start: openTodos,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add new task' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add new task' }).click()
|
||||
await expect(modal(p).getByPlaceholder('Task name')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK)
|
||||
await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE)
|
||||
// Out of the field, so its dark focus border does not read as a second ring.
|
||||
await modal(p).getByPlaceholder('Description (optional)').blur()
|
||||
await settle(p)
|
||||
},
|
||||
// The name is typed into the dialog's head band, where Task name stands.
|
||||
target: p => modal(p).getByPlaceholder('Task name'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'No list' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'No list' }).click()
|
||||
// The select's list is portalled to <body>, outside the dialog.
|
||||
await 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'P1', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'P2', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Exact: the keyboard button beside it is "Enter date manually", which a
|
||||
// loose name match also finds.
|
||||
await modal(p).getByRole('button', { name: 'Date', exact: true }).click()
|
||||
await expect(datePicker(p)).toBeVisible()
|
||||
// The calendar opens on the picture day's month, the last of the trip,
|
||||
// and four days later can already be in the next one.
|
||||
if (!(await datePicker(p).getByRole('button', { name: DUE_DAY }).isVisible())) {
|
||||
await datePicker(p).getByRole('button', { name: 'Next month' }).click()
|
||||
}
|
||||
await expect(datePicker(p).getByRole('button', { name: DUE_DAY })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: datePicker,
|
||||
// The pointer on the day about to be picked, not on whatever the middle is.
|
||||
hover: async p => { await datePicker(p).getByRole('button', { name: DUE_DAY }).hover() },
|
||||
act: async p => {
|
||||
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
|
||||
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
|
||||
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create task' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create task' }).click()
|
||||
await expect(p.locator('.trek-modal-backdrop')).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(task(p, NEW_TASK)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteTodo(p, NEW_TASK),
|
||||
},
|
||||
'todo-filters': {
|
||||
guide: guide('todo-filters'),
|
||||
start: openTodos,
|
||||
steps: [
|
||||
{
|
||||
target: p => sidebarFilter(p, 'Overdue'),
|
||||
act: async p => {
|
||||
await sidebarFilter(p, 'Overdue').click()
|
||||
await expect(p.getByRole('heading', { name: 'Overdue' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidebarFilter(p, OTHER_TASK_LIST),
|
||||
act: async p => {
|
||||
await sidebarFilter(p, OTHER_TASK_LIST).click()
|
||||
await expect(p.getByRole('heading', { name: OTHER_TASK_LIST })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidebarFilter(p, 'All').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Priority', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Priority', exact: true }).click()
|
||||
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(PRIORITY_TASK, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => task(p, EDITED_TASK),
|
||||
act: async p => {
|
||||
await task(p, EDITED_TASK).click()
|
||||
await expect(todoPane(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await selectList(p, 'Unassigned').getByRole('button', { name: ASSIGNEE }).click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
|
||||
act: async p => {
|
||||
await todoPane(p).getByRole('button', { name: 'Save changes' }).click()
|
||||
// The row names its assignee as an avatar, labelled with the name.
|
||||
await expect(task(p, EDITED_TASK).getByLabel(ASSIGNEE)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unassignTodo(p, EDITED_TASK),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureListsFixtures(request)
|
||||
seedPacking = await snapshotPacking(request)
|
||||
})
|
||||
|
||||
test('every registered lists guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripListsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-lists', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripListsContext.id, startLists)
|
||||
})
|
||||
|
||||
for (const id of tripListsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureMapFixtures } from './fixtures'
|
||||
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
|
||||
import { ensureDawarichConnection, disconnectDawarich } from './external'
|
||||
import { 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'
|
||||
|
||||
/**
|
||||
* Actions for the map in the middle of a trip's plan, keyed by the ids in
|
||||
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
|
||||
* toggle has a second route to draw inside the frame, and with the two Kyoto
|
||||
* tours of the Tours fixtures, one of them on a day; what a guide changes it
|
||||
* puts back in `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripMapGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */
|
||||
const map = (page: Page) => page.locator('#trek-map')
|
||||
/** A cluster bubble. Its only text is the number of places folded into it. */
|
||||
const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom')
|
||||
/**
|
||||
* A place pin. Cluster bubbles and the explore results are markers too, so both
|
||||
* are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin
|
||||
* carries an `aria-label` (a place marker is a bare divIcon with no name at all,
|
||||
* which is why a specific place can only be found through its order badge).
|
||||
*/
|
||||
const pins = (page: Page) =>
|
||||
page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
|
||||
/** A place pin wearing the place's photo rather than its category icon. */
|
||||
const photoPins = (page: Page) => pins(page).filter({ has: page.locator('img') })
|
||||
/**
|
||||
* The pin the markers guide works on after its bubble opened, marked when it
|
||||
* was chosen: a photo pin the pointer can reach, or any reachable pin when no
|
||||
* photo has landed.
|
||||
*/
|
||||
const markerPin = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[data-help-pin]')
|
||||
/** The pin of the stop that is number `n` in the open day. */
|
||||
const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first()
|
||||
/** An explore result: the one kind of marker with an accessible name. */
|
||||
const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]')
|
||||
/** The end of a drawn booking route. */
|
||||
const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker')
|
||||
/** The card that follows the pointer over a pin. */
|
||||
const hoverCard = (page: Page) => page.getByTestId('tooltip')
|
||||
/**
|
||||
* A category of the explore pill. The phone portal renders a second copy of the
|
||||
* whole pill, hidden at this width, so this goes by role rather than by
|
||||
* `aria-label`, which would match both.
|
||||
*/
|
||||
const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true })
|
||||
/** The pill itself: the nearest block holding the first category and the last. */
|
||||
const categoryRow = (page: Page) =>
|
||||
category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]')
|
||||
/** The route icon on a booking's row, and the one in the toolbar above the days. */
|
||||
const connection = (page: Page, shown: boolean) =>
|
||||
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`)
|
||||
const allConnections = (page: Page, shown: boolean) =>
|
||||
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`)
|
||||
const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel')
|
||||
/** The round Dawarich button under the whole-trip one; its label is its state. */
|
||||
const trailPill = (page: Page) => page.getByTestId('dawarich-trail-pill')
|
||||
/**
|
||||
* The recorded route. Leaflet writes the dash pattern onto the path, and "6 5"
|
||||
* is this layer's alone: the pending booking routes use "6, 6", the picked
|
||||
* alternative "2 7", so the dashed days are the one thing on the map with it.
|
||||
*/
|
||||
const trail = (page: Page) => page.locator('#trek-map path[stroke-dasharray="6 5"]')
|
||||
/**
|
||||
* The GL renderer. There is no `#trek-map` on it: `MapViewGL` mounts a bare
|
||||
* `div.w-full.h-full` and MapLibre puts its own canvas inside, so the compass
|
||||
* guide goes by that class. The map itself is reached through
|
||||
* `window.__trek_map`, which `MapViewGL` publishes on build for exactly this.
|
||||
*/
|
||||
const glCanvas = (page: Page) => page.locator('.maplibregl-canvas')
|
||||
/**
|
||||
* The compass button and the frosted pill around it. The planner renders the
|
||||
* pill twice, once in the desktop cluster beside the category row and once for
|
||||
* phones, `md:hidden` at this width; `getByRole` leaves the hidden copy out.
|
||||
*/
|
||||
const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' })
|
||||
const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
|
||||
/**
|
||||
* The booking detail an endpoint opens: a DialogShell portal, so `dialog` finds
|
||||
* its panel, and its Close is the round X in the head band.
|
||||
*/
|
||||
const bookingDetail = (page: Page) => dialog(page)
|
||||
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
|
||||
/**
|
||||
* 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()
|
||||
/**
|
||||
* The card that opens under the map when a place is selected. By its own test
|
||||
* id, not by a button in it: the collection button reads Save to Collection or
|
||||
* Saved depending on whether that place is already in a list, and the seed puts
|
||||
* the three Kyoto places in one, so a locator on the label matches for some
|
||||
* pins and for others not.
|
||||
*/
|
||||
const inspector = (page: Page) => page.getByTestId('inspector-scroll')
|
||||
/** The details panel's collapse: it keeps the day selected, where its close would drop it. */
|
||||
const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)')
|
||||
/** A day card's header in the days column, the drop target of a dragged pin. */
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
|
||||
/**
|
||||
* Fold the day details away and take the focus off the fold button. The
|
||||
* button keeps the focus after the click, and TREK's tooltip shows on focus,
|
||||
* so its Expand would otherwise stand at the foot of every later picture.
|
||||
*/
|
||||
async function foldDayDetails(page: Page): Promise<void> {
|
||||
await collapseDayDetails(page).last().click()
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of the map between the two floating columns, in page pixels. A
|
||||
* pin under a column can still be hovered at its edge, but a picture of it is
|
||||
* a picture of the column.
|
||||
*/
|
||||
async function openCorridor(page: Page): Promise<{ left: number; right: number }> {
|
||||
const box = await map(page).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
return { left: box.x + 420, right: box.x + box.width - 420 }
|
||||
}
|
||||
|
||||
/** Like `reachable`, but the pin also has to stand in the open part of the map when one does. */
|
||||
async function reachableInView(page: Page, locator: Locator, fromEnd = false): Promise<number> {
|
||||
const { left, right } = await openCorridor(page)
|
||||
const total = await locator.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const index = fromEnd ? total - 1 - i : i
|
||||
const b = await locator.nth(index).boundingBox()
|
||||
if (!b || b.x < left || b.x + b.width > right || b.y < 260) continue
|
||||
try {
|
||||
await locator.nth(index).hover({ trial: true, timeout: 1500 })
|
||||
return index
|
||||
} catch {
|
||||
// Covered by another marker.
|
||||
}
|
||||
}
|
||||
return reachable(locator, fromEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot on the map with nothing on it: right of the days column, left of the
|
||||
* explore pill, and clear of everything the plan floats over the lower half of
|
||||
* the map. Map-relative, so it is passed as a click `position`.
|
||||
*/
|
||||
const EMPTY = { x: 470, y: 80 }
|
||||
|
||||
/**
|
||||
* The first marker of a set the pointer can actually reach. Leaflet stacks
|
||||
* markers that overlap and the two columns float over the edges of the map, so
|
||||
* `.first()` is regularly one that nothing can hover, let alone click. Explore
|
||||
* results are searched from the end because Leaflet draws the southernmost
|
||||
* marker on top.
|
||||
*/
|
||||
async function reachable(locator: Locator, fromEnd = false): Promise<number> {
|
||||
const total = await locator.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const index = fromEnd ? total - 1 - i : i
|
||||
try {
|
||||
await locator.nth(index).hover({ trial: true, timeout: 1500 })
|
||||
return index
|
||||
} catch {
|
||||
// Covered by another marker or by one of the floating columns.
|
||||
}
|
||||
}
|
||||
throw new Error(`no reachable marker among ${total}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the map the way a reader would, so "Search this area" appears.
|
||||
*
|
||||
* Not the arrow keys: Leaflet only listens for them while its container has
|
||||
* focus, and focusing it from here did not take, so the map never moved and the
|
||||
* button that only exists once it has never appeared. The drag starts from a
|
||||
* point checked to be empty, because a press on a marker drags the place onto a
|
||||
* day instead of moving the map.
|
||||
*/
|
||||
async function panMap(page: Page, by: { x: number; y: number } = { x: -176, y: -72 }): Promise<void> {
|
||||
const box = await map(page).boundingBox()
|
||||
if (!box) throw new Error('the map has no box to drag')
|
||||
const candidates = [0.3, 0.7, 0.5].flatMap(fx => [0.75, 0.25].map(fy => ({
|
||||
x: Math.round(box.x + box.width * fx),
|
||||
y: Math.round(box.y + box.height * fy),
|
||||
})))
|
||||
let from: { x: number; y: number } | undefined
|
||||
for (const point of candidates) {
|
||||
const clear = await page.evaluate(({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
return !!el && !el.closest('.leaflet-marker-icon, button, [role="button"], .leaflet-control')
|
||||
}, point)
|
||||
if (clear) { from = point; break }
|
||||
}
|
||||
if (!from) throw new Error('found nowhere on the map to take hold of')
|
||||
await page.mouse.move(from.x, from.y)
|
||||
await page.mouse.down()
|
||||
// In steps, so Leaflet reads a drag rather than a click.
|
||||
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + (by.x * i) / 8, from.y + (by.y * i) / 8, { steps: 2 })
|
||||
await page.mouse.up()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip framed on day 1's two Tokyo stops.
|
||||
*
|
||||
* The frame is what the three guides starting here have in common: one
|
||||
* right-clicks a fixed point on the map and needs a street under it, one asks
|
||||
* the map what it can see for cafes, one puts imagery under the pins. The
|
||||
* whole-trip fit spans Japan, where that fixed point is the Sea of Japan as
|
||||
* often as it is a street and the address look-up comes back empty, and zooming
|
||||
* into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by
|
||||
* chance. Day 1 is the one frame the seed guarantees is over a city.
|
||||
*
|
||||
* The details panel is folded rather than closed: closing it deselects the day
|
||||
* (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to
|
||||
* the whole trip.
|
||||
*/
|
||||
async function openOnTokyo(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, 1)
|
||||
await foldDayDetails(page)
|
||||
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which renderer draws the planner and the journals. On the account, not on
|
||||
* the trip: `MapViewAuto` reads `settings.map_provider`, and `loadSettings`
|
||||
* runs on every page load, so a `page.goto` after this sees the new map.
|
||||
*/
|
||||
async function setMapProvider(page: Page, provider: 'leaflet' | 'maplibre-gl'): Promise<void> {
|
||||
const res = await page.request.post('/api/settings/bulk', { data: { settings: { map_provider: provider } } })
|
||||
if (!res.ok()) throw new Error(`could not set map_provider=${provider}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
|
||||
/** The slice of the GL map the compass guide reads and moves, on `window.__trek_map`. */
|
||||
interface TrekGlMap {
|
||||
getBearing: () => number
|
||||
getPitch: () => number
|
||||
loaded: () => boolean
|
||||
areTilesLoaded: () => boolean
|
||||
isMoving: () => boolean
|
||||
easeTo: (o: { bearing: number; pitch: number; duration: number }) => unknown
|
||||
}
|
||||
|
||||
/** Wait until the GL map has its style, its tiles and a still camera. */
|
||||
async function glMapIdle(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && m.loaded() && m.areTilesLoaded() && !m.isMoving()
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the raster basemap has actually painted.
|
||||
*
|
||||
* `toBeVisible()` on a tile is not enough: Leaflet puts the <img> in the DOM
|
||||
* with its position and its size before the byte arrives, so a picture taken on
|
||||
* that signal catches the grey underneath. A tile counts only once the browser
|
||||
* has decoded it, and a screenful needs several of them.
|
||||
*/
|
||||
async function rasterTilesPainted(page: Page, atLeast = 6): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
min => {
|
||||
const tiles = Array.from(document.querySelectorAll('#trek-map img.leaflet-tile'))
|
||||
const painted = tiles.filter(t => {
|
||||
const img = t as HTMLImageElement
|
||||
return img.complete && img.naturalWidth > 0
|
||||
})
|
||||
return painted.length >= min
|
||||
},
|
||||
atLeast,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The markers the running guide works on. Which one is reachable is decided
|
||||
* once, by `reachable`, and the steps that follow reuse the answer; `pin` holds
|
||||
* a place pin, an explore result or a route endpoint, whichever the guide is
|
||||
* about. `found` is the name of the explore result a guide took into the trip,
|
||||
* so `cleanup` can take exactly that one out again.
|
||||
*/
|
||||
let bubble = 0
|
||||
let pin = 0
|
||||
let found = ''
|
||||
/** 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'
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'map-lock': {
|
||||
guide: guide('map-lock'),
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByTestId('map-lock-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('map-lock-pill').click()
|
||||
await expect(p.getByRole('button', { name: 'Let the map follow the selection' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const unlock = p.getByRole('button', { name: 'Let the map follow the selection' })
|
||||
if (await unlock.count()) await unlock.click()
|
||||
},
|
||||
},
|
||||
'map-markers': {
|
||||
guide: guide('map-markers'),
|
||||
start: async p => {
|
||||
await openTrip(p)
|
||||
await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
bubble = await reachable(clusters(p))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => clusters(p).nth(bubble),
|
||||
act: async p => {
|
||||
await clusters(p).nth(bubble).click()
|
||||
await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2)
|
||||
// The pin's own photo is a thumb cut after the page has loaded, and
|
||||
// until it lands the pin is a bare icon beside a card that already
|
||||
// shows the photo. Bounded: a place with no photo anywhere stays an icon.
|
||||
await expect.poll(() => photoPins(p).count(), { timeout: 20_000 }).toBeGreaterThan(0).catch(() => {})
|
||||
await settle(p)
|
||||
// Marked rather than counted: more thumbs keep landing, and an index
|
||||
// among the photo pins would point at another pin a moment later.
|
||||
const photoPin = await reachable(photoPins(p)).catch(() => -1)
|
||||
const chosen = photoPin >= 0 ? photoPins(p).nth(photoPin) : pins(p).nth(await reachable(pins(p)))
|
||||
await chosen.evaluate(el => el.setAttribute('data-help-pin', ''))
|
||||
},
|
||||
},
|
||||
{
|
||||
// captureGuide hovers the target itself; this only makes sure the card
|
||||
// is up before the shot rather than a frame after it.
|
||||
prepare: async p => {
|
||||
await markerPin(p).hover()
|
||||
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
|
||||
},
|
||||
target: markerPin,
|
||||
},
|
||||
{
|
||||
// The picture worth having is the panel the click opens, not the pin
|
||||
// again, so the click is the preparation and the panel is the target.
|
||||
prepare: async p => {
|
||||
await markerPin(p).click()
|
||||
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await map(p).click({ position: EMPTY })
|
||||
await expect(inspector(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await selectDay(p, 1)
|
||||
// The panel IS the selection, so it is folded away rather than closed:
|
||||
// closing it would take the numbers off the pins again.
|
||||
await foldDayDetails(p)
|
||||
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => badged(p, 1),
|
||||
},
|
||||
{
|
||||
// Picture only, like the other drags in this family: HTML5 drag is what
|
||||
// the map hands the day plan, and a synthesised one does not start it.
|
||||
// Day 1's second stop onto day 3, the first empty day: a drop there
|
||||
// plainly plans something new, where one onto a day that already holds
|
||||
// the place would read as a move.
|
||||
prepare: async p => {
|
||||
await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => badged(p, 2),
|
||||
dropTo: p => dayHeader(p, 3).locator('xpath=..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
'map-nearby-places': {
|
||||
guide: guide('map-nearby-places'),
|
||||
// A search covers what is on screen, and day 1's fit is the two Tokyo stops
|
||||
// with the city between them: enough of a district for the sixty hits to
|
||||
// spread over the frame instead of piling up in the middle of it.
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{ target: categoryRow },
|
||||
{
|
||||
target: p => category(p, 'Cafés'),
|
||||
act: async p => {
|
||||
await category(p, 'Cafés').click()
|
||||
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Towards the right. The search covers the whole map, the part under
|
||||
// the two columns included, and on day 1's frame most of the first
|
||||
// answer lay under the days column; dragged this way, the west of the
|
||||
// view is open map when it is searched again, and the new pins are
|
||||
// where a reader can see them.
|
||||
await panMap(p, { x: 440, y: 96 })
|
||||
await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Search this area' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Search this area' }).click()
|
||||
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
pin = await reachableInView(p, poiPins(p), true)
|
||||
},
|
||||
target: p => poiPins(p).nth(pin),
|
||||
act: async p => {
|
||||
await poiPins(p).nth(pin).click()
|
||||
const name = modal(p).getByPlaceholder('e.g. Eiffel Tower')
|
||||
await expect(name).not.toHaveValue('', { timeout: 20_000 })
|
||||
// Which places OpenStreetMap answers with is never the same twice, so
|
||||
// the name is read off the form rather than assumed, and `cleanup`
|
||||
// deletes exactly what was created.
|
||||
found = await name.inputValue()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The whole dialog: the name is typed in its head band, outside the form.
|
||||
{ target: p => dialog(p) },
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, found)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, found),
|
||||
},
|
||||
'map-add-place': {
|
||||
guide: guide('map-add-place'),
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: map,
|
||||
// The map fills the frame, so the ring would sit nowhere in particular:
|
||||
// park the pointer on the spot the right-click is about to land on.
|
||||
hover: async p => {
|
||||
const box = await map(p).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
|
||||
},
|
||||
act: async p => {
|
||||
await map(p).click({ button: 'right', position: EMPTY })
|
||||
await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The address is a look-up of the coordinates and lands after the form.
|
||||
await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('')
|
||||
await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => dialog(p),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, NEW_PLACE),
|
||||
},
|
||||
'map-satellite': {
|
||||
guide: guide('map-satellite'),
|
||||
start: async p => {
|
||||
await openOnTokyo(p)
|
||||
pin = await reachable(pins(p))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Switch to satellite view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Switch to satellite view' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible()
|
||||
// The imagery is raster tiles from a third party, and the switch is
|
||||
// done long before they are on screen.
|
||||
await rasterTilesPainted(p)
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
},
|
||||
{ prepare: p => rasterTilesPainted(p), target: p => pins(p).nth(pin) },
|
||||
{
|
||||
prepare: p => rasterTilesPainted(p),
|
||||
target: p => p.getByRole('button', { name: 'Switch to map view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Switch to map view' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The layer is kept on the account, not on the trip: left on satellite it
|
||||
// would be the basemap of every picture taken after this guide.
|
||||
cleanup: async p => {
|
||||
await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } })
|
||||
},
|
||||
},
|
||||
'map-whole-trip': {
|
||||
guide: guide('map-whole-trip'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByTestId('trip-overview-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('trip-overview-pill').click()
|
||||
// One router request per leg, paced, so the card takes its time.
|
||||
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The total carries an … until every leg has answered; a picture taken
|
||||
// before that shows a partial sum.
|
||||
prepare: async p => {
|
||||
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: overviewPanel,
|
||||
},
|
||||
{
|
||||
target: p => overviewPanel(p).getByRole('button').first(),
|
||||
act: async p => {
|
||||
await overviewPanel(p).getByRole('button').first().click()
|
||||
await settle(p)
|
||||
// The text's second half: the wheel over Tokyo. With the whole trip
|
||||
// shown, the camera stays on all of it after the pick (the fit that
|
||||
// follows takes in every day's line), and there a day of ten
|
||||
// kilometres is a few pixels under a bubble; four notches bring days
|
||||
// 1 and 2 out side by side. Tokyo is the easternmost bubble of the seed.
|
||||
const boxes = await clusters(p).evaluateAll(els => els.map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
|
||||
}))
|
||||
const tokyo = boxes.sort((a, b) => b.x - a.x)[0]
|
||||
if (!tokyo) throw new Error('no bubble on the map to zoom in on')
|
||||
await p.mouse.move(tokyo.x, tokyo.y)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await p.mouse.wheel(0, -100)
|
||||
await p.waitForTimeout(450)
|
||||
}
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// No act: the guide ends with the whole trip on the map, which is what
|
||||
// the result picture is of. `cleanup` presses the button.
|
||||
{ target: p => p.getByRole('button', { name: 'Hide whole trip' }) },
|
||||
],
|
||||
cleanup: async p => {
|
||||
const off = p.getByRole('button', { name: 'Hide whole trip' })
|
||||
if (await off.isVisible().catch(() => false)) await off.click()
|
||||
},
|
||||
},
|
||||
'map-booking-routes': {
|
||||
guide: guide('map-booking-routes'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{ target: p => connection(p, false).first() },
|
||||
{
|
||||
prepare: async p => {
|
||||
await connection(p, false).first().click()
|
||||
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1)
|
||||
await settle(p)
|
||||
pin = await reachable(endpoints(p))
|
||||
},
|
||||
target: p => endpoints(p).nth(pin),
|
||||
act: async p => {
|
||||
await endpoints(p).nth(pin).click()
|
||||
await expect(bookingDetailClose(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole detail, head band to footer: the text walks through all of it.
|
||||
target: bookingDetail,
|
||||
act: async p => {
|
||||
await bookingDetailClose(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => allConnections(p, false),
|
||||
act: async p => {
|
||||
await allConnections(p, false).click()
|
||||
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => allConnections(p, true),
|
||||
act: async p => {
|
||||
await allConnections(p, true).click()
|
||||
await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The per-trip choice lives in this browser, not on the server, so it would
|
||||
// otherwise carry into every guide that runs after it in the same context.
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId)
|
||||
},
|
||||
},
|
||||
'map-dawarich-trail': {
|
||||
guide: guide('map-dawarich-trail'),
|
||||
// Day 1 in Tokyo: at the whole-trip zoom the recording is one dashed line
|
||||
// along the Shinkansen and the walks through the cities vanish under the
|
||||
// pins, so the map is framed on a day before the button is pressed;
|
||||
// nothing fits the map to the trail.
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: trailPill,
|
||||
act: async p => {
|
||||
await trailPill(p).click()
|
||||
// One read of the trip's dates from the Dawarich, thinned to 600
|
||||
// points a day. The label is the reliable signal: anything but Hide
|
||||
// (nothing recorded, unreachable, offline) is not a picture worth
|
||||
// taking, so the wait fails on it rather than shooting an empty map.
|
||||
await expect(trailPill(p)).toHaveAttribute('aria-label', 'Hide recorded route', { timeout: 120_000 })
|
||||
await expect(trail(p).first()).toBeAttached({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
await p.waitForTimeout(600)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole map: the dashed day under the planned route, with the
|
||||
// pointer parked on an empty spot so no pin's card is in the picture.
|
||||
target: map,
|
||||
hover: async p => {
|
||||
const box = await map(p).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByTestId('trip-overview-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('trip-overview-pill').click()
|
||||
// One router request per leg, paced; the total carries an … until
|
||||
// every leg has answered, and the result picture waits for that.
|
||||
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
|
||||
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const off = p.getByRole('button', { name: 'Hide whole trip' })
|
||||
if (await off.isVisible().catch(() => false)) await off.click()
|
||||
if ((await trailPill(p).getAttribute('aria-pressed').catch(() => null)) === 'true') await trailPill(p).click()
|
||||
// Per trip and per browser session; the test's context ends here, but
|
||||
// be explicit rather than trust that.
|
||||
const { tripId } = seededTrip()
|
||||
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
|
||||
},
|
||||
},
|
||||
'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'),
|
||||
// The compass only exists on the GL renderers, so the guide switches the
|
||||
// account to MapLibre GL (no token, OpenFreeMap tiles) before opening the
|
||||
// trip, and `cleanup` puts Leaflet back: every other map guide goes by
|
||||
// `#trek-map`, which the GL map does not have.
|
||||
start: async p => {
|
||||
await setMapProvider(p, 'maplibre-gl')
|
||||
await openTrip(p)
|
||||
// MapViewAuto shows Leaflet while the GL chunk loads, then swaps; WebGL
|
||||
// runs on SwiftShader headless at 1920×1080×2, so the style, the vector
|
||||
// tiles from tiles.openfreemap.org and the day-1 framing take their time.
|
||||
await expect(glCanvas(p)).toBeVisible({ timeout: 60_000 })
|
||||
await expect(resetNorth(p)).toBeVisible({ timeout: 30_000 })
|
||||
await glMapIdle(p)
|
||||
await settle(p)
|
||||
// `settle` counts `<img>` loads; a WebGL canvas has none, so give the
|
||||
// tiles a moment to paint or the picture catches the blank canvas.
|
||||
await p.waitForTimeout(2000)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// The turn itself is the map's own camera move rather than a synthetic
|
||||
// right-button drag: a drag has to start on bare canvas, and the pins,
|
||||
// the two floating columns and the explore pill all sit over it. 35°
|
||||
// is enough for the streets to lie visibly askew and the arrow to lean.
|
||||
prepare: async p => {
|
||||
await p.evaluate(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
if (!m) throw new Error('no GL map on the page')
|
||||
m.easeTo({ bearing: 35, pitch: 0, duration: 0 })
|
||||
})
|
||||
await p.waitForFunction(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && Math.abs(m.getBearing() - 35) < 0.5 && !m.isMoving()
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
target: compassPill,
|
||||
// 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,
|
||||
act: async p => {
|
||||
await resetNorth(p).click()
|
||||
// `easeTo` takes 300 ms; the compass is only upright once it has landed.
|
||||
await p.waitForFunction(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && Math.abs(m.getBearing()) < 0.5 && Math.abs(m.getPitch()) < 0.5 && !m.isMoving()
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The renderer is kept on the account, not on the trip: left on MapLibre it
|
||||
// would be the map of every picture taken after this guide, and every
|
||||
// `#trek-map` locator in the other guides would find nothing.
|
||||
cleanup: p => setMapProvider(p, 'leaflet'),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureMapFixtures(request)
|
||||
// The 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)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, not trip-scoped: while the addon is on, every trip map
|
||||
// carries the round Dawarich button in its bottom-right corner. This file's
|
||||
// pictures show it on purpose; the other trip screens' do not, so it goes
|
||||
// off again here, whatever happened in between. The renderer likewise: the
|
||||
// compass guide puts Leaflet back in its cleanup, but a guide that fails
|
||||
// never reaches its cleanup, and every `#trek-map` locator in the files that
|
||||
// run after this one would find a GL canvas instead.
|
||||
await disconnectDawarich(request)
|
||||
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
|
||||
// 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 () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-map', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripMapContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripMapContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,733 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Actions for the place card that floats over the map, keyed by the ids in
|
||||
* `src/help/contexts/tripPlace.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Kyoto walk imported, the day fixtures in place and this screen's own
|
||||
* fixtures on top; what a guide changes it changes back in `cleanup`, so the
|
||||
* next one starts from the same card.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripPlaceGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** An unplanned waypoint of the imported walk: the place that is on no day yet. */
|
||||
const SPARE = 'Ginkaku-ji'
|
||||
/** The walk itself: the only place of the trip that carries a path. */
|
||||
const TRACK = "Philosopher's Path"
|
||||
/** What the GPX import hands the trip's first track: TRACK_COLORS[0]. */
|
||||
const TRACK_IMPORT_COLOR = '#1d4ed8'
|
||||
/** The colour the guide picks instead: TRACK_COLORS[1]. */
|
||||
const TRACK_PICKED_COLOR = '#ea580c'
|
||||
/** The booking `ensureDaysFixtures` pins to the market stop on day 6. */
|
||||
const BOOKING = 'Lunch at Nishiki'
|
||||
/** The only list the seed creates. */
|
||||
const LIST = 'Kyoto shortlist'
|
||||
/** The list's row in the picker: its name, then the status chip once the place is in it. */
|
||||
const LIST_ROW = new RegExp(`^${LIST}(?: (?:Idea|Planned|Visited))?$`)
|
||||
const UPLOADED = 'cover-fixture.jpg'
|
||||
/**
|
||||
* The 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=..')
|
||||
const head = (page: Page) => card(page).locator('> header')
|
||||
const footer = (page: Page) => card(page).locator('> footer')
|
||||
/** A block of the card by its heading: every block is a <section> that opens with it. */
|
||||
const section = (page: Page, heading: string) =>
|
||||
card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
|
||||
/** A row of the places column (the rows are options of a list). */
|
||||
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
/** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */
|
||||
const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first()
|
||||
/** PlaceRating's root: the stars, the average and the voter faces on one line. */
|
||||
const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..')
|
||||
/** ParticipantsBox: the block under its heading. */
|
||||
const participants = (page: Page) => section(page, 'Participants')
|
||||
/** A member's chip, or, once the chip is gone, that member's line in the add list. */
|
||||
const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) })
|
||||
const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true })
|
||||
/** "Files" while the place has none, "<n> files" once it has. */
|
||||
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files?)$/ })
|
||||
/** The booking strip opens the booking; it is a button named after it. */
|
||||
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
|
||||
/** The booking the strip opens, named by its title. Its form is one Edit further. */
|
||||
const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING })
|
||||
/** The strip's lower row: Date, Time and Booking Code beside each other. */
|
||||
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
// Exact: the picker's own button is named "Track color: #EA580C" once the colour is set.
|
||||
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR, exact: true })
|
||||
/** The seed's day 1: the trip starts nine days before the picture day (`start_date: day(-9)`). */
|
||||
const DAY_ONE = -9
|
||||
/** The weekday the English UI prints for a seeded day, `Saturday` on a run where day 1 is one. */
|
||||
const weekday = (offset: number) => long(offset).split(',')[0]
|
||||
/**
|
||||
* The Opening Hours row: the one button of the card with a clock in it. Found
|
||||
* by the icon rather than the label, because the label is the open day's line
|
||||
* ("Saturday: 06:00-17:00") and the day moves with the run.
|
||||
*/
|
||||
const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)')
|
||||
/** The hours block: the row and, once unfolded, the week in its place. */
|
||||
const hoursBox = (page: Page) => section(page, 'Opening Hours')
|
||||
/** The round picture in the head; its name says whether a picture of your own is set. */
|
||||
const avatar = (page: Page) => card(page).getByRole('button', { name: /^(Upload image|Change image)$/ })
|
||||
/** The popover the picture opens when the place has photos among its files. */
|
||||
const imageChooser = (page: Page) => page.getByRole('dialog', { name: 'Choose a picture' })
|
||||
/** The place form's hours editor, found by its remove button (only there while editing). */
|
||||
const hoursEditor = (page: Page) =>
|
||||
modal(page).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::section[1]')
|
||||
|
||||
/**
|
||||
* The card's own X, the one in its head; the day panel over the map has another.
|
||||
* By name: the red X on a custom picture is in the head as well.
|
||||
*/
|
||||
async function closeCard(page: Page): Promise<void> {
|
||||
await head(page).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(card(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the provider's answer for a place into the card's own cache before the
|
||||
* card asks for it. The card reads `sessionStorage` under this key first
|
||||
* (PlaceInspector's usePlaceDetails) and only then asks the server, with an
|
||||
* eight-second limit that Overpass exceeds as often as not; asked here over
|
||||
* the API there is no such limit, and a thin answer is asked again.
|
||||
*/
|
||||
async function primeDetails(page: Page, placeName: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { name: string; osm_id?: string | null }[] } | { name: string; osm_id?: string | null }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
const osmId = places.find(p => p.name === placeName)?.osm_id
|
||||
if (!osmId) throw new Error(`${placeName} carries no OpenStreetMap id; ensurePlaceFixtures did not run`)
|
||||
type Details = { opening_hours?: string[] | null } | null
|
||||
const hasWeek = (d: Details): boolean => Array.isArray(d?.opening_hours) && d.opening_hours.length === 7
|
||||
let details: Details = null
|
||||
for (let attempt = 1; attempt <= 3 && !hasWeek(details); attempt++) {
|
||||
const answer = await page.request.get(`/api/maps/details/${encodeURIComponent(osmId)}?lang=en`, { timeout: 45_000 })
|
||||
details = answer.ok() ? ((await answer.json()) as { place: Details }).place : null
|
||||
}
|
||||
if (!hasWeek(details)) throw new Error(`no opening hours for ${placeName} (${osmId}) from the details route`)
|
||||
await page.evaluate(([key, value]) => sessionStorage.setItem(key, value), [`gdetails_${osmId}_en`, JSON.stringify(details)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a place that carries an OpenStreetMap id and wait until the provider's
|
||||
* answer is on the card.
|
||||
*
|
||||
* The hours, the ring and the phone number arrive one request after the card:
|
||||
* the server asks Overpass and Nominatim for them, caches nothing on that path,
|
||||
* and the card swallows a lookup that times out. `settle` waits five seconds
|
||||
* for the network at most, which a slow mirror exceeds, and the card would then
|
||||
* be pictured without the feature the text is about. So this waits for the
|
||||
* hours row itself, and when it has not come, closes the card and opens the
|
||||
* place again, which fires the lookup afresh (closing clears the id the hook
|
||||
* watches). A third miss fails the run. With `day` the row's line is checked
|
||||
* against that day's weekday, which is what the step text promises; with
|
||||
* `phone` the tel link has to be there too.
|
||||
*/
|
||||
async function openWithHours(
|
||||
page: Page,
|
||||
open: (p: Page) => Promise<void>,
|
||||
opts: { day?: number; phone?: RegExp; place?: string } = {},
|
||||
): Promise<void> {
|
||||
await primeDetails(page, opts.place ?? 'Senso-ji Temple')
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
await open(page)
|
||||
const shown = await hoursRow(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false)
|
||||
if (shown) break
|
||||
if (attempt === 3) throw new Error(`no opening hours on the card after ${attempt} openings`)
|
||||
await closeCard(page)
|
||||
}
|
||||
if (opts.day !== undefined) await expect(hoursRow(page)).toHaveText(new RegExp(`^${weekday(opts.day)}: `))
|
||||
if (opts.phone) await expect(card(page).getByRole('link', { name: opts.phone })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openPlace(page: Page, name: string): Promise<void> {
|
||||
await row(page, name).click()
|
||||
await expect(card(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Opening the card from a stop is what gives it the stop's participants and booking. */
|
||||
async function openStop(page: Page, name: string): Promise<void> {
|
||||
await stop(page, name).click()
|
||||
await expect(card(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── The seed over the API, for the cleanups ──────────────────────────────────
|
||||
|
||||
type ApiPlace = { id: number; name: string; lat?: number | null; lng?: number | null }
|
||||
type ApiAssignment = { id: number; place_id: number }
|
||||
|
||||
async function tripPlaces(page: Page): Promise<ApiPlace[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: ApiPlace[] } | ApiPlace[]
|
||||
return Array.isArray(body) ? body : (body.places ?? [])
|
||||
}
|
||||
|
||||
const findPlace = async (page: Page, name: string): Promise<ApiPlace | undefined> =>
|
||||
(await tripPlaces(page)).find(p => p.name === name)
|
||||
|
||||
async function patchPlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const place = await findPlace(page, name)
|
||||
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
|
||||
}
|
||||
|
||||
async function assignmentsOf(page: Page, dayIndex: number): Promise<ApiAssignment[]> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
|
||||
const body = (await res.json()) as { assignments?: ApiAssignment[] }
|
||||
return body.assignments ?? []
|
||||
}
|
||||
|
||||
async function assignmentFor(page: Page, dayIndex: number, name: string): Promise<ApiAssignment | undefined> {
|
||||
const place = await findPlace(page, name)
|
||||
if (!place) return undefined
|
||||
return (await assignmentsOf(page, dayIndex)).find(a => a.place_id === place.id)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* Take a place out of every collection it sits in.
|
||||
*
|
||||
* Run before the guide as well as after it: the Collections screen's own guides
|
||||
* run earlier in the suite and leave places saved, and a place that is already
|
||||
* in a list offers Saved where this guide's first step looks for Save to
|
||||
* Collection.
|
||||
*/
|
||||
async function unsaveEverywhere(page: Page, name: string): Promise<void> {
|
||||
const place = await findPlace(page, name)
|
||||
if (!place) return
|
||||
const query = new URLSearchParams({ name: place.name, lat: String(place.lat ?? ''), lng: String(place.lng ?? '') })
|
||||
const res = await page.request.get(`/api/addons/collections/membership?${query.toString()}`)
|
||||
if (!res.ok()) return
|
||||
const body = (await res.json()) as { lists?: { place_id: number }[] }
|
||||
for (const entry of body.lists ?? []) {
|
||||
await page.request.delete(`/api/addons/collections/places/${entry.place_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'read-place': {
|
||||
guide: guide('read-place'),
|
||||
// Day 1 explicitly: closing a day's detail panel clears the open day, and
|
||||
// the card is a different card without one.
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'Senso-ji Temple'),
|
||||
act: p => openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ }),
|
||||
},
|
||||
only(head),
|
||||
only(ratingRow),
|
||||
only(p => card(p).locator('.collab-note-md').first()),
|
||||
only(participants),
|
||||
{
|
||||
// Unfolded before the picture: the step is about the week, and a ring
|
||||
// around the collapsed row would show the reader one line of it.
|
||||
prepare: async p => {
|
||||
await hoursRow(p).click()
|
||||
await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible()
|
||||
await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: hoursBox,
|
||||
},
|
||||
only(footer),
|
||||
],
|
||||
},
|
||||
'rate-place': {
|
||||
guide: guide('rate-place'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openPlace(p, RATED_PLACE)
|
||||
},
|
||||
steps: [
|
||||
only(ratingRow),
|
||||
{
|
||||
target: p => card(p).getByRole('radio', { name: '4', exact: true }),
|
||||
act: async p => {
|
||||
await card(p).getByRole('radio', { name: '4', exact: true }).click()
|
||||
await expect(card(p).getByRole('radio', { name: '4', exact: true }))
|
||||
.toHaveAttribute('aria-checked', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// captureGuide hovers the target before it shoots, which is the gesture
|
||||
// the step describes; the tooltip is a portal, so it is asserted here.
|
||||
target: ratingRow,
|
||||
act: async p => { await expect(p.getByRole('tooltip')).toBeVisible() },
|
||||
},
|
||||
only(p => row(p, RATED_PLACE)),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const place = await findPlace(p, RATED_PLACE)
|
||||
// Only the admin's vote: the two members' votes are the fixture's.
|
||||
if (place) await p.request.delete(`/api/trips/${tripId}/places/${place.id}/rating`)
|
||||
},
|
||||
},
|
||||
'place-image': {
|
||||
guide: guide('place-image'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => row(p, 'Arashiyama Bamboo Grove'),
|
||||
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
|
||||
},
|
||||
{
|
||||
target: 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 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(head),
|
||||
only(p => row(p, 'Arashiyama Bamboo Grove')),
|
||||
],
|
||||
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 }),
|
||||
steps: [
|
||||
{
|
||||
target: p => dayHeader(p, 6),
|
||||
act: p => selectDay(p, 6),
|
||||
},
|
||||
{
|
||||
target: p => row(p, SPARE),
|
||||
act: async p => {
|
||||
await openPlace(p, SPARE)
|
||||
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Add to Day' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Add to Day' }).click()
|
||||
await expect(footer(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The gesture only: dragging it here would leave the day reordered for the
|
||||
// last step's picture, and the arrow already says what to do.
|
||||
{ target: p => stop(p, SPARE), dropTo: p => stop(p, 'Arashiyama Bamboo Grove') },
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Remove from Day' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Remove from Day' }).click()
|
||||
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const assignment = await assignmentFor(p, 5, SPARE)
|
||||
if (assignment) await p.request.delete(`/api/trips/${tripId}/days/${dayIds[5]}/assignments/${assignment.id}`)
|
||||
},
|
||||
},
|
||||
'place-participants': {
|
||||
guide: guide('place-participants'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets'),
|
||||
act: async p => {
|
||||
await openStop(p, 'teamLab Planets')
|
||||
await expect(participants(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => member(p, 'jonas'),
|
||||
act: async p => {
|
||||
await member(p, 'jonas').click()
|
||||
await expect(member(p, 'jonas')).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await addMember(p).click()
|
||||
await expect(member(p, 'jonas')).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: participants,
|
||||
},
|
||||
{
|
||||
target: p => member(p, 'jonas'),
|
||||
act: async p => {
|
||||
await member(p, 'jonas').click()
|
||||
await expect(addMember(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const assignment = await assignmentFor(p, 0, 'teamLab Planets')
|
||||
// An empty list is what "everybody is going" is stored as.
|
||||
if (assignment) {
|
||||
await p.request.put(`/api/trips/${tripId}/assignments/${assignment.id}/participants`, { data: { user_ids: [] } })
|
||||
}
|
||||
},
|
||||
},
|
||||
'place-booking': {
|
||||
guide: guide('place-booking'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 6)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'Nishiki Market'),
|
||||
act: async p => {
|
||||
await openStop(p, 'Nishiki Market')
|
||||
await expect(card(p).getByText(BOOKING)).toBeVisible()
|
||||
},
|
||||
},
|
||||
// The fields rather than the whole strip: step 3 rings the strip, and two
|
||||
// steps pointing at one element give the reader the same picture twice.
|
||||
only(bookingFields),
|
||||
{
|
||||
target: bookingStrip,
|
||||
act: async p => {
|
||||
await bookingStrip(p).click()
|
||||
await expect(bookingDetail(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingDetail(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'),
|
||||
act: closeModal,
|
||||
},
|
||||
{
|
||||
// The + is rendered only while its own stop is hovered, so the row is
|
||||
// put in view and hovered before the target is looked up, and hovered
|
||||
// again for the picture instead of the button itself.
|
||||
prepare: async p => {
|
||||
await stop(p, 'Nishiki Market').scrollIntoViewIfNeeded()
|
||||
await stop(p, 'Nishiki Market').hover()
|
||||
await expect(stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' })).toBeVisible()
|
||||
},
|
||||
target: p => stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }),
|
||||
hover: p => stop(p, 'Nishiki Market').hover(),
|
||||
act: async p => {
|
||||
await stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The new booking is never saved, so there is nothing to delete.
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'place-files': {
|
||||
guide: guide('place-files'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openPlace(p, 'teamLab Planets')
|
||||
},
|
||||
steps: [
|
||||
only(filesToggle),
|
||||
{
|
||||
target: p => card(p).getByText('Upload', { exact: true }),
|
||||
act: async p => {
|
||||
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
|
||||
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The list opens itself once the upload lands.
|
||||
only(p => card(p).getByRole('button', { name: '1 file', exact: true })),
|
||||
// No click: openFile hands the browser a download.
|
||||
only(p => card(p).getByRole('button', { name: UPLOADED })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/files`)
|
||||
const body = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of (body.files ?? []).filter(f => f.original_name === UPLOADED)) {
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
},
|
||||
},
|
||||
'place-navigation': {
|
||||
guide: guide('place-navigation'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openWithHours(p, q => openPlace(q, PLACE_WEBSITE.place), { place: PLACE_WEBSITE.place })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Navigation' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Navigation' }).click()
|
||||
await expect(p.getByRole('menu')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('menu')),
|
||||
{
|
||||
// Clicking one opens an external tab, so the menu is closed instead.
|
||||
target: p => p.getByRole('menuitem', { name: 'Google Maps' }),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(p.getByRole('menu')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
only(p => footer(p).getByRole('button', { name: 'Open Website' })),
|
||||
],
|
||||
},
|
||||
'place-to-collection': {
|
||||
guide: guide('place-to-collection'),
|
||||
start: async p => {
|
||||
await unsaveEverywhere(p, 'Senso-ji Temple')
|
||||
await openTrip(p, { day: null })
|
||||
await openWithHours(p, q => openPlace(q, 'Senso-ji Temple'))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Save to Collection' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Save to Collection' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Save to list' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(dialog),
|
||||
{
|
||||
// Anchored, not exact: the Collections guides leave a list behind whose
|
||||
// name opens with this one's, and once the place is saved the row's
|
||||
// name grows the status chip (Idea, Planned, Visited).
|
||||
target: p => modal(p).getByRole('button', { name: LIST_ROW }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: LIST_ROW }).click()
|
||||
await expect(modal(p).getByRole('button', { name: LIST_ROW })).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Close' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Close' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(footer(p).getByRole('button', { name: 'Saved' })).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unsaveEverywhere(p, 'Senso-ji Temple'),
|
||||
},
|
||||
'place-track': {
|
||||
guide: guide('place-track'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => row(p, TRACK),
|
||||
act: async p => {
|
||||
await openPlace(p, TRACK)
|
||||
await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible()
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Track Stats')),
|
||||
{
|
||||
target: p => card(p).getByRole('button', { name: 'Track color' }),
|
||||
act: async p => {
|
||||
await card(p).getByRole('button', { name: 'Track color' }).click()
|
||||
await expect(swatch(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: swatch,
|
||||
act: async p => {
|
||||
await swatch(p).click()
|
||||
await expect(swatch(p)).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// No click: it would undo the result picture. The reset happens in cleanup.
|
||||
only(p => card(p).getByRole('button', { name: 'Automatic color' })),
|
||||
],
|
||||
cleanup: p => patchPlace(p, TRACK, { route_color: TRACK_IMPORT_COLOR }),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
|
||||
await ensureDaysFixtures(request)
|
||||
await ensurePlaceFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered place guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlaceContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-place', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripPlaceContext.id, async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
await openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ })
|
||||
})
|
||||
})
|
||||
|
||||
for (const id of tripPlaceContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, dragLoop, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, gpxFixture } from './fixtures'
|
||||
import { openTrip, modal, dialog, confirmDialog } from './trip-shared'
|
||||
import { disconnectDawarich } from './external'
|
||||
import { ensureDawarichStays, undoDawarichStays, setTours } from './fixtures-trip-places'
|
||||
import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the places column of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripPlaces.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Tours add-on on, as on every install: the column has the Places and
|
||||
* Tours switch, no Import file and no Tracks filter. The file import is
|
||||
* pictured with Tours off, and the Dawarich stays with a connection, each for
|
||||
* its own guide only; what a guide creates or switches it takes back again in
|
||||
* `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripPlacesGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
|
||||
/** What create-place looks up. See the note in its second step for why this one. */
|
||||
const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ }
|
||||
|
||||
/** The search result create-place picks. */
|
||||
const suggestion = (page: Page) =>
|
||||
modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first()
|
||||
/** A row of the places column by the place's name (the rows are options of a list). */
|
||||
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
/** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */
|
||||
const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last()
|
||||
/** The add button: "Add Place/Activity" with no day open, "New place" with one. */
|
||||
const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ })
|
||||
/**
|
||||
* The head band of the column: the add row, the search with the select switch,
|
||||
* and the row of filters. Found from its import button, the one control only
|
||||
* this band has, so the plain "Search" of the field is read inside it.
|
||||
*/
|
||||
const head = (page: Page) =>
|
||||
page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]')
|
||||
/** The 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: 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 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) => 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=..')
|
||||
/** FileImportModal, recognised by its hidden file input. */
|
||||
const importFileDialog = (page: Page) =>
|
||||
page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') })
|
||||
/** ListImportModal, recognised by the link box. */
|
||||
const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) })
|
||||
/** The drop box of the file dialog: the dashed button that opens the file picker. */
|
||||
const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first()
|
||||
/** The column itself: the header block around the search box. */
|
||||
const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]')
|
||||
|
||||
async function createSpare(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
// 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`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
|
||||
const sortList = (page: Page) => 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' }
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'sort-places': {
|
||||
guide: guide('sort-places'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await sortTrigger(p).click()
|
||||
await expect(sortList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: sortList,
|
||||
},
|
||||
{
|
||||
target: p => sortList(p).getByRole('button', { name: 'Highest rated' }),
|
||||
act: async p => {
|
||||
await sortList(p).getByRole('button', { name: 'Highest rated' }).click()
|
||||
await expect(sortList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await sortTrigger(p).click()
|
||||
await sortList(p).getByRole('button', { name: 'Recently added' }).click()
|
||||
},
|
||||
},
|
||||
'place-by-hand': {
|
||||
guide: guide('place-by-hand'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await addButton(p).click()
|
||||
const box = modal(p).getByPlaceholder('Search places...')
|
||||
await expect(box).toBeVisible()
|
||||
await typeInto(p, box, HANDMADE.name)
|
||||
await box.press('Enter')
|
||||
await expect(modal(p).getByText(/^Nothing found for/)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Add by hand' }).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add by hand' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add by hand' }).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(HANDMADE.name)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HANDMADE.phone)
|
||||
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HANDMADE.email)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add opening hours' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Remove opening hours' })).toBeVisible()
|
||||
await modal(p).getByRole('button', { name: 'Remove opening hours' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[.//input][1]'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, HANDMADE.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, HANDMADE.name),
|
||||
},
|
||||
'create-place': {
|
||||
guide: guide('create-place'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
// A running trip always has a day open, so the button reads New place.
|
||||
target: addButton,
|
||||
act: async p => {
|
||||
await addButton(p).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The results are pictured before the pick: once one is taken the
|
||||
// search box empties again, and a ring round an empty box shows the
|
||||
// reader nothing of what the step says.
|
||||
prepare: async p => {
|
||||
const box = modal(p).getByPlaceholder('Search places...')
|
||||
// Osaka Castle on purpose: its entry in the index carries a picture,
|
||||
// opening hours and a description, so Place details shows what the
|
||||
// text says it shows. Most results have none of that and the column
|
||||
// would be an empty state in the picture.
|
||||
await typeInto(p, box, SEARCHED.query)
|
||||
await expect(suggestion(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).locator('.shadow-dropdown').first(),
|
||||
hover: p => suggestion(p).hover(),
|
||||
act: async p => {
|
||||
await suggestion(p).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 })
|
||||
// The details column answers after the form; wait for it or the
|
||||
// picture catches the spinner.
|
||||
await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The details column is an <aside> beside the form; both used to resolve to
|
||||
// the whole dialog, which gave two steps the same picture.
|
||||
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::aside[1]')),
|
||||
// The name and the category sit in the head band, above the form, so the
|
||||
// step about the fields shows the dialog rather than the form alone.
|
||||
only(p => dialog(p)),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, SEARCHED.query)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SEARCHED.query),
|
||||
},
|
||||
'place-to-open-day': {
|
||||
guide: guide('place-to-open-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await openTrip(p, { day: null })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => dayHeader(p, 1),
|
||||
act: async p => {
|
||||
await dayHeader(p, 1).click()
|
||||
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add to the open day' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add to the open day' }).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
|
||||
await beat(p, 400)
|
||||
await closeModal(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await row(p, SPARE.name).hover() },
|
||||
target: p => row(p, SPARE.name).getByRole('button', { name: '+ Day' }),
|
||||
act: async p => {
|
||||
await row(p, SPARE.name).getByRole('button', { name: '+ Day' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The other way round, and the one most readers try first: the row is
|
||||
// dragged onto a day card. The place is off the day again before the
|
||||
// drag so the picture shows it leaving the column rather than a stop
|
||||
// that is already there.
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(row(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, SPARE.name),
|
||||
dropTo: p => 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()
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'filter-places': {
|
||||
guide: guide('filter-places'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await resolveRegions(p)
|
||||
await openTrip(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await showTrigger(p).click()
|
||||
await expect(showList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: showList,
|
||||
// The lists close on a second click of their trigger.
|
||||
act: async p => { await showTrigger(p).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, searchBox(p), 'temple'); await settle(p) },
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => { await searchBox(p).fill(''); await settle(p) },
|
||||
},
|
||||
{
|
||||
// One category ticked, so the list shows the tick, the button its count
|
||||
// and the foot of the list its Clear filter.
|
||||
prepare: async p => {
|
||||
await categoryTrigger(p).click()
|
||||
await expect(categoryList(p)).toBeVisible()
|
||||
// No Category: the seed's places carry none, so it is the choice that
|
||||
// leaves rows in the list rather than an empty column.
|
||||
await categoryList(p).getByRole('button', { name: /No Category/ }).click()
|
||||
await expect(categoryList(p).getByRole('button', { name: 'Clear filter' })).toBeVisible()
|
||||
// The list scrolls inside itself; its foot is where the tick and Clear filter are.
|
||||
await categoryList(p).evaluate(el => { el.scrollTop = el.scrollHeight })
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: categoryList,
|
||||
act: async p => {
|
||||
await categoryList(p).getByRole('button', { name: 'Clear filter' }).click()
|
||||
await categoryTrigger(p).click()
|
||||
await expect(categoryList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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),
|
||||
},
|
||||
'edit-place': {
|
||||
guide: guide('edit-place'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, 'Shibuya Crossing').click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: menu,
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue('Shibuya Crossing')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(dialog),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
||||
act: closeModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
'delete-place': {
|
||||
guide: guide('delete-place'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await openTrip(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, SPARE.name).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Delete' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => menu(p).getByRole('button', { name: 'Delete' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(confirmDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: confirmDialog,
|
||||
act: async p => {
|
||||
await confirmDialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(row(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'select-places': {
|
||||
guide: guide('select-places'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Select', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(p.getByRole('button', { name: 'Select all' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Two rows next to each other, so one picture shows both ticks.
|
||||
prepare: async p => {
|
||||
await row(p, 'Shibuya Crossing').click()
|
||||
await row(p, 'Meiji Jingu').click()
|
||||
await expect(p.getByRole('status', { name: '2 selected' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => row(p, 'Meiji Jingu'),
|
||||
},
|
||||
only(selectionBar),
|
||||
{
|
||||
target: p => selectionBar(p).getByRole('button', { name: 'Done' }),
|
||||
act: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Select all' })).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'import-places-file': {
|
||||
guide: guide('import-places-file'),
|
||||
// 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 => {
|
||||
await p.getByRole('button', { name: 'Import Places' }).click()
|
||||
await expect(importMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => importMenu(p).getByRole('button', { name: 'Import file' }),
|
||||
act: async p => {
|
||||
await importMenu(p).getByRole('button', { name: 'Import file' }).click()
|
||||
await expect(importFileDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await importFileDialog(p).locator('input[type="file"]').setInputFiles(gpxFixture('arashiyama-loop'))
|
||||
await expect(importFileDialog(p).getByText('Tracks (with path geometry)')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: importFileDialog,
|
||||
},
|
||||
only(dropBox),
|
||||
{
|
||||
target: p => importFileDialog(p).getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await importFileDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importFileDialog(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(row(p, 'Arashiyama loop')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: 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'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Import Places' }).click()
|
||||
await expect(importMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
// 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: LIST_IMPORT }).click()
|
||||
await expect(listImportDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, listImportDialog(p).getByPlaceholder(/goo\.gl|naver/), 'https://maps.app.goo.gl/kyoto-favourites')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: listImportDialog,
|
||||
},
|
||||
only(p => listImportDialog(p).getByRole('button', { name: 'Import', exact: true })),
|
||||
],
|
||||
// The dialog has no Escape route: Cancel is the way out.
|
||||
cleanup: async p => {
|
||||
if (await listImportDialog(p).isVisible().catch(() => false)) {
|
||||
await listImportDialog(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(listImportDialog(p)).toHaveCount(0)
|
||||
}
|
||||
},
|
||||
},
|
||||
'dawarich-stays': {
|
||||
guide: guide('dawarich-stays'),
|
||||
// Connected for this guide only: while it is, the panel heads the column in
|
||||
// every picture, and the other guides of this screen do not show it.
|
||||
start: async p => {
|
||||
await ensureDawarichStays(p.request, seededTrip().tripId)
|
||||
await openTrip(p)
|
||||
await expect(dawarichHeader(p)).toBeVisible({ timeout: 30_000 })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: dawarichHeader,
|
||||
act: async p => {
|
||||
await dawarichHeader(p).click()
|
||||
await expect(stayAction(p, 'Add as a place')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The first stay's button; the frame shows its row and the day above it.
|
||||
target: p => stayAction(p, 'Add as a place'),
|
||||
act: async p => {
|
||||
await stayAction(p, 'Add as a place').click()
|
||||
await expect(modal(p).locator('#dawarich-accept-name')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dialog,
|
||||
act: async p => {
|
||||
const name = await modal(p).locator('#dawarich-accept-name').inputValue()
|
||||
await modal(p).getByRole('button', { name: 'Add place', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(row(p, name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => stayAction(p, 'Not a place I visited'),
|
||||
act: async p => {
|
||||
await stayAction(p, 'Not a place I visited').click()
|
||||
await expect(p.getByRole('button', { name: /^Show \d+ already dealt with$/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await undoDawarichStays(p.request, seededTrip().tripId)
|
||||
await disconnectDawarich(p.request)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, and a guide that fails never reaches its cleanup: Tours back
|
||||
// on, as on every install, and Dawarich off, so its panel on top of the places
|
||||
// list does not ride into the pictures of the files after this one.
|
||||
await setTours(request, true)
|
||||
await disconnectDawarich(request)
|
||||
})
|
||||
|
||||
test('every registered places guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlacesContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-places', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripPlacesContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripPlacesContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
// 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])
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,208 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { dismissReleaseNotice, settle, type StepAction } from './guide'
|
||||
import { seededTrip } from './fixtures'
|
||||
|
||||
/**
|
||||
* What every trip screen's script needs: the way into the seeded trip, the
|
||||
* day selection and the dialogs. Not a `.guide.ts`, so importing it registers
|
||||
* no tests twice.
|
||||
*/
|
||||
|
||||
export const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
export const dialog = (page: Page) => modal(page).locator('> [role="presentation"], > div').first()
|
||||
/**
|
||||
* ConfirmDialog is its own portal and carries `trek-backdrop-enter` without the
|
||||
* `trek-modal-backdrop` of `Modal`, so the yes/no questions need their own
|
||||
* locator: `modal` would never see them.
|
||||
*/
|
||||
export const confirmBackdrop = (page: Page) => page.locator('.trek-backdrop-enter:not(.trek-modal-backdrop)').last()
|
||||
export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.trek-modal-enter').last()
|
||||
/**
|
||||
* The import dialogs of the places column are portals of their own with no
|
||||
* shared backdrop class at all, so they are found by what stands inside them.
|
||||
* Both the overlay and the card carry `role="presentation"`, and the card comes
|
||||
* second in document order, which is the one worth a picture.
|
||||
*/
|
||||
export const portalDialog = (page: Page, contains: Locator): Locator =>
|
||||
page.locator('div[role="presentation"]').filter({ has: contains }).last()
|
||||
|
||||
/**
|
||||
* The trip page has loaded: its header carries the Trip settings pill, which
|
||||
* every tab shows. (Share moved into that pill's dialog in v5, so it is no
|
||||
* longer a button of its own up there.)
|
||||
*/
|
||||
export async function tripReady(page: Page): Promise<void> {
|
||||
await expect(page.getByRole('button', { name: 'Trip settings', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const { tab, day = 1 } = opts
|
||||
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await tripReady(page)
|
||||
await settle(page)
|
||||
await untilTranslated(page)
|
||||
if (!tab && day) {
|
||||
// The trip is running, so the plan opens on today, an empty day at the
|
||||
// bottom. Day 1 has the places; its details panel would cover the map.
|
||||
await selectDay(page, day)
|
||||
await closeDayDetails(page)
|
||||
}
|
||||
// The map tiles land after the page says it is idle.
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
/**
|
||||
* A text that is still its key: `dayplan.dayN` where "Day 1" belongs. The
|
||||
* locale bundles come from `shared/dist`, and while a watcher rebuilds it a
|
||||
* page can load with a part of them missing. Reloading after the rebuild has
|
||||
* landed brings the strings back; a page that stays untranslated fails here
|
||||
* rather than in a picture.
|
||||
*/
|
||||
const rawKey = (page: Page) => page.getByText(/^(common|dayplan|planner|places|reservations|trip|day|budget|files|collab|packing|todo)\.[A-Za-z0-9.]+$/)
|
||||
|
||||
async function untilTranslated(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
|
||||
await page.waitForTimeout(4000)
|
||||
await page.reload()
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await tripReady(page)
|
||||
await settle(page)
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
|
||||
}
|
||||
|
||||
/** Click the day's header: selects it and opens its details panel. */
|
||||
export async function selectDay(page: Page, n: number): Promise<void> {
|
||||
const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
// The header carries buttons of its own: the booked night in the middle, the
|
||||
// transit, note and fold actions on the right. A click on the centre lands on
|
||||
// the booked night as soon as a day has one, which selects nothing, so the
|
||||
// click goes to the day's number at the left edge.
|
||||
await header.click({ position: { x: 22, y: 20 } })
|
||||
// The details panel is the selection: without it the day's route tools, the
|
||||
// + on a place row and To day are all absent, and a guide fails far from here.
|
||||
await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** The day details panel: the one fixed, floating card over the map. */
|
||||
export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first()
|
||||
|
||||
/**
|
||||
* The details panel's own Close, in its head band. Found inside the panel: the
|
||||
* last X of the page is not always this one once the day carries bookings.
|
||||
*/
|
||||
export async function closeDayDetails(page: Page): Promise<void> {
|
||||
const close = dayDetails(page).getByRole('button', { name: 'Close', exact: true })
|
||||
if (await close.isVisible().catch(() => false)) {
|
||||
await close.click()
|
||||
await settle(page)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip with day `n` selected and its details panel standing. The panel
|
||||
* IS the selection — closing it deselects the day — and the day's route tools
|
||||
* and the "+" at the end of a place row render for a selected day only, so a
|
||||
* guide about either of them starts here rather than at `openTrip`.
|
||||
*/
|
||||
export async function openTripOnDay(page: Page, n = 1): Promise<void> {
|
||||
await openTrip(page, { day: null })
|
||||
await selectDay(page, n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a `ContextMenu`. It closes on a document click and ignores Escape,
|
||||
* and a real click would land on the map or on the row underneath.
|
||||
*/
|
||||
export async function closeMenu(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.body.click())
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── Import from file ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import from file, on the Bookings and the Transports tab alike. The dialog
|
||||
* is another bare portal, told apart by its heading; the parse it starts is
|
||||
* reported by the background widget, a fixed portal in the bottom-right
|
||||
* corner (the one at z-index 50000) with a card per run.
|
||||
*/
|
||||
export const importDialog = (page: Page) =>
|
||||
portalDialog(page, page.getByText('Import booking confirmations', { exact: true }))
|
||||
export const importWidget = (page: Page) => page.locator('body > div[style*="z-index: 50000"]')
|
||||
/** The widget's card for the run that parses `file`; the card is labelled with the file names. */
|
||||
export const importTask = (page: Page, file: string) =>
|
||||
importWidget(page).locator('.bg-surface-card').filter({ hasText: file }).last()
|
||||
|
||||
/**
|
||||
* The first three steps of an import guide, the same on either tab: the
|
||||
* toolbar button, the drop box with the file named on it, and Import.
|
||||
*/
|
||||
export function importSteps(fixture: () => string, file: string): StepAction[] {
|
||||
return [
|
||||
{
|
||||
// An icon in the toolbar, named like the dialog it opens.
|
||||
target: p => p.getByRole('button', { name: 'Import booking confirmations', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Import booking confirmations', exact: true }).click()
|
||||
await expect(importDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Set the hidden input directly: the box opens the file picker of the
|
||||
// operating system, which Playwright cannot photograph.
|
||||
await importDialog(p).locator('input[type="file"]').setInputFiles(fixture())
|
||||
await expect(importDialog(p).getByText(file)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => importDialog(p).getByText(file).locator('xpath=ancestor::button[1]'),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the widget away after an import guide. The review takes the card with
|
||||
* it when it starts, so one still standing means the run stopped before
|
||||
* that, and it would sit in the corner of every later guide's pictures. Close
|
||||
* is only offered once the parse has stopped, and the wait is bounded so a
|
||||
* card nobody can close does not fail the guide from its cleanup.
|
||||
*/
|
||||
export async function dismissImportTask(page: Page, file: string): Promise<void> {
|
||||
const task = importTask(page, file)
|
||||
if (!(await task.isVisible().catch(() => false))) return
|
||||
const close = task.getByRole('button', { name: 'Close' })
|
||||
await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {})
|
||||
if (await close.isVisible().catch(() => false)) await close.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a document a guide uploaded out of the trip for good. DELETE alone is
|
||||
* the trash, and the Files guides photograph the trash, so the permanent
|
||||
* delete follows.
|
||||
*/
|
||||
export async function deleteTripFiles(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files`)
|
||||
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of files.filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { tripTourBikeContext, tripTourBikeGuides } from '../../src/help/contexts/tripTourBike';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
|
||||
import { ensureToursFixtures, openTourTrip, TOUR_NAMES, TOUR_PLACES, TOUR_TRIP } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* Actions for bike tours on the Tours tab, keyed by the ids in
|
||||
* `src/help/contexts/tripTourBike.ts`. They run on "Summer in the Dolomites"
|
||||
* (`ensureToursFixtures`): the road bike loop over the four passes and the
|
||||
* MTB loop over Monte Pana, both saved tours that are only opened here and
|
||||
* left as they were. The one guide that saves a tour of its own deletes it
|
||||
* again in `cleanup`.
|
||||
*
|
||||
* Every route in the editor is planned in the browser against the public
|
||||
* Valhalla, and the Surface and ways card asks the same server how the route
|
||||
* runs, so these pictures need the network; the MTB trail grades come from
|
||||
* the server's trail-grade endpoint (TREK Places, else Overpass).
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTourBikeGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
/** The tour the profile guide plans and saves, and deletes again afterwards. */
|
||||
const NEW_TOUR = 'Selva to Bolzano by bike';
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The left rail: the neutral head with Plan new tour, or the editor. Both are labelled "Plan a tour". */
|
||||
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
|
||||
/** The right rail, "Tours of this trip". */
|
||||
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
|
||||
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name });
|
||||
const profileField = (page: Page) => editorRail(page).getByRole('group', { name: 'Profile' });
|
||||
const waysCard = (page: Page) => page.getByRole('region', { name: 'Surface and ways' });
|
||||
const summaryCard = (page: Page) => page.getByTestId('tour-planner-overview');
|
||||
/** A section of the ways card by its eyebrow ("Surface", "Kind of way", "Trail grade (mtb:scale)"). */
|
||||
const waysSection = (page: Page, label: string) =>
|
||||
waysCard(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[1]');
|
||||
|
||||
/** The first row whose test id starts with one of the prefixes, in the order given. */
|
||||
async function firstRow(page: Page, prefixes: string[]): Promise<Locator> {
|
||||
for (const prefix of prefixes) {
|
||||
const rows = waysCard(page).locator(`[data-testid^="route-ways-row-${prefix}"]`);
|
||||
if ((await rows.count()) > 0) return rows.first();
|
||||
}
|
||||
throw new Error(`no row in the Surface and ways card for ${prefixes.join(', ')}`);
|
||||
}
|
||||
|
||||
/** An unpaved surface row: gravel or natural ground read best, compacted will do. */
|
||||
const UNPAVED_ROWS = ['surface-gravel', 'surface-ground', 'surface-compacted', 'surface-rough'];
|
||||
/** A kind of way that is not the road: the forest track is the one an MTB loop has. */
|
||||
const WAY_ROWS = ['way-track', 'way-path', 'way-cyclePath', 'way-quiet', 'way-'];
|
||||
/** A graded trail before an ungraded one. */
|
||||
const GRADE_ROWS = ['trail-s', 'trail-'];
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function openToursTab(page: Page): Promise<void> {
|
||||
await openTourTrip(page, { tab: 'tour-planner' });
|
||||
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tourRow(page, TOUR_NAMES.roadBike)).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the route in the editor is planned and its heights are in: the
|
||||
* status badges beside the rail's title are gone once nothing is happening.
|
||||
*/
|
||||
async function waitForRoute(page: Page): Promise<void> {
|
||||
await expect(summaryCard(page)).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByTestId('tour-planner-status')).toHaveCount(0, { timeout: 120_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The Surface and ways card once it has read the ways (and, on an MTB tour, the grades). */
|
||||
async function waitForWays(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('bike-ways-card')).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('Reading the ways along the route…')).toHaveCount(0, { timeout: 120_000 });
|
||||
await expect(page.getByText('Reading the trail grades…')).toHaveCount(0, { timeout: 120_000 });
|
||||
await expect(waysSection(page, 'Surface')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function openBikeTour(page: Page, name: string): Promise<void> {
|
||||
await tourRow(page, name).click();
|
||||
await expect(editorRail(page).getByRole('heading', { name: 'Edit tour' })).toBeVisible({ timeout: 30_000 });
|
||||
await waitForRoute(page);
|
||||
await waitForWays(page);
|
||||
}
|
||||
|
||||
async function openToursTabWith(page: Page, name: string): Promise<void> {
|
||||
await openToursTab(page);
|
||||
await openBikeTour(page, name);
|
||||
}
|
||||
|
||||
/** Delete the tour the profile guide saved, wherever the run stopped. */
|
||||
async function deleteNewTour(page: Page): Promise<void> {
|
||||
const trips =
|
||||
((await (await page.request.get('/api/trips')).json()) as { trips?: { id: number; title: string }[] }).trips ?? [];
|
||||
const trip = trips.find((t) => t.title === TOUR_TRIP);
|
||||
if (!trip) return;
|
||||
const { tours = [] } = (await (await page.request.get(`/api/trips/${trip.id}/tours`)).json()) as {
|
||||
tours?: { place_id: number; name: string }[];
|
||||
};
|
||||
for (const tour of tours.filter((t) => t.name === NEW_TOUR)) {
|
||||
await page.request.delete(`/api/trips/${trip.id}/places/${tour.place_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The scripts ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'tour-bike-profile': {
|
||||
guide: guide('tour-bike-profile'),
|
||||
start: async (p) => {
|
||||
await deleteNewTour(p);
|
||||
await openToursTab(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }),
|
||||
act: async (p) => {
|
||||
await editorRail(p).getByRole('button', { name: 'Plan new tour', exact: true }).click();
|
||||
await expect(editorRail(p).getByRole('heading', { name: 'New tour' })).toBeVisible({ timeout: 15_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-type-select'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-type-select').click();
|
||||
await p.getByTestId('tour-type-option-bike').click();
|
||||
await expect(profileField(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: profileField,
|
||||
act: async (p) => {
|
||||
await profileField(p).getByRole('button', { name: 'Touring', exact: true }).click();
|
||||
await expect(p.getByText('Prefers cycle paths and quiet roads, paved where it can.')).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-planner-add-place'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-planner-add-place').click();
|
||||
for (const name of [TOUR_PLACES.selvaHotel, TOUR_PLACES.bolzanoHotel]) {
|
||||
await p.getByTestId('trip-place-candidate').filter({ hasText: name }).first().click();
|
||||
await settle(p);
|
||||
}
|
||||
await p.getByTestId('tour-planner-add-place').click();
|
||||
await waitForRoute(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: summaryCard,
|
||||
hover: async (p) => {
|
||||
await summaryCard(p).getByRole('button', { name: 'How the riding time is worked out' }).hover();
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await p.locator('#tour-planner-name').fill(NEW_TOUR);
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }),
|
||||
act: async (p) => {
|
||||
await editorRail(p).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(tourRow(p, NEW_TOUR)).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: deleteNewTour,
|
||||
},
|
||||
'tour-bike-surfaces': {
|
||||
guide: guide('tour-bike-surfaces'),
|
||||
start: openToursTab,
|
||||
steps: [
|
||||
{
|
||||
target: (p) => tourRow(p, TOUR_NAMES.mtb),
|
||||
act: (p) => openBikeTour(p, TOUR_NAMES.mtb),
|
||||
},
|
||||
{
|
||||
target: (p) =>
|
||||
waysCard(p)
|
||||
.getByText(/unpaved$/)
|
||||
.first(),
|
||||
},
|
||||
{
|
||||
target: (p) => waysSection(p, 'Surface'),
|
||||
act: async (p) => {
|
||||
await (await firstRow(p, UNPAVED_ROWS)).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => waysSection(p, 'Kind of way'),
|
||||
act: async (p) => {
|
||||
await (await firstRow(p, WAY_ROWS)).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'tour-bike-mtb-grades': {
|
||||
guide: guide('tour-bike-mtb-grades'),
|
||||
start: (p) => openToursTabWith(p, TOUR_NAMES.mtb),
|
||||
steps: [
|
||||
{
|
||||
target: profileField,
|
||||
hover: async (p) => {
|
||||
await profileField(p).getByRole('button', { name: 'MTB', exact: true }).hover();
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => waysSection(p, 'Trail grade (mtb:scale)'),
|
||||
},
|
||||
{
|
||||
target: (p) => waysSection(p, 'Trail grade (mtb:scale)').getByRole('list'),
|
||||
act: async (p) => {
|
||||
await (await firstRow(p, GRADE_ROWS)).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureToursFixtures(request);
|
||||
});
|
||||
|
||||
test('every registered bike tour guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourBikeContext.guides].sort());
|
||||
});
|
||||
|
||||
test('hero: trip-tour-bike', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, tripTourBikeContext.id, (p) => openToursTabWith(p, TOUR_NAMES.roadBike));
|
||||
});
|
||||
|
||||
for (const id of tripTourBikeContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
test.setTimeout(8 * 60_000);
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { tripTourDetailContext, tripTourDetailGuides } from '../../src/help/contexts/tripTourDetail';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import {
|
||||
deleteToursNamed,
|
||||
deleteTripFilesNamed,
|
||||
ensureTourDetailFixtures,
|
||||
FERRATA_PACKING,
|
||||
HUT_BOOKING_FILE,
|
||||
hutBookingFixture,
|
||||
removeInariRecording,
|
||||
tourIdOf,
|
||||
unkeepTour,
|
||||
unlinkInariRecording,
|
||||
unlinkPacking,
|
||||
} from './fixtures-trip-tour-detail';
|
||||
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
|
||||
import {
|
||||
ensureTourRecording,
|
||||
ensureToursFixtures,
|
||||
openTourTrip,
|
||||
TOUR_COLLECTION,
|
||||
TOUR_NAMES,
|
||||
type ToursFixtures,
|
||||
} from './tour-fixtures';
|
||||
import { openTrip } from './trip-shared';
|
||||
|
||||
/**
|
||||
* Actions for the card a tour opens over the map on the Plan tab, keyed by the
|
||||
* ids in `src/help/contexts/tripTourDetail.ts`. They run on "Summer in the
|
||||
* Dolomites" from `tour-fixtures.ts`, and the two about actual tracks on the
|
||||
* Fushimi Inari hike of the seeded "Autumn in Japan" with its recording
|
||||
* (`ensureTourRecording`). What a guide changes it changes back in `cleanup`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTourDetailGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
let fx: ToursFixtures | null = null;
|
||||
const fixtures = (): ToursFixtures => {
|
||||
if (!fx) throw new Error('ensureToursFixtures did not run');
|
||||
return fx;
|
||||
};
|
||||
|
||||
/** The new name the rename guide types. */
|
||||
const RENAMED = 'Sella Ronda, four passes';
|
||||
/** The colour it picks: TRACK_COLORS[1], the orange. */
|
||||
const PICKED_COLOR = '#ea580c';
|
||||
const COPY_NAME = `${TOUR_NAMES.roadBike} (copy)`;
|
||||
|
||||
/**
|
||||
* The tour card: the box whose head carries the Track color dot. Only the
|
||||
* tour card has that button in a header, the place card names its own
|
||||
* "Track color".
|
||||
*/
|
||||
const card = (page: Page) => page.locator('header:has(button[aria-label="Track color"])').locator('xpath=..');
|
||||
const head = (page: Page) => card(page).locator('> header');
|
||||
const footer = (page: Page) => card(page).locator('> footer');
|
||||
/** A block of the card by its heading: every block is a <section> that opens with it. */
|
||||
const section = (page: Page, heading: string) =>
|
||||
card(page).getByText(heading, { exact: true }).first().locator('xpath=ancestor::section[1]');
|
||||
/** The Places | Tours switch at the top of the right column. */
|
||||
const toursTab = (page: Page) => page.getByRole('tablist', { name: 'Add view' }).getByRole('tab', { name: 'Tours' });
|
||||
/** A tour's row in the Tours list. */
|
||||
const row = (page: Page, name: string) => page.getByRole('option', { name, exact: true }).first();
|
||||
/** The block of distance, times, heights and the profile: the first fact list with the altitudes, and its frame. */
|
||||
const measures = (page: Page) =>
|
||||
card(page).locator('dl').filter({ hasText: 'Minimum altitude' }).first().locator('xpath=..');
|
||||
/** "Files" while the tour has none, "<n> file(s)" once it has. */
|
||||
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files?)$/ });
|
||||
const compare = (page: Page) => card(page).getByRole('region', { name: 'Plan and actual' });
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target });
|
||||
|
||||
/** Switch the right column to Tours and open a tour's card from its row. */
|
||||
async function openTour(page: Page, name: string): Promise<void> {
|
||||
if ((await toursTab(page).getAttribute('aria-selected')) !== 'true') await toursTab(page).click();
|
||||
await expect(row(page, name)).toBeVisible({ timeout: 30_000 });
|
||||
await row(page, name).click();
|
||||
await expect(head(page).getByRole('heading', { name, exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function putPlace(page: Page, tripId: number, placeId: number, data: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.put(`/api/trips/${tripId}/places/${placeId}`, { data });
|
||||
if (!res.ok()) throw new Error(`restore place ${placeId}: ${res.status()} ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'tour-detail-read': {
|
||||
guide: guide('tour-detail-read'),
|
||||
// Day 4 open, the first stage of the hut trek, so the card knows the stop it was opened on.
|
||||
start: async (p) => {
|
||||
await openTourTrip(p, { day: 4 });
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await toursTab(p).click();
|
||||
await expect(row(p, TOUR_NAMES.hutToHut)).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => row(p, TOUR_NAMES.hutToHut),
|
||||
act: async (p) => {
|
||||
await row(p, TOUR_NAMES.hutToHut).click();
|
||||
await expect(head(p).getByRole('heading', { name: TOUR_NAMES.hutToHut, exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only(head),
|
||||
// The pill with the clock; hovering it shows the parts of the time.
|
||||
only((p) =>
|
||||
head(p)
|
||||
.locator('span[aria-label]')
|
||||
.filter({ has: p.locator('svg.lucide-clock') })
|
||||
.first()
|
||||
),
|
||||
only((p) => section(p, 'Trip days')),
|
||||
only(measures),
|
||||
only((p) => section(p, 'Origin')),
|
||||
// No click: the open card is what the guide leaves behind.
|
||||
only((p) => head(p).getByRole('button', { name: 'Close', exact: true })),
|
||||
],
|
||||
},
|
||||
|
||||
'tour-rename-colour': {
|
||||
guide: guide('tour-rename-colour'),
|
||||
start: async (p) => {
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.roadBike);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => head(p).getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async (p) => {
|
||||
await head(p).getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await expect(head(p).getByRole('textbox', { name: 'Name' })).toBeVisible();
|
||||
await typeInto(p, head(p).getByRole('textbox', { name: 'Name' }), RENAMED);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => head(p).getByRole('textbox', { name: 'Name' }).locator('xpath=..'),
|
||||
act: async (p) => {
|
||||
await head(p).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(head(p).getByRole('heading', { name: RENAMED, exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => head(p).getByRole('button', { name: 'Track color' }),
|
||||
act: async (p) => {
|
||||
await head(p).getByRole('button', { name: 'Track color' }).click();
|
||||
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => card(p).getByRole('button', { name: 'Automatic color' }).locator('xpath=ancestor::section[1]'),
|
||||
act: async (p) => {
|
||||
await card(p).getByRole('button', { name: PICKED_COLOR, exact: true }).click();
|
||||
await expect(card(p).getByRole('button', { name: PICKED_COLOR, exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
const { tripId, tours } = fixtures();
|
||||
const id = tours.roadBike ?? (await tourIdOf(p.request, tripId, RENAMED));
|
||||
await putPlace(p, tripId, id, { name: TOUR_NAMES.roadBike, route_color: null });
|
||||
},
|
||||
},
|
||||
|
||||
'tour-take-along': {
|
||||
guide: guide('tour-take-along'),
|
||||
// The city walk: the one tour with a web page of its own.
|
||||
start: async (p) => {
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.cityWalk);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => footer(p).getByRole('button', { name: 'Navigation' }),
|
||||
act: async (p) => {
|
||||
await footer(p).getByRole('button', { name: 'Navigation' }).click();
|
||||
await expect(p.getByRole('menu')).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Clicking it opens an external tab, so the step only shows it.
|
||||
prepare: async (p) => {
|
||||
await p.keyboard.press('Escape');
|
||||
await expect(p.getByRole('menu')).toHaveCount(0);
|
||||
// Escape may have closed the card as well; bring it back.
|
||||
if ((await card(p).count()) === 0) await openTour(p, TOUR_NAMES.cityWalk);
|
||||
},
|
||||
target: (p) => footer(p).getByRole('button', { name: /^Website: / }),
|
||||
},
|
||||
{
|
||||
target: (p) => footer(p).getByTestId('tour-gpx-export'),
|
||||
act: async (p) => {
|
||||
const download = p.waitForEvent('download', { timeout: 30_000 });
|
||||
await footer(p).getByTestId('tour-gpx-export').click();
|
||||
await download;
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'tour-copy-keep': {
|
||||
guide: guide('tour-copy-keep'),
|
||||
start: async (p) => {
|
||||
const { tripId, collectionId } = fixtures();
|
||||
await deleteToursNamed(p.request, tripId, COPY_NAME);
|
||||
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.roadBike);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// The visible label is Copy; the button is named by what it does.
|
||||
target: (p) => footer(p).getByRole('button', { name: 'Save as new tour' }),
|
||||
act: async (p) => {
|
||||
await footer(p).getByRole('button', { name: 'Save as new tour' }).click();
|
||||
await expect(head(p).getByRole('heading', { name: COPY_NAME, exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only(head),
|
||||
{
|
||||
// Back to the original: that is the one kept for later trips.
|
||||
prepare: async (p) => {
|
||||
await openTour(p, TOUR_NAMES.roadBike);
|
||||
},
|
||||
target: (p) => footer(p).getByTestId('tour-keep-in-collection'),
|
||||
act: async (p) => {
|
||||
await footer(p).getByTestId('tour-keep-in-collection').click();
|
||||
await expect(p.getByTestId('keep-tour-confirm')).toBeVisible();
|
||||
await expect(p.getByTestId(`keep-tour-list-${fixtures().collectionId}`)).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId(`keep-tour-list-${fixtures().collectionId}`),
|
||||
act: async (p) => {
|
||||
await p.getByTestId(`keep-tour-list-${fixtures().collectionId}`).click();
|
||||
await p.getByTestId('keep-tour-confirm').click();
|
||||
await expect(p.getByTestId('keep-tour-confirm')).toHaveCount(0, { timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
const { tripId, collectionId } = fixtures();
|
||||
await deleteToursNamed(p.request, tripId, COPY_NAME);
|
||||
if (collectionId !== null) await unkeepTour(p.request, collectionId, TOUR_NAMES.roadBike);
|
||||
},
|
||||
},
|
||||
|
||||
'tour-packing-link': {
|
||||
guide: guide('tour-packing-link'),
|
||||
start: async (p) => {
|
||||
const { tripId } = fixtures();
|
||||
await ensureTourDetailFixtures(p.request, tripId);
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.ferrata);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => card(p).getByRole('button', { name: 'Link a packing list' }),
|
||||
act: async (p) => {
|
||||
await card(p).getByRole('button', { name: 'Link a packing list' }).click();
|
||||
await expect(p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`),
|
||||
act: async (p) => {
|
||||
await p.getByTestId(`tour-packing-choice-${FERRATA_PACKING.category}`).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-packing-confirm'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-packing-confirm').click();
|
||||
await expect(card(p).getByTestId('tour-packing-card')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only((p) => card(p).getByTestId('tour-packing-card')),
|
||||
{
|
||||
target: (p) => card(p).getByRole('button', { name: 'Open packing list' }),
|
||||
act: async (p) => {
|
||||
await card(p).getByRole('button', { name: 'Open packing list' }).click();
|
||||
await expect(p.getByText(FERRATA_PACKING.items[0], { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
const { tripId, tours } = fixtures();
|
||||
await unlinkPacking(p.request, tripId, tours.ferrata ?? (await tourIdOf(p.request, tripId, TOUR_NAMES.ferrata)));
|
||||
},
|
||||
},
|
||||
|
||||
'tour-files': {
|
||||
guide: guide('tour-files'),
|
||||
start: async (p) => {
|
||||
await deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE);
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.hutToHut);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => section(p, 'Files').getByText('Upload', { exact: true }),
|
||||
act: async (p) => {
|
||||
await card(p).locator('input[type="file"][multiple]').setInputFiles(hutBookingFixture());
|
||||
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
// The list unfolds itself once the upload lands.
|
||||
only((p) => section(p, 'Files')),
|
||||
// No click: opening the file hands the browser a download.
|
||||
only((p) => card(p).getByRole('button', { name: new RegExp(`^${HUT_BOOKING_FILE.replace('.', '\\.')}`) })),
|
||||
],
|
||||
cleanup: (p) => deleteTripFilesNamed(p.request, fixtures().tripId, HUT_BOOKING_FILE),
|
||||
},
|
||||
|
||||
'tour-activity-link': {
|
||||
guide: guide('tour-activity-link'),
|
||||
start: async (p) => {
|
||||
await ensureTourRecording(p.request);
|
||||
await unlinkInariRecording(p.request);
|
||||
await openTrip(p, { day: 5 });
|
||||
await openTour(p, TOUR_NAMES.inariHike);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => section(p, 'Actual track').getByRole('button', { name: 'Add actual track' }),
|
||||
act: async (p) => {
|
||||
await card(p).getByRole('button', { name: 'Add actual track' }).click();
|
||||
await expect(p.getByTestId('tour-activity-confirm')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(p.getByTestId('tour-activity-candidate').first()).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only((p) => p.getByText('Done on', { exact: true }).first().locator('xpath=ancestor::section[1]')),
|
||||
{
|
||||
target: (p) => p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }),
|
||||
act: async (p) => {
|
||||
await p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) }).click();
|
||||
await expect(p.getByRole('radio', { name: new RegExp(TOUR_NAMES.inariRecording) })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-activity-confirm'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-activity-confirm').click();
|
||||
await expect(card(p).getByTestId('tour-activity')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only((p) => card(p).getByTestId('tour-activity')),
|
||||
],
|
||||
// The guide ends where ensureTourRecording leaves the hike: the recording linked on day 5.
|
||||
cleanup: async (p) => {
|
||||
await ensureTourRecording(p.request);
|
||||
},
|
||||
},
|
||||
|
||||
'tour-activity-compare': {
|
||||
guide: guide('tour-activity-compare'),
|
||||
start: async (p) => {
|
||||
await ensureTourRecording(p.request);
|
||||
await openTrip(p, { day: 5 });
|
||||
await openTour(p, TOUR_NAMES.inariHike);
|
||||
},
|
||||
steps: [
|
||||
only((p) => card(p).getByTestId('tour-activity-compare')),
|
||||
// The fact list under the table: Farthest from the plan and Off the plan.
|
||||
only((p) => compare(p).locator('dl').last()),
|
||||
{
|
||||
target: (p) => compare(p).getByRole('button', { name: 'Compare on map' }),
|
||||
act: async (p) => {
|
||||
await compare(p).getByRole('button', { name: 'Compare on map' }).click();
|
||||
await expect(compare(p).getByRole('button', { name: 'End comparison' })).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
only((p) => card(p).getByTestId('tour-activity').locator('footer')),
|
||||
],
|
||||
},
|
||||
|
||||
'tour-origin-check': {
|
||||
guide: guide('tour-origin-check'),
|
||||
// The imported Seceda walk first, then the hut trek, which was drawn in TREK.
|
||||
start: async (p) => {
|
||||
await openTourTrip(p, { day: null });
|
||||
await openTour(p, TOUR_NAMES.recorded);
|
||||
},
|
||||
steps: [
|
||||
only((p) => section(p, 'Origin').getByText('Source', { exact: true }).locator('xpath=..')),
|
||||
only((p) => section(p, 'Origin')),
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await openTour(p, TOUR_NAMES.hutToHut);
|
||||
},
|
||||
target: (p) => section(p, 'Origin'),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
fx = await ensureToursFixtures(request);
|
||||
if (fx.collectionId === null)
|
||||
throw new Error(`no collection "${TOUR_COLLECTION}": ensureToursFixtures could not make it`);
|
||||
await ensureTourDetailFixtures(request, fx.tripId);
|
||||
});
|
||||
|
||||
// The recording is one more tour in a trip the other screens photograph.
|
||||
test.afterAll(async ({ request }) => {
|
||||
await removeInariRecording(request);
|
||||
});
|
||||
|
||||
test('every registered tour card guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourDetailContext.guides].sort());
|
||||
});
|
||||
|
||||
test('hero: trip-tour-detail', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, tripTourDetailContext.id, async (p) => {
|
||||
await openTourTrip(p, { day: 4 });
|
||||
await openTour(p, TOUR_NAMES.hutToHut);
|
||||
});
|
||||
});
|
||||
|
||||
for (const id of tripTourDetailContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { tripTourHikeContext, tripTourHikeGuides } from '../../src/help/contexts/tripTourHike';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import {
|
||||
addTripPlaces,
|
||||
deleteToursNamed,
|
||||
editorRail,
|
||||
forgetDraft,
|
||||
openDetails,
|
||||
openTour,
|
||||
openToursTab,
|
||||
pickTourType,
|
||||
routeSettled,
|
||||
SAVED_BY_GUIDES,
|
||||
startNewTour,
|
||||
toursRail,
|
||||
} from './fixtures-trip-tour-hike';
|
||||
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript } from './guide';
|
||||
import { ensureToursFixtures, TOUR_NAMES, TOUR_PLACES } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* Actions for hikes and runs in the Tours tab, keyed by the ids in
|
||||
* `src/help/contexts/tripTourHike.ts`. They run on "Summer in the Dolomites"
|
||||
* from `tour-fixtures.ts` (the hut trek, the via ferrata hike and the railway
|
||||
* run) and, where a dense web of paths matters, on the Fushimi Inari hike of
|
||||
* the seeded Japan trip. The two plan guides save a tour through the UI and
|
||||
* delete it again by name in `cleanup`; every other guide changes the open
|
||||
* tour in the editor only and never saves, so a fresh page starts from the
|
||||
* saved tour again.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTourHikeGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
|
||||
/** The Add point and Trip places pair under the waypoints, one quiet bar. */
|
||||
const addRow = (page: Page) =>
|
||||
page.getByTestId('tour-planner-add-point').locator('xpath=ancestor::div[contains(@class,"bg-surface-tertiary")][1]');
|
||||
const waysCard = (page: Page) => page.getByTestId('hike-ways-card');
|
||||
const waysSwitch = (page: Page) => waysCard(page).getByRole('button', { name: 'Show along the route', exact: true });
|
||||
const facetButton = (page: Page, name: 'SAC' | 'Surface' | 'Way type') =>
|
||||
page.getByRole('group', { name: 'What to show', exact: true }).getByRole('button', { name, exact: true });
|
||||
/** The first class row of the known ways, the most of the route first. */
|
||||
const firstKnownRow = (page: Page) => waysCard(page).locator('ul').first().getByRole('button').first();
|
||||
const waypointRow = (page: Page, n: number) => page.getByTestId('tour-planner-waypoint-row').nth(n);
|
||||
const openMenu = (page: Page) => page.getByRole('menu');
|
||||
const ferrataIcons = (page: Page) => page.locator('.trek-ferrata-icon');
|
||||
const ferrataCard = (page: Page) => page.getByTestId('ferrata-detail-card');
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Switch Trail details on and wait until the ways along the line have been read. */
|
||||
async function showWays(page: Page): Promise<void> {
|
||||
if ((await waysSwitch(page).getAttribute('aria-pressed')) !== 'true') await waysSwitch(page).click();
|
||||
await expect(page.getByTestId('sac-known').or(page.getByTestId('surface-known'))).toBeVisible({ timeout: 120_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Click Save and wait for the toast that says so. */
|
||||
async function saveTour(page: Page): Promise<void> {
|
||||
await saveButton(page).click();
|
||||
await expect(page.getByText('Tour saved', { exact: true }).first()).toBeVisible({ timeout: 60_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* The index of the via ferrata Brigata Tridentina's sign among the ones drawn,
|
||||
* picked by its card: the Sella group has several ferratas in one view. Only a
|
||||
* sign between the two columns counts, as a click on one under a column lands
|
||||
* on the column. Falls back to the first sign that is clear of both.
|
||||
*/
|
||||
let ferrataIndex = 0;
|
||||
async function findTridentina(page: Page): Promise<void> {
|
||||
const left = await editorRail(page).boundingBox();
|
||||
const right = await toursRail(page).boundingBox();
|
||||
const minX = (left ? left.x + left.width : 0) + 24;
|
||||
const maxX = (right ? right.x : VIEWPORT.width) - 24;
|
||||
const count = await ferrataIcons(page).count();
|
||||
let fallback = -1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const box = await ferrataIcons(page).nth(i).boundingBox();
|
||||
if (!box || box.x < minX || box.x + box.width > maxX || box.y < 80 || box.y > VIEWPORT.height - 120) continue;
|
||||
if (fallback < 0) fallback = i;
|
||||
await ferrataIcons(page).nth(i).click();
|
||||
await expect(ferrataCard(page)).toBeVisible();
|
||||
const name = (await ferrataCard(page).locator('h3').textContent()) ?? '';
|
||||
await ferrataCard(page).getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(ferrataCard(page)).toHaveCount(0);
|
||||
await page.waitForTimeout(300);
|
||||
if (/tridentina/i.test(name)) {
|
||||
ferrataIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (fallback < 0)
|
||||
throw new Error('no via ferrata sign between the columns: the map is not framed on the Sella group');
|
||||
ferrataIndex = fallback;
|
||||
}
|
||||
|
||||
// ── The scripts ───────────────────────────────────────────────────────────────
|
||||
|
||||
let summerTripId = 0;
|
||||
let japanTripId = 0;
|
||||
|
||||
async function openSummer(page: Page): Promise<void> {
|
||||
summerTripId = await openToursTab(page, 'summer');
|
||||
}
|
||||
|
||||
async function openJapan(page: Page): Promise<void> {
|
||||
japanTripId = await openToursTab(page, 'japan');
|
||||
}
|
||||
|
||||
/** The stop the compare-ways guide asks from, found by `legWithOtherWays`. */
|
||||
let waysFrom = 1;
|
||||
|
||||
/**
|
||||
* The first stop, from the Yotsutsuji crossing on, whose next leg the router
|
||||
* offers another way for. What the public router knows changes with its data,
|
||||
* so a fixed leg can come back with "no other way" on one day and two on the next.
|
||||
*/
|
||||
async function legWithOtherWays(page: Page): Promise<number> {
|
||||
const card = page.getByTestId('leg-variants-card');
|
||||
const rows = await page.getByTestId('tour-planner-waypoint-row').count();
|
||||
const order = [1, ...Array.from({ length: Math.max(0, rows - 1) }, (_, i) => i).filter((i) => i !== 1)];
|
||||
for (const i of order) {
|
||||
await waypointRow(page, i).hover();
|
||||
await waypointRow(page, i).getByRole('button', { name: 'More actions', exact: true }).click();
|
||||
const compare = openMenu(page).getByRole('button', { name: 'Compare ways for the next leg', exact: true });
|
||||
if (!(await compare.isVisible().catch(() => false))) {
|
||||
await page.keyboard.press('Escape');
|
||||
continue;
|
||||
}
|
||||
await compare.click();
|
||||
const way = page.getByRole('radio', { name: 'Way A', exact: true });
|
||||
const none = card.getByText(/knows no other way|did not answer/);
|
||||
await expect(way.or(none)).toBeVisible({ timeout: 120_000 });
|
||||
const found = await way.isVisible();
|
||||
await card.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
if (found) return i;
|
||||
}
|
||||
throw new Error('the router knows no other way for any leg of the Inari hike');
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'tour-hike-plan': {
|
||||
guide: guide('tour-hike-plan'),
|
||||
start: openSummer,
|
||||
steps: [
|
||||
{
|
||||
target: (p) => editorRail(p).getByRole('button', { name: 'Plan new tour' }),
|
||||
act: startNewTour,
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await p.getByTestId('tour-type-select').click();
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => p.getByTestId('tour-type-option-hike'),
|
||||
act: (p) => pickTourType(p, 'hike'),
|
||||
},
|
||||
{
|
||||
target: (p) => p.locator('#tour-planner-name'),
|
||||
act: async (p) => {
|
||||
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.hike);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: addRow,
|
||||
act: async (p) => {
|
||||
// Two trip places stand in for two clicks on the map: they land on real spots every run.
|
||||
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
|
||||
await routeSettled(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: openDetails,
|
||||
target: (p) => p.locator('#tour-planner-difficulty'),
|
||||
act: async (p) => {
|
||||
await p.locator('#tour-planner-difficulty').click();
|
||||
await p.getByRole('button', { name: /^T3\b/ }).last().click();
|
||||
await routeSettled(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: saveButton,
|
||||
act: saveTour,
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.hike);
|
||||
await forgetDraft(p, summerTripId);
|
||||
},
|
||||
},
|
||||
|
||||
'tour-hike-trail-details': {
|
||||
guide: guide('tour-hike-trail-details'),
|
||||
start: async (p) => {
|
||||
await openSummer(p);
|
||||
await openTour(p, TOUR_NAMES.hutToHut);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: waysSwitch,
|
||||
act: showWays,
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByRole('group', { name: 'What to show', exact: true }),
|
||||
act: async (p) => {
|
||||
await facetButton(p, 'SAC').click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('sac-known').locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]'),
|
||||
},
|
||||
{
|
||||
target: firstKnownRow,
|
||||
act: async (p) => {
|
||||
await firstKnownRow(p).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
// Back to the whole route before the next facet, as the step before says.
|
||||
await firstKnownRow(p).click();
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => facetButton(p, 'Surface'),
|
||||
act: async (p) => {
|
||||
await facetButton(p, 'Surface').click();
|
||||
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => forgetDraft(p, summerTripId),
|
||||
},
|
||||
|
||||
'tour-hike-round': {
|
||||
guide: guide('tour-hike-round'),
|
||||
start: async (p) => {
|
||||
await openJapan(p);
|
||||
await openTour(p, TOUR_NAMES.inariHike);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-planner-round-hike'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-planner-round-hike').click();
|
||||
await expect(p.getByTestId('round-hike-card')).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.locator('#round-hike-distance').locator('xpath=ancestor::div[2]'),
|
||||
act: async (p) => {
|
||||
await p.locator('#round-hike-distance').fill('6');
|
||||
await expect(p.getByTestId('round-hike-window')).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('round-hike-find'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('round-hike-find').click();
|
||||
// Many questions to the router, one after the other; the bar goes when the last has come back.
|
||||
await expect(p.getByRole('radio', { name: 'Round A', exact: true })).toBeVisible({ timeout: 240_000 });
|
||||
await expect(p.getByTestId('round-hike-card').getByRole('progressbar')).toHaveCount(0, { timeout: 240_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByRole('radio', { name: 'Round A', exact: true }),
|
||||
act: async (p) => {
|
||||
await p.getByRole('radio', { name: 'Round A', exact: true }).click();
|
||||
await expect(p.getByTestId('round-hike-apply')).toBeEnabled();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('round-hike-apply'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('round-hike-apply').click();
|
||||
await p.getByRole('button', { name: 'Replace', exact: true }).click();
|
||||
await expect(p.getByText('Round taken', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await routeSettled(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => forgetDraft(p, japanTripId),
|
||||
},
|
||||
|
||||
'tour-hike-compare-ways': {
|
||||
guide: guide('tour-hike-compare-ways'),
|
||||
start: async (p) => {
|
||||
await openJapan(p);
|
||||
await openTour(p, TOUR_NAMES.inariHike);
|
||||
waysFrom = await legWithOtherWays(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// A stop whose next leg the router knows another way for; the Yotsutsuji crossing usually.
|
||||
prepare: async (p) => {
|
||||
await waypointRow(p, waysFrom).hover();
|
||||
await waypointRow(p, waysFrom).getByRole('button', { name: 'More actions', exact: true }).click();
|
||||
await expect(openMenu(p)).toBeVisible();
|
||||
},
|
||||
target: (p) => openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }),
|
||||
act: async (p) => {
|
||||
await openMenu(p).getByRole('button', { name: 'Compare ways for the next leg', exact: true }).click();
|
||||
await expect(p.getByTestId('leg-variants-card')).toBeVisible();
|
||||
await expect(p.getByRole('radio', { name: 'Way A', exact: true })).toBeVisible({ timeout: 120_000 });
|
||||
await expect(p.getByTestId('leg-variants-card').getByText('Looking for other ways')).toHaveCount(0, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('leg-variants-card'),
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByRole('radio', { name: 'Way A', exact: true }),
|
||||
act: async (p) => {
|
||||
await p.getByRole('radio', { name: 'Way A', exact: true }).click();
|
||||
await expect(p.getByTestId('leg-variants-apply')).toBeEnabled();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('leg-variants-apply'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('leg-variants-apply').click();
|
||||
await expect(p.getByText('Way taken', { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
await routeSettled(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => forgetDraft(p, japanTripId),
|
||||
},
|
||||
|
||||
'tour-ferrata-add': {
|
||||
guide: guide('tour-ferrata-add'),
|
||||
start: async (p) => {
|
||||
await openSummer(p);
|
||||
// Opening the ferrata hike frames the map on the Sella group, and a new tour without
|
||||
// points keeps that view. Not through Back to Tours: the neutral rail frames the whole
|
||||
// trip again, too far out for the via ferrata signs. Add tour on the right goes straight
|
||||
// from the open tour to a new one.
|
||||
await openTour(p, TOUR_NAMES.ferrata);
|
||||
await toursRail(p).getByRole('button', { name: 'Add tour', exact: true }).click();
|
||||
await p.getByRole('button', { name: 'Plan new tour', exact: true }).last().click();
|
||||
await expect(editorRail(p).getByRole('heading', { name: 'New tour', exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await pickTourType(p, 'hike');
|
||||
await settle(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => p.getByRole('button', { name: 'Via ferratas', exact: true }),
|
||||
act: async (p) => {
|
||||
await p.getByRole('button', { name: 'Via ferratas', exact: true }).click();
|
||||
await expect(p.getByTestId('ferrata-count-badge')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(p.getByTestId('ferrata-count-spinner')).toHaveCount(0, { timeout: 90_000 });
|
||||
await expect(ferrataIcons(p).first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: findTridentina,
|
||||
target: (p) => ferrataIcons(p).nth(ferrataIndex),
|
||||
act: async (p) => {
|
||||
await ferrataIcons(p).nth(ferrataIndex).click();
|
||||
await expect(ferrataCard(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('ferrata-adopt'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('ferrata-adopt').click();
|
||||
await expect(p.getByTestId('tour-planner-waypoint-row')).toHaveCount(2, { timeout: 15_000 });
|
||||
await routeSettled(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-planner-ferrata-leg').first(),
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-ferrata-crossings'),
|
||||
},
|
||||
],
|
||||
cleanup: (p) => forgetDraft(p, summerTripId),
|
||||
},
|
||||
|
||||
'tour-run-plan': {
|
||||
guide: guide('tour-run-plan'),
|
||||
start: openSummer,
|
||||
steps: [
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await startNewTour(p);
|
||||
await p.getByTestId('tour-type-select').click();
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => p.getByTestId('tour-type-option-run'),
|
||||
act: (p) => pickTourType(p, 'run'),
|
||||
},
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-run-profile'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-run-profile').getByRole('button', { name: 'Trail running', exact: true }).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => p.locator('#tour-planner-run-pace'),
|
||||
act: async (p) => {
|
||||
await p.locator('#tour-planner-run-pace').fill('7:30');
|
||||
await p.locator('#tour-planner-run-pace').press('Enter');
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await addTripPlaces(p, TOUR_PLACES.selvaHotel, TOUR_PLACES.firenze);
|
||||
await routeSettled(p);
|
||||
await expect(p.getByTestId('tour-run-time-sum')).toBeVisible({ timeout: 30_000 });
|
||||
},
|
||||
target: (p) => p.getByTestId('tour-run-time-sum'),
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await p.locator('#tour-planner-name').fill(SAVED_BY_GUIDES.run);
|
||||
},
|
||||
target: saveButton,
|
||||
act: saveTour,
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
await deleteToursNamed(p, summerTripId, SAVED_BY_GUIDES.run);
|
||||
await forgetDraft(p, summerTripId);
|
||||
},
|
||||
},
|
||||
|
||||
'tour-run-ways': {
|
||||
guide: guide('tour-run-ways'),
|
||||
start: async (p) => {
|
||||
await openSummer(p);
|
||||
await openTour(p, TOUR_NAMES.run);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: waysSwitch,
|
||||
act: showWays,
|
||||
},
|
||||
{
|
||||
// The card's head: the unpaved share and the hardest graded path, read once the ways are in.
|
||||
target: (p) => p.getByRole('region', { name: 'Trail details', exact: true }).locator('> div').first(),
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await facetButton(p, 'Surface').click();
|
||||
await expect(p.getByTestId('surface-known')).toBeVisible({ timeout: 120_000 });
|
||||
await settle(p);
|
||||
},
|
||||
target: firstKnownRow,
|
||||
act: async (p) => {
|
||||
await firstKnownRow(p).click();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => forgetDraft(p, summerTripId),
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }, testInfo) => {
|
||||
// A cold run fetches every route of the Tours demo trips once (minutes), later runs read the cache.
|
||||
testInfo.setTimeout(600_000);
|
||||
await ensureToursFixtures(request);
|
||||
});
|
||||
|
||||
test('every registered hike and run guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHikeContext.guides].sort());
|
||||
});
|
||||
|
||||
test('hero: trip-tour-hike', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, tripTourHikeContext.id, async (p) => {
|
||||
await openSummer(p);
|
||||
await openTour(p, TOUR_NAMES.ferrata);
|
||||
});
|
||||
});
|
||||
|
||||
for (const id of tripTourHikeContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
test.setTimeout(600_000);
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { tripTourHorseContext, tripTourHorseGuides } from '../../src/help/contexts/tripTourHorse';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import {
|
||||
deleteToursNamed,
|
||||
finishMapPan,
|
||||
forgetTourDraft,
|
||||
horsePlan,
|
||||
NEW_RIDE,
|
||||
openSavedRide,
|
||||
openToursTab,
|
||||
RECORDED_RIDE,
|
||||
recordedRideFile,
|
||||
routeTitle,
|
||||
serveHorseRoutes,
|
||||
tourRow,
|
||||
type HorsePlan,
|
||||
} from './fixtures-trip-tour-horse';
|
||||
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide';
|
||||
import { ensureToursFixtures } from './tour-fixtures';
|
||||
|
||||
/**
|
||||
* Actions for horse rides on the Tours tab, keyed by the ids in
|
||||
* `src/help/contexts/tripTourHorse.ts`. They run on "Summer in the Dolomites"
|
||||
* (`ensureToursFixtures`), with the Seiser Alm in view: the saved "Seiser Alm
|
||||
* horse ride" for the overview, a new ride built from the horse routes the
|
||||
* card is given (`horsePlan`, the same answer for every picture), and a ride
|
||||
* recorded on a GPS device imported from its file. The new ride and the
|
||||
* import are deleted again in `cleanup`; the gap guide saves nothing, but its
|
||||
* draft is kept on the account, so its cleanup drops it before the next guide
|
||||
* of the trip opens the Tours tab.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTourHorseGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
let plan: HorsePlan;
|
||||
let rideFile = '';
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The editor's left column: a section named after what it is for. */
|
||||
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour' });
|
||||
const planNewTour = (page: Page) => page.getByRole('button', { name: 'Plan new tour', exact: true });
|
||||
const typeSelect = (page: Page) => page.getByTestId('tour-type-select');
|
||||
const horseOption = (page: Page) => page.getByTestId('tour-type-option-horse');
|
||||
const horseCard = (page: Page) => page.getByRole('region', { name: 'Horse routes' });
|
||||
const findButton = (page: Page) =>
|
||||
page.getByTestId('horse-routes-card').getByRole('button', { name: 'Find in map view' });
|
||||
const routeRow = (page: Page, id: string) => page.getByTestId(`horse-route-${id}`);
|
||||
/**
|
||||
* A dialog's accessible name is its eyebrow ("Horse route", "Import preview"),
|
||||
* not its title, so the two dialogs are found by the title heading they hold.
|
||||
*/
|
||||
const dialogTitled = (page: Page, title: string) =>
|
||||
page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
|
||||
const routeDialog = (page: Page, id: string) => dialogTitled(page, routeTitle(plan, id));
|
||||
const sectionChip = (page: Page, n: number) => page.getByTestId(`horse-section-${n}`);
|
||||
const takeSection = (page: Page) => page.getByTestId('horse-section-adopt');
|
||||
const joinGapSwitch = (page: Page) => page.getByRole('button', { name: 'Join with a manual section', exact: true });
|
||||
const horseLeg = (page: Page) => page.getByTestId('tour-planner-horse-leg').first();
|
||||
const manualLeg = (page: Page) => page.getByTestId('tour-planner-free-leg').first();
|
||||
const saveButton = (page: Page) => editorRail(page).getByRole('button', { name: 'Save', exact: true });
|
||||
const previewDialog = (page: Page) => dialogTitled(page, RECORDED_RIDE.file);
|
||||
const recordedCard = (page: Page) => page.getByRole('region', { name: 'Recorded ride' });
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Plan new tour, and the type set to Horse ride. */
|
||||
async function startRide(page: Page): Promise<void> {
|
||||
await planNewTour(page).click();
|
||||
await expect(typeSelect(page)).toBeVisible({ timeout: 15_000 });
|
||||
await typeSelect(page).click();
|
||||
await horseOption(page).click();
|
||||
await expect(horseCard(page)).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find in map view, and the list. A view the card finds too large says so
|
||||
* instead of searching; then the map is zoomed in once over its middle and the
|
||||
* button pressed again, which is what the notice asks of the reader too.
|
||||
*/
|
||||
async function findRoutes(page: Page): Promise<void> {
|
||||
const zoomNotice = page.getByText('Zoom in further to look for horse routes.');
|
||||
// The planner opens around zoom 11, near a degree wide; a search may span half of one.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
await findButton(page).click();
|
||||
await expect(page.getByTestId('horse-routes-list').or(zoomNotice)).toBeVisible({ timeout: 45_000 });
|
||||
if (await page.getByTestId('horse-routes-list').isVisible()) break;
|
||||
const box = await page.getByTestId('tour-map-surface').boundingBox();
|
||||
if (!box) throw new Error('the planner map has no box');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, -480);
|
||||
await finishMapPan(page);
|
||||
}
|
||||
await expect(page.getByTestId('horse-routes-list')).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function openRoute(page: Page, id: string): Promise<void> {
|
||||
await routeRow(page, id).click();
|
||||
await expect(routeDialog(page, id)).toBeVisible({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function pickSection(page: Page, n: number): Promise<void> {
|
||||
await sectionChip(page, n).click();
|
||||
await expect(sectionChip(page, n)).toHaveAttribute('aria-pressed', 'true');
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Take the open dialog's section and wait for its badge in the Waypoints card. */
|
||||
async function adopt(page: Page, id: string): Promise<void> {
|
||||
await takeSection(page).click();
|
||||
await expect(routeDialog(page, id)).toHaveCount(0);
|
||||
await expect(horseLeg(page)).toBeVisible({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The gap guide's start: a ride holding its first section, and the route with the gap open. */
|
||||
async function rideWithFirstSection(page: Page): Promise<void> {
|
||||
await serveHorseRoutes(page, plan);
|
||||
await openToursTab(page);
|
||||
await startRide(page);
|
||||
await findRoutes(page);
|
||||
await openRoute(page, plan.first.routeId);
|
||||
await pickSection(page, plan.first.section);
|
||||
await adopt(page, plan.first.routeId);
|
||||
await openRoute(page, plan.gap.routeId);
|
||||
}
|
||||
|
||||
// ── The guides ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'tour-horse-plan': {
|
||||
guide: guide('tour-horse-plan'),
|
||||
start: async (p) => {
|
||||
await deleteToursNamed(p.request, NEW_RIDE);
|
||||
await serveHorseRoutes(p, plan);
|
||||
await openToursTab(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: planNewTour,
|
||||
act: async (p) => {
|
||||
await planNewTour(p).click();
|
||||
await expect(typeSelect(p)).toBeVisible({ timeout: 15_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await typeSelect(p).click();
|
||||
await expect(horseOption(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
target: horseOption,
|
||||
act: async (p) => {
|
||||
await horseOption(p).click();
|
||||
await expect(horseCard(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{ target: findButton, act: findRoutes },
|
||||
{
|
||||
target: (p) => routeRow(p, plan.first.routeId),
|
||||
act: (p) => openRoute(p, plan.first.routeId),
|
||||
},
|
||||
{
|
||||
prepare: (p) => pickSection(p, plan.first.section),
|
||||
target: (p) => routeDialog(p, plan.first.routeId),
|
||||
// The pointer on the section chosen, so its tooltip names what the tags say along it.
|
||||
hover: async (p) => {
|
||||
await sectionChip(p, plan.first.section).hover();
|
||||
},
|
||||
},
|
||||
{
|
||||
target: takeSection,
|
||||
act: (p) => adopt(p, plan.first.routeId),
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await typeInto(p, p.locator('#tour-planner-name'), NEW_RIDE);
|
||||
await p.locator('#tour-planner-name').press('Tab');
|
||||
// Heights are asked for the line before a ride can be saved.
|
||||
await expect(saveButton(p)).toBeEnabled({ timeout: 60_000 });
|
||||
await settle(p);
|
||||
},
|
||||
target: saveButton,
|
||||
act: async (p) => {
|
||||
await saveButton(p).click();
|
||||
await expect(p.getByText('Tour saved')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(tourRow(p, NEW_RIDE)).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => deleteToursNamed(p.request, NEW_RIDE),
|
||||
},
|
||||
'tour-horse-gap': {
|
||||
guide: guide('tour-horse-gap'),
|
||||
start: rideWithFirstSection,
|
||||
steps: [
|
||||
{
|
||||
prepare: (p) => pickSection(p, plan.gap.section),
|
||||
target: (p) => p.getByTestId('horse-section-gap'),
|
||||
},
|
||||
{
|
||||
target: joinGapSwitch,
|
||||
act: async (p) => {
|
||||
await joinGapSwitch(p).click();
|
||||
await expect(joinGapSwitch(p)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(takeSection(p)).toBeEnabled();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: takeSection,
|
||||
act: async (p) => {
|
||||
await takeSection(p).click();
|
||||
await expect(routeDialog(p, plan.gap.routeId)).toHaveCount(0);
|
||||
await expect(manualLeg(p)).toBeVisible({ timeout: 15_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{ target: manualLeg },
|
||||
],
|
||||
cleanup: (p) => forgetTourDraft(p.request),
|
||||
},
|
||||
'tour-horse-recorded': {
|
||||
guide: guide('tour-horse-recorded'),
|
||||
start: async (p) => {
|
||||
await deleteToursNamed(p.request, RECORDED_RIDE.name);
|
||||
await openToursTab(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-rail-add'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-rail-add').click();
|
||||
const entry = p.getByRole('button', { name: 'Import GPX or KML', exact: true });
|
||||
await expect(entry).toBeVisible();
|
||||
// The file picker of the operating system cannot be photographed; its chooser is answered instead.
|
||||
const chooser = p.waitForEvent('filechooser');
|
||||
await entry.click();
|
||||
await (await chooser).setFiles(rideFile);
|
||||
await expect(previewDialog(p)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(previewDialog(p).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await previewDialog(p).getByTestId('tour-type-select').click();
|
||||
await horseOption(p).click();
|
||||
await expect(previewDialog(p).getByTestId('gpx-preview-horse')).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => previewDialog(p).getByTestId('gpx-preview-horse'),
|
||||
},
|
||||
{
|
||||
target: (p) => previewDialog(p).getByTestId('gpx-preview-import'),
|
||||
act: async (p) => {
|
||||
await previewDialog(p).getByTestId('gpx-preview-import').click();
|
||||
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
|
||||
await expect(recordedCard(p)).toBeVisible({ timeout: 60_000 });
|
||||
await finishMapPan(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: recordedCard,
|
||||
// On the badge with the most to say, so the picture carries its tooltip.
|
||||
hover: async (p) => {
|
||||
await recordedCard(p).getByText('Access not checked', { exact: true }).hover();
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: (p) => deleteToursNamed(p.request, RECORDED_RIDE.name),
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
test.setTimeout(600_000);
|
||||
await ensureToursFixtures(request);
|
||||
plan = await horsePlan(request);
|
||||
console.log(`[trip-tour-horse] horse routes: ${plan.source}`);
|
||||
rideFile = await recordedRideFile();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Here as well as in the cleanups, so a failed run leaves no ride behind.
|
||||
await deleteToursNamed(request, NEW_RIDE, RECORDED_RIDE.name);
|
||||
await forgetTourDraft(request);
|
||||
});
|
||||
|
||||
test('every registered horse ride guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourHorseContext.guides].sort());
|
||||
});
|
||||
|
||||
test('hero: trip-tour-horse', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, tripTourHorseContext.id, async (p) => {
|
||||
await serveHorseRoutes(p, plan);
|
||||
await openSavedRide(p);
|
||||
});
|
||||
});
|
||||
|
||||
for (const id of tripTourHorseContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { tripTourImportContext, tripTourImportGuides } from '../../src/help/contexts/tripTourImport';
|
||||
import type { HelpGuide } from '../../src/help/types';
|
||||
import {
|
||||
deleteToursNamed,
|
||||
disconnectWanderer,
|
||||
ensureWandererConnection,
|
||||
forgetTourDraft,
|
||||
IMPORTED_NAME,
|
||||
importFile,
|
||||
RECORDED_NAME,
|
||||
restoreRecordedTrack,
|
||||
tourNamed,
|
||||
tripIdByTitle,
|
||||
WANDERER_TRAIL,
|
||||
} from './fixtures-trip-tour-import';
|
||||
import { captureGuide, captureHero, settle, VIEWPORT, type GuideScript, type StepAction } from './guide';
|
||||
import { ensureToursFixtures, openTourTrip, SKI_TRIP, TOUR_NAMES, TOUR_TRIP } from './tour-fixtures';
|
||||
import { confirmDialog } from './trip-shared';
|
||||
|
||||
/**
|
||||
* Actions for getting tours into a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripTourImport.ts`. They run on the Tours tab of
|
||||
* "Summer in the Dolomites" (`ensureToursFixtures`): a GPX file of the
|
||||
* Rasciesa balcony walk goes in through the Import preview, the recorded
|
||||
* Seceda ridge walk that the fixtures imported is read, planned over and put
|
||||
* back, a run of that trip is taken over into the ski week, and the Rasciesa
|
||||
* walk comes in once more from a wanderer instance. What a guide adds it
|
||||
* deletes again in `cleanup`, and the Seceda walk is always given its
|
||||
* recorded track back, because the fixtures never rebuild a tour.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTourImportGuides.find((x) => x.id === id);
|
||||
if (!g) throw new Error(`no registered guide "${id}"`);
|
||||
return g;
|
||||
};
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The right column of the Tours tab. */
|
||||
const toursRail = (page: Page) => page.getByRole('complementary', { name: 'Tours of this trip' });
|
||||
const tourRow = (page: Page, name: string) => toursRail(page).getByRole('option', { name, exact: true });
|
||||
const addTour = (page: Page) => page.getByTestId('tour-rail-add');
|
||||
/** The menu the + opens; a portal on the body, labelled like its button. */
|
||||
const addMenuItem = (page: Page, label: string) =>
|
||||
page.getByRole('group', { name: 'Add tour' }).getByRole('button', { name: label, exact: true });
|
||||
/** The left column while an import is shown: a section named after the tour. */
|
||||
const importRail = (page: Page, name: string) => page.getByRole('region', { name, exact: true });
|
||||
const railCard = (page: Page, rail: string, card: string) =>
|
||||
importRail(page, rail).getByRole('region', { name: card, exact: true });
|
||||
/** The left column while a tour is edited (and while nothing is open). */
|
||||
const editorRail = (page: Page) => page.getByRole('region', { name: 'Plan a tour', exact: true });
|
||||
const previewDialog = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Import preview' });
|
||||
const previewSection = (page: Page, has: 'tracks' | 'as') =>
|
||||
has === 'tracks'
|
||||
? previewDialog(page)
|
||||
.locator('section')
|
||||
.filter({ has: page.getByTestId('gpx-preview-track') })
|
||||
: previewDialog(page)
|
||||
.locator('section')
|
||||
.filter({ has: page.getByTestId('tour-type-select') });
|
||||
/**
|
||||
* The take-over dialog. Its header labels it by the eyebrow ("Take over a
|
||||
* tour"), not by the title, so it is found by the list section the title
|
||||
* names, which stays right whichever header line ends up as the label.
|
||||
*/
|
||||
const library = (page: Page) =>
|
||||
page.getByRole('dialog').filter({ has: page.getByRole('region', { name: 'From another trip', exact: true }) });
|
||||
const wandererRail = (page: Page) => page.getByRole('region', { name: 'wanderer', exact: true });
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The Tours tab of the Dolomites trip (or the ski week), with its list of tours in. */
|
||||
async function openTours(page: Page, trip: 'summer' | 'ski' = 'summer'): Promise<void> {
|
||||
await openTourTrip(page, { trip, tab: 'tour-planner' });
|
||||
await expect(toursRail(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toursRail(page).getByRole('option').first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The + and its menu, as the first two steps of every way in show them. */
|
||||
function addMenuSteps(entry: string, act: (page: Page) => Promise<void>): StepAction[] {
|
||||
return [
|
||||
{
|
||||
target: addTour,
|
||||
act: async (p) => {
|
||||
await addTour(p).click();
|
||||
await expect(addMenuItem(p, entry)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{ target: (p) => addMenuItem(p, entry), act },
|
||||
];
|
||||
}
|
||||
|
||||
/** Picks the Rasciesa GPX in the file chooser the menu entry opens, and waits for the preview. */
|
||||
async function chooseImportFile(page: Page): Promise<void> {
|
||||
const file = await importFile();
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await addMenuItem(page, 'Import GPX or KML').click();
|
||||
await (await chooser).setFiles(file);
|
||||
await expect(previewDialog(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(previewDialog(page).getByTestId('gpx-preview-track').first()).toBeVisible({ timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Opens the recorded Seceda walk from the list: an import is shown as it came in. */
|
||||
async function viewRecorded(page: Page): Promise<void> {
|
||||
await tourRow(page, RECORDED_NAME).click();
|
||||
await expect(railCard(page, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 20_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Asks for the editable version and waits until the router has answered and the card compares. */
|
||||
async function analyse(page: Page): Promise<void> {
|
||||
await railCard(page, RECORDED_NAME, 'Editable version')
|
||||
.getByRole('button', { name: 'Plan an editable version' })
|
||||
.click();
|
||||
await expect(page.getByRole('button', { name: 'Replace the import' })).toBeVisible({ timeout: 120_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Takes the suggestion over the import in place; the editor opens and has to route before it can save. */
|
||||
async function replaceImport(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Replace the import' }).click();
|
||||
await expect(editorRail(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({ timeout: 120_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function saveEditor(page: Page): Promise<void> {
|
||||
await editorRail(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Tour saved').first()).toBeVisible({ timeout: 60_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** Puts the Dolomites trip back as the fixtures left it: no Rasciesa tour, the Seceda walk as recorded, no draft. */
|
||||
async function resetSummer(page: Page): Promise<void> {
|
||||
const tripId = await tripIdByTitle(page.request, TOUR_TRIP);
|
||||
await deleteToursNamed(page.request, tripId, IMPORTED_NAME, WANDERER_TRAIL);
|
||||
await restoreRecordedTrack(page.request, tripId, RECORDED_NAME);
|
||||
await forgetTourDraft(page.request, tripId);
|
||||
}
|
||||
|
||||
// ── Scripts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAKEN_OVER_NAME = 'Morning run on the railway trail';
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'tour-import-gpx': {
|
||||
guide: guide('tour-import-gpx'),
|
||||
start: async (p) => {
|
||||
await resetSummer(p);
|
||||
await openTours(p);
|
||||
},
|
||||
steps: [
|
||||
...addMenuSteps('Import GPX or KML', chooseImportFile),
|
||||
{ target: (p) => previewSection(p, 'tracks') },
|
||||
// Hike is preset, and the Rasciesa walk is one: the step shows the field and leaves it.
|
||||
{ target: (p) => previewSection(p, 'as') },
|
||||
{
|
||||
target: (p) => p.getByTestId('gpx-preview-import'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('gpx-preview-import').click();
|
||||
await expect(previewDialog(p)).toHaveCount(0, { timeout: 60_000 });
|
||||
// One track: it opens on the map at once, as a GPX tour.
|
||||
await expect(railCard(p, IMPORTED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tourRow(p, IMPORTED_NAME)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: resetSummer,
|
||||
},
|
||||
|
||||
'tour-view-import': {
|
||||
guide: guide('tour-view-import'),
|
||||
start: async (p) => {
|
||||
await resetSummer(p);
|
||||
await openTours(p);
|
||||
},
|
||||
steps: [
|
||||
{ target: (p) => tourRow(p, RECORDED_NAME), act: viewRecorded },
|
||||
{ target: (p) => railCard(p, RECORDED_NAME, 'Tour summary') },
|
||||
{
|
||||
target: (p) => importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }),
|
||||
act: async (p) => {
|
||||
await importRail(p, RECORDED_NAME).getByRole('button', { name: 'Back to Tours' }).click();
|
||||
await expect(importRail(p, RECORDED_NAME)).toHaveCount(0);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'tour-plan-from-import': {
|
||||
guide: guide('tour-plan-from-import'),
|
||||
start: async (p) => {
|
||||
await resetSummer(p);
|
||||
await openTours(p);
|
||||
await viewRecorded(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) =>
|
||||
railCard(p, RECORDED_NAME, 'Editable version').getByRole('button', { name: 'Plan an editable version' }),
|
||||
act: analyse,
|
||||
},
|
||||
{ target: (p) => railCard(p, RECORDED_NAME, 'Editable version') },
|
||||
{ target: (p) => p.getByRole('button', { name: 'Replace the import' }), act: replaceImport },
|
||||
{ target: (p) => editorRail(p).getByRole('button', { name: 'Save', exact: true }), act: saveEditor },
|
||||
],
|
||||
cleanup: resetSummer,
|
||||
},
|
||||
|
||||
'tour-restore-recorded': {
|
||||
guide: guide('tour-restore-recorded'),
|
||||
// The walk is planned over first, the way the guide before this one does
|
||||
// it, then the editor is closed so step 1 opens it from the list.
|
||||
start: async (p) => {
|
||||
await resetSummer(p);
|
||||
await openTours(p);
|
||||
await viewRecorded(p);
|
||||
await analyse(p);
|
||||
await replaceImport(p);
|
||||
await saveEditor(p);
|
||||
await editorRail(p).getByRole('button', { name: 'Back to Tours' }).click();
|
||||
await expect(editorRail(p).getByRole('button', { name: 'Plan new tour' })).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async (p) => {
|
||||
await tourRow(p, RECORDED_NAME).click();
|
||||
await expect(editorRail(p).getByRole('region', { name: 'Recorded track', exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
target: (p) => editorRail(p).getByRole('region', { name: 'Recorded track', exact: true }),
|
||||
},
|
||||
{
|
||||
target: (p) => editorRail(p).getByRole('button', { name: 'Restore recorded track' }),
|
||||
act: async (p) => {
|
||||
await editorRail(p).getByRole('button', { name: 'Restore recorded track' }).click();
|
||||
await expect(confirmDialog(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }),
|
||||
act: async (p) => {
|
||||
await confirmDialog(p).getByRole('button', { name: 'Restore recorded track' }).click();
|
||||
await expect(p.getByText('Recorded track restored').first()).toBeVisible({ timeout: 30_000 });
|
||||
// It comes back as a GPX tour, shown as it was recorded.
|
||||
await expect(railCard(p, RECORDED_NAME, 'Tour summary')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: resetSummer,
|
||||
},
|
||||
|
||||
'tour-take-over': {
|
||||
guide: guide('tour-take-over'),
|
||||
start: async (p) => {
|
||||
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
|
||||
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME, TOUR_NAMES.run);
|
||||
await forgetTourDraft(p.request, skiId);
|
||||
await openTours(p, 'ski');
|
||||
},
|
||||
steps: [
|
||||
...addMenuSteps('Take over a tour from another trip', async (p) => {
|
||||
await addMenuItem(p, 'Take over a tour from another trip').click();
|
||||
await expect(library(p)).toBeVisible();
|
||||
await expect(library(p).getByTestId('tour-library-list')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
}),
|
||||
{
|
||||
target: (p) => library(p).getByRole('region', { name: 'From another trip' }),
|
||||
act: async (p) => {
|
||||
await library(p).getByPlaceholder('Search by name').fill('railway');
|
||||
await expect(
|
||||
library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run })
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }),
|
||||
act: async (p) => {
|
||||
await library(p).locator('[data-testid^="tour-library-row-"]').filter({ hasText: TOUR_NAMES.run }).click();
|
||||
await expect(library(p).getByTestId('tour-library-preview')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(library(p).getByTestId('tour-library-confirm')).toBeEnabled({ timeout: 20_000 });
|
||||
await p.waitForTimeout(800); // the preview's line
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => library(p).getByLabel('Name in this trip'),
|
||||
act: async (p) => {
|
||||
await library(p).getByLabel('Name in this trip').fill(TAKEN_OVER_NAME);
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => library(p).getByTestId('tour-library-confirm'),
|
||||
act: async (p) => {
|
||||
await library(p).getByTestId('tour-library-confirm').click();
|
||||
await expect(library(p)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(tourRow(p, TAKEN_OVER_NAME)).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
const skiId = await tripIdByTitle(p.request, SKI_TRIP);
|
||||
await deleteToursNamed(p.request, skiId, TAKEN_OVER_NAME);
|
||||
await forgetTourDraft(p.request, skiId);
|
||||
},
|
||||
},
|
||||
|
||||
'tour-import-wanderer': {
|
||||
guide: guide('tour-import-wanderer'),
|
||||
// Connected here and taken away in cleanup: with a connection the neutral
|
||||
// rail grows the wanderer button, which no other Tours picture shows.
|
||||
start: async (p) => {
|
||||
await resetSummer(p);
|
||||
await ensureWandererConnection(p.request);
|
||||
await openTours(p);
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: (p) => p.getByTestId('tour-rail-open-wanderer'),
|
||||
act: async (p) => {
|
||||
await p.getByTestId('tour-rail-open-wanderer').click();
|
||||
await expect(wandererRail(p)).toBeVisible();
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => wandererRail(p).getByRole('textbox', { name: 'Search trails' }),
|
||||
act: async (p) => {
|
||||
await wandererRail(p).getByRole('textbox', { name: 'Search trails' }).fill('Rasciesa');
|
||||
await expect(wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }),
|
||||
act: async (p) => {
|
||||
await wandererRail(p).getByRole('option').filter({ hasText: WANDERER_TRAIL }).click();
|
||||
await expect(wandererRail(p).getByRole('button', { name: 'Import into trip' })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
target: (p) => wandererRail(p).getByRole('button', { name: 'Import into trip' }),
|
||||
act: async (p) => {
|
||||
await wandererRail(p).getByRole('button', { name: 'Import into trip' }).click();
|
||||
await expect(wandererRail(p)).toHaveCount(0, { timeout: 60_000 });
|
||||
await expect(railCard(p, WANDERER_TRAIL, 'Tour summary')).toBeVisible({ timeout: 30_000 });
|
||||
await settle(p);
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async (p) => {
|
||||
await resetSummer(p);
|
||||
await disconnectWanderer(p.request);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureToursFixtures(request);
|
||||
const tripId = await tripIdByTitle(request, TOUR_TRIP);
|
||||
if (!(await tourNamed(request, tripId, RECORDED_NAME)))
|
||||
throw new Error(`"${RECORDED_NAME}" is missing: the fixtures could not import it`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide: left on, every Tours tab would offer wanderer. Here as well
|
||||
// as in the guide's cleanup, so a failed run puts it back too.
|
||||
await disconnectWanderer(request);
|
||||
});
|
||||
|
||||
test('every registered tour import guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTourImportContext.guides].sort());
|
||||
});
|
||||
|
||||
// The screen as a reader meets it: the Import preview of a recorded walk over the Tours tab.
|
||||
test('hero: trip-tour-import', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureHero(page, tripTourImportContext.id, async (p) => {
|
||||
await resetSummer(p);
|
||||
await openTours(p);
|
||||
await addTour(p).click();
|
||||
await chooseImportFile(p);
|
||||
});
|
||||
});
|
||||
|
||||
for (const id of tripTourImportContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT);
|
||||
await captureGuide(page, SCRIPTS[id]);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user