Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e225548c79 | |||
| e5bda89faa | |||
| 815020dd58 | |||
| ebfa266ee0 | |||
| f450208db4 | |||
| d1d7857363 | |||
| e7a5acbd68 | |||
| e02cc7ecf7 | |||
| d3219af062 | |||
| ae2d522890 | |||
| b513dde39e | |||
| dee2603d42 | |||
| 633a2b38a4 | |||
| 2a66665935 | |||
| d2bc4cc297 | |||
| 4c65b17a72 | |||
| 2e1db584bd | |||
| 003c5826ab | |||
| dff79089ab | |||
| f9dabccf19 | |||
| f84564e2e9 | |||
| 105cce5deb | |||
| be5004e282 | |||
| b37d66a4c1 | |||
| c620b44a7b | |||
| c58818e8f8 | |||
| 5e1b588791 | |||
| b4ea23203d | |||
| b9d0f6df4b | |||
| aa482be840 | |||
| afa0dd0e33 | |||
| 4be4c4ea11 | |||
| 5db38e82f2 | |||
| 8cbea7ad47 | |||
| 49c598a384 | |||
| 1dd1ad838d | |||
| 7f5352efe5 | |||
| 96f5d9b587 | |||
| 049cc50fda | |||
| cd6f3b9d2c | |||
| 784237a02b | |||
| f9333d51a6 | |||
| 802ac51904 |
@@ -26,5 +26,3 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||
|
||||
@@ -92,12 +92,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -108,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: |
|
||||
@@ -172,12 +163,7 @@ jobs:
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
# The labels live on each platform image; the index gets the ones a
|
||||
# client reads before it picks a platform (#1498).
|
||||
docker buildx imagetools create \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
|
||||
--annotation "index:org.opencontainers.image.version=$VERSION" \
|
||||
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
|
||||
@@ -138,12 +138,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
run: |
|
||||
echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -154,9 +148,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: |
|
||||
@@ -216,12 +207,7 @@ jobs:
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
# The labels live on each platform image; the index gets the ones a
|
||||
# client reads before it picks a platform (#1498).
|
||||
docker buildx imagetools create \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/liketrek/TREK" \
|
||||
--annotation "index:org.opencontainers.image.version=$VERSION" \
|
||||
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
|
||||
@@ -51,12 +51,6 @@ on:
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
@@ -75,12 +69,6 @@ on:
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
@@ -180,26 +168,9 @@ jobs:
|
||||
# catches it.
|
||||
run: cd server && npm run typecheck:tests
|
||||
|
||||
- name: Typecheck entity generator
|
||||
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
||||
# gen-plugin-facts.ts uses import.meta and does not compile under this
|
||||
# config (needs a newer --module target), so the gate is named for what
|
||||
# it actually covers rather than the whole directory.
|
||||
run: cd server && npm run typecheck:scripts
|
||||
|
||||
- name: Entity files match the generator
|
||||
run: cd server && npm run check:entities
|
||||
|
||||
- name: Lint
|
||||
# Type-aware rules build the src + tests program, which outgrows
|
||||
# Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: cd server && npm run lint:check
|
||||
|
||||
- name: DB call-graph gate (no sync DB-touching methods, no raw transactions)
|
||||
run: cd server && npm run db:call-graph -- --sync --tx
|
||||
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
@@ -90,7 +90,7 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -20,15 +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/
|
||||
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 ./
|
||||
@@ -53,27 +51,17 @@ COPY server/package.json ./server/
|
||||
# changeset, so it costs nothing. Everything copied after this point carries
|
||||
# --chown=node:node for the same reason — a recursive chown in a later layer
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
#
|
||||
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
|
||||
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
|
||||
# python3 + build-essential fallback compiled it against whatever Node headers
|
||||
# the base image had that day, and 24.19+ headers give an addon that aborts with
|
||||
# "Assertion failed: (env) != nullptr" the first time a statement is collected
|
||||
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
|
||||
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
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 && \
|
||||
chown -R node:node /app
|
||||
|
||||
# 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
|
||||
@@ -123,30 +111,14 @@ ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# OCI metadata: Renovate, Watchtower and the registries read the source label to
|
||||
# link an image update to its release notes (#1498). The release workflows add
|
||||
# the commit and the build time; a local build carries everything else.
|
||||
LABEL org.opencontainers.image.title="TREK" \
|
||||
org.opencontainers.image.description="Self-hosted collaborative travel planner" \
|
||||
org.opencontainers.image.url="https://github.com/liketrek/TREK" \
|
||||
org.opencontainers.image.source="https://github.com/liketrek/TREK" \
|
||||
org.opencontainers.image.documentation="https://github.com/liketrek/TREK/wiki" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-only" \
|
||||
org.opencontainers.image.version="${APP_VERSION}"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost: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
|
||||
|
||||
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"]
|
||||
|
||||
@@ -168,9 +168,9 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, 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
|
||||
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
|
||||
</td>
|
||||
|
||||
@@ -49,9 +49,7 @@ See `values.yaml` for more options.
|
||||
- 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.3
|
||||
version: 4.2.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.3"
|
||||
appVersion: "4.2.0"
|
||||
|
||||
@@ -2,11 +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` and
|
||||
`VAPID_PRIVATE_KEY`. 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.
|
||||
|
||||
@@ -43,9 +43,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 }}
|
||||
@@ -64,21 +61,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 }}
|
||||
@@ -88,30 +73,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 }}
|
||||
|
||||
@@ -75,30 +75,6 @@ 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
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -20,18 +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 }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -46,32 +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 }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -54,9 +54,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: ""
|
||||
@@ -71,58 +68,22 @@ env:
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
# Value of the OIDC claim that grants admin role.
|
||||
# OIDC_USERNAME_CLAIM: ""
|
||||
# Claim a new account's username comes from, e.g. "preferred_username". Unset: name, then preferred_username.
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# HTTP_PROXY: ""
|
||||
# HTTPS_PROXY: ""
|
||||
# NO_PROXY: "localhost,127.0.0.1"
|
||||
# Outbound proxy for TREK's requests to the internet; NO_PROXY lists hosts that bypass it.
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "300"
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
# NOMINATIM_URL: ""
|
||||
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
|
||||
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
|
||||
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
|
||||
# way. Defaults to https://nominatim.openstreetmap.org.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||
# OVERPASS_TIMEOUT_MS: "25000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
# TREK_PLACES_URL: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
|
||||
# VAPID_PUBLIC_KEY: ""
|
||||
# Web Push key pair, public half (base64url, 65 bytes). Leave both halves empty and TREK generates a pair on
|
||||
# first start and keeps it in the database, so it travels with backups. To use your own pair, set this and
|
||||
# secretEnv.VAPID_PRIVATE_KEY together, e.g. from `npx web-push generate-vapid-keys`. Changing the pair later
|
||||
# means every device has to turn push on again.
|
||||
# VAPID_SUBJECT: ""
|
||||
# The contact push services see with every Web Push message: a mailto: address or an https:// URL. Defaults to
|
||||
# APP_URL when it is an https:// address other than localhost, else https://github.com/liketrek/TREK. No email
|
||||
# address is sent unless you set one here.
|
||||
# OVERPASS_TIMEOUT_MS: "12000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -148,35 +109,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
|
||||
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. 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
|
||||
|
||||
|
||||
@@ -6,4 +6,3 @@ playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
e2e/help/media.env
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -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` |
|
||||
| No videos | | stills only, by decision |
|
||||
|
||||
The app's own appearance is the default theme, English, light mode, the seeded
|
||||
demo data (`e2e/screenshots/seed.ts`) plus the fixtures in `fixtures.ts`. Do not
|
||||
change the theme, the language or the zoom for a picture.
|
||||
|
||||
## How a screen gets its pictures
|
||||
|
||||
1. Register the screen and its guides in `src/help/contexts/<screen>.ts` and
|
||||
`src/help/registry.ts`; write the English texts as a block in
|
||||
`shared/src/i18n/en/help.ts` (`// ── Screen: <id>`). Guide ids are global: one id,
|
||||
one picture directory, so never reuse an id from another screen
|
||||
(`registry.test.ts` refuses duplicates).
|
||||
2. Write `e2e/help/<screen>.guide.ts`: one `GuideScript` per guide, exactly one
|
||||
`StepAction` per registered step, `start` that opens the screen, `cleanup` that
|
||||
puts the seed back. A step's `target` is the element the text tells the reader
|
||||
to use; `prepare` gets there, `act` does what the text says.
|
||||
3. Run `npm run help:media -- <screen>.guide.ts` (own ports 5183/3011, fresh
|
||||
database, the seed, then the file). Fix until every guide passes; a failing
|
||||
step is a text that no longer matches the UI.
|
||||
4. Look at the pictures in `e2e/.tmp/help-media/`, then `node e2e/help/promote.mjs`.
|
||||
5. Translate the block into every other locale (`i18n:parity:strict` is the gate) and
|
||||
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
|
||||
|
||||
## What the fixtures may look like
|
||||
|
||||
The documents and pictures the guides upload are drawn in `fixtures.ts`, because
|
||||
no real person's paperwork and no photograph may live in this repository. They
|
||||
still have to look like something a traveller would really put into a trip, or
|
||||
the picture teaches the reader that the feature is a toy.
|
||||
|
||||
- **A document is a document.** `drawPdf` takes an issuer band, a title, a
|
||||
reference line, a table of labelled fields and a footer, which is the shape of
|
||||
every confirmation, ticket and voucher. Four lines of Helvetica on white is not
|
||||
a document; it was the first attempt and it was thrown out.
|
||||
- **A picture is a graphic, not a painting of a photograph.** A hand-drawn
|
||||
market or landscape reads as clip art at any size, however it is shaded, and it
|
||||
was tried three times. What works is the kind of image people actually upload:
|
||||
a map screenshot with a pin and a search field, a booking confirmation in a
|
||||
browser window, a printed network map. Those are drawings in reality too, so a
|
||||
drawing of them is honest.
|
||||
- **Every date comes from `e2e/dates.ts`**, never from a calendar typed into a
|
||||
fixture. A confirmation whose check-in is two days before the trip's last day
|
||||
keeps making sense next month.
|
||||
- The names of the files are part of the seed: guides find their row by name, so
|
||||
renaming a fixture means renaming it in the guide too.
|
||||
|
||||
## Changing the style
|
||||
|
||||
`guide.ts` is shared by every picture, so anything changed there is a property of
|
||||
the whole library, not of the guide in front of you. Before changing it, work out
|
||||
which pictures actually come out different and re-render exactly those, one
|
||||
`--grep` away:
|
||||
|
||||
```bash
|
||||
npm run help:media -- --grep "pictures: (files-trash|files-preview)$|hero: trip-files"
|
||||
```
|
||||
|
||||
The staging directory answers most of those questions without a browser, because
|
||||
the frame is in the file:
|
||||
|
||||
```bash
|
||||
# which pictures were taken at the frame's maximum, i.e. cut at the sides
|
||||
python -c "import struct,os;[print(r,f,struct.unpack('>II',open(os.path.join(r,f),'rb').read(24)[16:24])) for r,_,fs in os.walk('e2e/.tmp/help-media') for f in fs if f.endswith('.png')]"
|
||||
```
|
||||
|
||||
A change that alters every picture (the frame, the ring's geometry, the viewport)
|
||||
is a full run of about four hours on one worker. It is not a thing to do twice,
|
||||
so look at three or four pictures from a subset run before starting it.
|
||||
|
||||
## Rules learned the hard way
|
||||
|
||||
- Never run `npx prettier` over `client/` sources: `client/.prettierrc` says
|
||||
`semi: true` and the sources are written without semicolons, so it rewrites
|
||||
whole files. Nothing in CI formats the client.
|
||||
- A killed run leaves its server holding 3011 and 5183, and the next run dies
|
||||
with "port is already used". Free them first:
|
||||
`Get-NetTCPConnection -LocalPort 3011 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`.
|
||||
- The browser's clock is pinned to the picture day (`PICTURE_DAY`). Anything in
|
||||
the app or a library that animates from the wall clock therefore never
|
||||
finishes: Leaflet's tile fade is the one that bit, and the fix is in `settle`.
|
||||
A new picture that comes out empty or grey is worth suspecting of the same.
|
||||
- `promote.mjs` converts the whole staging directory, so a guide that was not
|
||||
re-run keeps the picture it had; staging is the source of truth for what gets
|
||||
published, not the last run.
|
||||
- Never edit `shared/` while a media run is going: the watcher rebuilds `dist`
|
||||
mid-run and the pictures show raw i18n keys.
|
||||
- Never `npm run build --workspace=shared` while `npm run dev` is running; restart
|
||||
the dev server if it happened.
|
||||
- Each run boots a fresh server (~2 min); batch fixes, and dump the accessibility
|
||||
tree once (`page.locator('body').ariaSnapshot()`) instead of guessing locators.
|
||||
- Several runs at once, each on its own `E2E_WEB_PORT`/`E2E_API_PORT`, share
|
||||
`server/dist`: build it once and set `E2E_SERVER_PREBUILT=1` on every run, or
|
||||
each run rebuilds it under the servers of the others. Give each run its own
|
||||
`TREK_PLUGINS_DIR` and `TREK_PLUGINS_DATA_DIR` as well (the seed installs a
|
||||
plugin), and its own Vite `cacheDir` when it starts a Vite of its own.
|
||||
- A plugin installed by a run lives in `server/data/plugins/`; the dev server
|
||||
imports it on its next restart. Guides that install one uninstall it in `cleanup`.
|
||||
- The trip's last day is the run day by construction (`e2e/dates.ts`: every
|
||||
seeded date is an offset from `E2E_PICTURE_DAY`, which `run.mjs` fixes to today
|
||||
once for the whole run), so the plan opens on today, the trip's last and empty
|
||||
day; `openTrip` in `trip-shared.ts` selects day 1 and closes its details panel.
|
||||
A guide that has to find a date by its label builds it with the same helpers
|
||||
(`short`, `long`, `pickerLabel`, `dotted`) instead of writing a calendar date.
|
||||
|
||||
## How the panel shows them
|
||||
|
||||
`src/components/Help/HelpGuideView.tsx` and `HelpHome.tsx` hold the other half of
|
||||
the style, and the two have to agree:
|
||||
|
||||
- A step picture goes into a 16:10 box, a hero and a result picture into a 16:9
|
||||
one, and all three with `object-contain`. Never `object-cover`: the pictures
|
||||
are already framed, and covering crops them a second time, which took the
|
||||
sides off every wide one.
|
||||
- Nothing is drawn over a picture in the panel. The step is numbered beside the
|
||||
text and the number is in the picture itself.
|
||||
- A click opens the lightbox, which shows the picture at its own size.
|
||||
|
||||
## Before you promote
|
||||
|
||||
Look at three or four of the new pictures, not the list of passing tests:
|
||||
|
||||
- The ring is in the picture and visible against what is behind it.
|
||||
- The number is in the picture.
|
||||
- What the step's text names is in the frame.
|
||||
- A map shows a map, not a grey rectangle.
|
||||
- An uploaded document looks like a document someone would upload.
|
||||
|
||||
## The guides that show another service
|
||||
|
||||
A few guides picture TREK talking to something outside it: AirTrail (flights
|
||||
into the Transports tab), Dawarich (the recorded route on the map, countries
|
||||
and wishes in the Atlas), a document store (Nextcloud on the Files tab) and the
|
||||
booking extractor (confirmations into Bookings and Transports). Their fixtures
|
||||
in `external.ts` fill each service themselves through its API, so the pictures
|
||||
do not depend on what happens to be in it; they need its address and key from
|
||||
`e2e/help/media.env` (copy `media.env.example`, not committed). Without the
|
||||
file those guides fail loudly at their first step and every other guide runs
|
||||
as before.
|
||||
|
||||
- Every address has to be the machine's LAN address: the server's SSRF guard
|
||||
refuses loopback whatever `ALLOW_INTERNAL_NETWORK` says, and that flag has to
|
||||
be `true` in the same file for a private network to be reachable at all.
|
||||
- AirTrail: any instance with one user and an API key from Settings > Security.
|
||||
The fixture puts four flights into that account.
|
||||
- Dawarich: any 1.15 instance with a user and their API key; add the LAN address
|
||||
to its `APPLICATION_HOSTS`, and give it a reverse geocoder (Nominatim works)
|
||||
or the Atlas dialog finds no countries. The fixture uploads a synthetic
|
||||
recording of the trip and of the year before it (`dawarich-track.ts`).
|
||||
- Nextcloud: an app password. The fixture keeps a folder under `/Reisen` with
|
||||
two documents, and the guide binds the trip to it.
|
||||
- The extractor: `KITINERARY_EXTRACTOR_PATH` must point at a program the
|
||||
server can run with one file argument. On Linux that is the package's binary
|
||||
(`libkitinerary-bin`); on Windows, a small executable that runs it inside the
|
||||
TREK Docker image (`docker run --rm -v <dir>:/in:ro --entrypoint
|
||||
/usr/local/bin/kitinerary-extractor <image> /in/<file>`) and answers
|
||||
`--version` by itself.
|
||||
@@ -1,601 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ensureAdminFixtures, pluginPackage } from './fixtures'
|
||||
import { toggleAddon } from './external'
|
||||
import { adminContext, adminTabContexts, adminGuides } from '../../src/help/contexts/admin'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the admin screens, keyed by the ids in `src/help/contexts/admin.ts`.
|
||||
* One route, one tab per screen. The guides that create something (a user, an
|
||||
* invite link, a backup, an installed plugin) either leave a harmless row behind
|
||||
* or take it away again in `cleanup`, so the tabs after them look like the seed.
|
||||
*/
|
||||
|
||||
const TAB_OF: Record<string, string> = {
|
||||
'admin-users': 'Users',
|
||||
'admin-defaults': 'User Defaults',
|
||||
'admin-config': 'Personalization',
|
||||
'admin-settings': 'Settings',
|
||||
'admin-addons': 'Addons',
|
||||
'admin-plugins': 'Plugins',
|
||||
'admin-storage': 'Storage',
|
||||
'admin-notifications': 'Notifications',
|
||||
'admin-mcp-tokens': 'MCP Access',
|
||||
'admin-github': 'GitHub',
|
||||
'admin-backup': 'Backup',
|
||||
'admin-audit': 'Audit',
|
||||
}
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = adminGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'User Defaults' }) }).first()
|
||||
/** The nearest card around an element: the admin cards are rounded-xl or rounded-2xl boxes, or a <section>. */
|
||||
const cardOf = (el: Locator) => el.locator('xpath=ancestor::*[contains(@class,"rounded-xl") or contains(@class,"rounded-2xl") or self::section][1]')
|
||||
/** An admin card by the text of its h2. */
|
||||
const section = (page: Page, title: string | RegExp) => cardOf(page.locator('h2').filter({ hasText: title }).first())
|
||||
/** A provider block under Settings, a <section> headed by an h3. */
|
||||
const block = (page: Page, title: string) => page.locator('h3').filter({ hasText: new RegExp(`^${title}$`) }).first().locator('xpath=ancestor::section[1]')
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
/** A row of the user table by its username. */
|
||||
const userRow = (page: Page, username: string) => page.locator('table tbody tr').filter({ hasText: username }).first()
|
||||
/** An addon tile by the addon's name. */
|
||||
const tile = (page: Page, name: string) => page.locator('h4').filter({ hasText: new RegExp(`^${name}$`) }).first().locator('xpath=ancestor::article[1]')
|
||||
|
||||
async function openAdmin(page: Page, tab: string): Promise<void> {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
|
||||
// The run's server is in development mode, which adds a developer-only tab
|
||||
// nobody has in production; the pictures show the sidebar as shipped.
|
||||
await sidebar(page).getByRole('button', { name: 'Dev: Notifications' }).evaluateAll(els => {
|
||||
for (const el of els) (el as HTMLElement).style.display = 'none'
|
||||
})
|
||||
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const opener = (contextId: string) => (page: Page) => openAdmin(page, TAB_OF[contextId])
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
// ── Users ────────────────────────────────────────────────────────────────
|
||||
'create-user': {
|
||||
guide: guide('create-user'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, /^Users$/).getByRole('button', { name: 'Create User' }),
|
||||
act: async p => {
|
||||
await section(p, /^Users$/).getByRole('button', { name: 'Create User' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Username'), 'lena')
|
||||
await typeInto(p, modal(p).getByPlaceholder('Email'), 'lena.k@example.com')
|
||||
await typeInto(p, modal(p).getByPlaceholder('Password'), 'Lena12345!')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create User' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create User' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(userRow(p, 'lena')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'edit-user': {
|
||||
guide: guide('edit-user'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => userRow(p, 'mara').getByRole('button', { name: 'Edit User' }),
|
||||
act: async p => {
|
||||
await userRow(p, 'mara').getByRole('button', { name: 'Edit User' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'invite-links': {
|
||||
guide: guide('invite-links'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }),
|
||||
act: async p => {
|
||||
await section(p, 'Invite Links').getByRole('button', { name: 'Create Link' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: '3×', exact: true }).click()
|
||||
await modal(p).getByRole('button', { name: '14d', exact: true }).click()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create & Copy' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Invite Links')),
|
||||
],
|
||||
},
|
||||
'delete-user': {
|
||||
guide: guide('delete-user'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
only(p => userRow(p, 'jonas').getByRole('button', { name: 'Delete user' })),
|
||||
],
|
||||
},
|
||||
permissions: {
|
||||
guide: guide('permissions'),
|
||||
start: opener('admin-users'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
const row = section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..')
|
||||
// The select's trigger is the row's only button; its options are buttons too.
|
||||
await row.locator('button').first().click()
|
||||
await p.getByRole('button', { name: 'Admin only', exact: true }).last().click()
|
||||
await expect(row.getByText('customized')).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'Permission Settings').getByText('Delete trips', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
target: p => section(p, 'Permission Settings').getByRole('button', { name: 'Save', exact: true }),
|
||||
// Not saved: the seed keeps its defaults for the other screens.
|
||||
act: async p => {
|
||||
await section(p, 'Permission Settings').getByRole('button', { name: 'Reset to defaults' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── User Defaults ────────────────────────────────────────────────────────
|
||||
'default-map': {
|
||||
guide: guide('default-map'),
|
||||
start: opener('admin-defaults'),
|
||||
steps: [
|
||||
only(p => section(p, /^Map$/)),
|
||||
{
|
||||
// The reset link only exists next to a field an admin has set.
|
||||
prepare: async p => {
|
||||
await section(p, 'Default User Settings').getByRole('button', { name: 'Dark', exact: true }).click()
|
||||
// The link sits inside the field's label, so its accessible name is the label's, not "reset".
|
||||
await expect(section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'Default User Settings').locator('label').filter({ hasText: 'Color Mode' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await section(p, 'Default User Settings').locator('button').filter({ hasText: /^reset$/ }).first().click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Personalization ──────────────────────────────────────────────────────
|
||||
'packing-templates': {
|
||||
guide: guide('packing-templates'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }),
|
||||
act: async p => {
|
||||
await section(p, 'Packing Templates').getByRole('button', { name: 'New Template' }).click()
|
||||
const input = p.getByPlaceholder('Template name (e.g. Beach Holiday)')
|
||||
await expect(input).toBeVisible()
|
||||
await typeInto(p, input, 'City Break')
|
||||
await beat(p, 300)
|
||||
// Leave the seed as it is: the second icon next to the field discards the draft.
|
||||
await input.locator('xpath=..').locator('button').nth(1).click()
|
||||
await expect(input).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Beach Holiday', exact: true }).click()
|
||||
await expect(p.getByRole('button', { name: 'Add category' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=../..').locator('> div').nth(1),
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Beach Holiday', exact: true }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
categories: {
|
||||
guide: guide('categories'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, /^Categories$/).getByRole('button', { name: 'New Category' }).click()
|
||||
await expect(p.getByPlaceholder('Category name')).toBeVisible()
|
||||
await typeInto(p, p.getByPlaceholder('Category name'), 'Viewpoints')
|
||||
await settle(p)
|
||||
},
|
||||
// The new category opens in a dialog, its name in the dialog's head.
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByPlaceholder('Category name')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"group")][1]'),
|
||||
hover: async p => {
|
||||
await section(p, /^Categories$/).getByText('Restaurant', { exact: true }).first().hover()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'school-holiday-catalog': {
|
||||
guide: guide('school-holiday-catalog'),
|
||||
start: opener('admin-config'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Add country' }).click()
|
||||
await expect(section(p, 'School holidays').getByPlaceholder('US')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => section(p, 'School holidays'),
|
||||
act: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(section(p, 'School holidays').getByPlaceholder('US')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, 'School holidays').getByRole('button', { name: 'Bavaria', exact: true }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await expect(modal(p).getByRole('textbox', { name: 'Holiday name' }).first()).toHaveValue('Autumn break', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
'auth-methods': {
|
||||
guide: guide('auth-methods'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => section(p, 'Authentication Methods').getByText('Password Login', { exact: true }).locator('xpath=../..')),
|
||||
// The SSO rows exist only once OIDC is configured; the run shows where that happens.
|
||||
only(p => section(p, 'Single Sign-On (OIDC)')),
|
||||
only(p => section(p, 'Require two-factor authentication (2FA)')),
|
||||
],
|
||||
},
|
||||
oidc: {
|
||||
guide: guide('oidc'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => section(p, 'Single Sign-On (OIDC)')),
|
||||
only(p => section(p, 'Authentication Methods')),
|
||||
],
|
||||
},
|
||||
'instance-keys': {
|
||||
guide: guide('instance-keys'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => block(p, 'Google Maps API Key')),
|
||||
{
|
||||
prepare: async p => {
|
||||
const details = p.locator('details').filter({ hasText: 'What the key may be used for' })
|
||||
if (!(await details.evaluate(el => (el as HTMLDetailsElement).open))) await details.locator('summary').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.locator('details').filter({ hasText: 'What the key may be used for' }),
|
||||
},
|
||||
only(p => block(p, 'Unsplash API Key')),
|
||||
],
|
||||
},
|
||||
'places-transit': {
|
||||
guide: guide('places-transit'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => block(p, 'Place search provider')),
|
||||
only(p => block(p, 'Transit Provider')),
|
||||
],
|
||||
},
|
||||
'file-types': {
|
||||
guide: guide('file-types'),
|
||||
start: opener('admin-settings'),
|
||||
steps: [
|
||||
only(p => section(p, 'Allowed File Types')),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Addons ───────────────────────────────────────────────────────────────
|
||||
'toggle-addon': {
|
||||
guide: guide('toggle-addon'),
|
||||
start: opener('admin-addons'),
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Atlas'),
|
||||
act: async p => {
|
||||
const toggle = tile(p, 'Atlas').getByRole('button', { name: 'Atlas' })
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false', { timeout: 10_000 })
|
||||
await beat(p, 600)
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => tile(p, 'Lists')),
|
||||
],
|
||||
},
|
||||
'document-providers': {
|
||||
guide: guide('document-providers'),
|
||||
start: opener('admin-addons'),
|
||||
steps: [
|
||||
// The tile with its shelf: Documents is on in the seed, so the five rows are drawn.
|
||||
only(p => tile(p, 'Documents')),
|
||||
{
|
||||
// The row's switch is a ToggleSwitch whose aria-label is the row's title.
|
||||
target: p => tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true }),
|
||||
act: async p => {
|
||||
const toggle = tile(p, 'Documents').getByRole('button', { name: 'Nextcloud', exact: true })
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// Back to the seed, where every store is off; the files run switches this one on for itself.
|
||||
cleanup: p => toggleAddon(p.request, 'nextcloud', false),
|
||||
},
|
||||
|
||||
// ── Plugins ──────────────────────────────────────────────────────────────
|
||||
'install-plugin': {
|
||||
guide: guide('install-plugin'),
|
||||
start: async p => {
|
||||
const upload = await p.request.post('/api/admin/plugins/upload', {
|
||||
multipart: { file: { name: 'trip-doctor.tar.gz', mimeType: 'application/gzip', buffer: pluginPackage() } },
|
||||
})
|
||||
if (!upload.ok()) throw new Error(`could not upload the plugin: ${upload.status()} ${await upload.text()}`)
|
||||
await openAdmin(p, 'Plugins')
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('tab', { name: /Discover/ }).click()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
target: p => section(p, /^Plugins$/),
|
||||
act: async p => {
|
||||
await p.getByRole('tab', { name: /Installed/ }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]'),
|
||||
act: async p => {
|
||||
const row = p.getByTestId('plugin-row-menu-btn-trip-doctor').locator('xpath=ancestor::div[contains(@class,"group") and contains(@class,"px-3.5")][1]')
|
||||
await row.getByRole('button', { name: 'Enable plugin' }).click()
|
||||
await expect(row.getByText('Active', { exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByTestId('plugin-row-menu-btn-trip-doctor').click()
|
||||
await expect(p.getByTestId('plugin-row-menu-trip-doctor')).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByTestId('plugin-row-menu-trip-doctor'),
|
||||
// Allowed hosts only shows for plugins that call out; Trip Doctor does not.
|
||||
hover: async p => { await p.getByTestId('plugin-row-menu-trip-doctor').getByText('View error log').hover() },
|
||||
act: async p => { await p.keyboard.press('Escape') },
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const res = await p.request.post('/api/admin/plugins/trip-doctor/uninstall', { data: { deleteData: true } })
|
||||
if (!res.ok()) throw new Error(`could not uninstall the plugin: ${res.status()} ${await res.text()}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────────
|
||||
'storage-backends': {
|
||||
guide: guide('storage-backends'),
|
||||
start: opener('admin-storage'),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await section(p, /^Backends$/).getByRole('button', { name: 'Add backend' }).click()
|
||||
await expect(modal(p).getByText('Add backend', { exact: true })).toBeVisible()
|
||||
await typeInto(p, modal(p).locator('input').first(), 'archive')
|
||||
await settle(p)
|
||||
},
|
||||
// The new backend opens in a dialog.
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p).getByText('Add backend', { exact: true })).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
only(p => section(p, /^Categories$/)),
|
||||
only(p => section(p, /^Health$/)),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
'channels-instance': {
|
||||
guide: guide('channels-instance'),
|
||||
start: opener('admin-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Email (SMTP)')),
|
||||
only(p => section(p, /^Ntfy$/)),
|
||||
only(p => section(p, 'Trip Reminders')),
|
||||
],
|
||||
},
|
||||
'admin-channels': {
|
||||
guide: guide('admin-channels'),
|
||||
start: opener('admin-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Admin Ntfy')),
|
||||
only(p => section(p, 'Admin Ntfy').getByRole('button', { name: 'Send test ntfy' })),
|
||||
],
|
||||
},
|
||||
|
||||
// ── MCP Access ───────────────────────────────────────────────────────────
|
||||
'mcp-tokens-admin': {
|
||||
guide: guide('mcp-tokens-admin'),
|
||||
start: opener('admin-mcp-tokens'),
|
||||
steps: [
|
||||
only(p => p.locator('h2, h3').filter({ hasText: 'API Tokens' }).first().locator('xpath=ancestor::section[1]')),
|
||||
only(p => p.locator('h2, h3').filter({ hasText: 'OAuth Sessions' }).first().locator('xpath=ancestor::section[1]')),
|
||||
],
|
||||
},
|
||||
|
||||
// ── GitHub ───────────────────────────────────────────────────────────────
|
||||
'release-history': {
|
||||
guide: guide('release-history'),
|
||||
start: opener('admin-github'),
|
||||
steps: [
|
||||
{
|
||||
// The banner exists only while a newer release is out; otherwise the card's head.
|
||||
target: p => {
|
||||
const banner = p.getByText('Update available', { exact: true })
|
||||
return banner.or(section(p, 'Release History').locator('> div').first()).first()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(section(p, 'Release History').getByRole('button', { name: 'Show details' }).first()).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
target: p => section(p, 'Release History'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Backup ───────────────────────────────────────────────────────────────
|
||||
'create-backup': {
|
||||
guide: guide('create-backup'),
|
||||
start: opener('admin-backup'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }),
|
||||
act: async p => {
|
||||
await section(p, 'Data Backup').getByRole('button', { name: 'Create Backup' }).click()
|
||||
await expect(section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first()).toBeVisible({ timeout: 120_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Download' }).first().locator('xpath=ancestor::div[contains(@class,"py-3")][1]')),
|
||||
only(p => section(p, 'Data Backup').getByRole('button', { name: 'Restore' }).first()),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const list = await p.request.get('/api/backup/list')
|
||||
const body = (await list.json()) as { backups?: { filename: string }[] } | { filename: string }[]
|
||||
const backups = Array.isArray(body) ? body : (body.backups ?? [])
|
||||
for (const b of backups) await p.request.delete(`/api/backup/${encodeURIComponent(b.filename)}`)
|
||||
},
|
||||
},
|
||||
'auto-backup': {
|
||||
guide: guide('auto-backup'),
|
||||
start: opener('admin-backup'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).locator('xpath=ancestor::label[1]'),
|
||||
act: async p => {
|
||||
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
|
||||
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Auto-Backup').getByText('Delete old backups after', { exact: true }).locator('xpath=..')),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await section(p, 'Auto-Backup').getByRole('button', { name: /^Enable auto-backup/ }).click()
|
||||
await expect(section(p, 'Auto-Backup').getByText('Interval', { exact: true })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Audit ────────────────────────────────────────────────────────────────
|
||||
'audit-log': {
|
||||
guide: guide('audit-log'),
|
||||
start: opener('admin-audit'),
|
||||
steps: [
|
||||
only(p => cardOf(p.locator('table').first())),
|
||||
only(p => p.getByRole('button', { name: 'Refresh' })),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const ALL_CONTEXTS = [adminContext, ...adminTabContexts]
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureAdminFixtures(request)
|
||||
})
|
||||
|
||||
// The invite guide copies the new link; without the permission the write rejects
|
||||
// and the page logs an unhandled promise, which is noise the pictures do not need.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
})
|
||||
|
||||
test('every registered admin guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
|
||||
})
|
||||
|
||||
test('hero: admin', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, adminContext.id, p => openAdmin(p, 'Users'))
|
||||
})
|
||||
|
||||
for (const ctx of adminTabContexts) {
|
||||
test(`hero: ${ctx.id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, ctx.id, opener(ctx.id))
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { year } from '../dates'
|
||||
import { ensureAtlasFixtures } from './fixtures'
|
||||
import { ensureDawarichConnection, disconnectDawarich } from './external'
|
||||
import { modal } from './trip-shared'
|
||||
import { atlasGuides, atlasContext } from '../../src/help/contexts/atlas'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Atlas guides, keyed by the ids in `src/help/contexts/atlas.ts`.
|
||||
* They run in the order the context lists them and leave their marks on the
|
||||
* map on purpose: Greece is marked and removed again, a region of Austria and
|
||||
* Bavaria stay, Iceland and Petra land on the bucket list. The countries
|
||||
* are drawn on a canvas, so the way in is always the search box: it flies to
|
||||
* the country and opens the same popup a click on the map would.
|
||||
*/
|
||||
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = atlasGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
async function openAtlas(page: Page): Promise<void> {
|
||||
await page.goto('/atlas')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.getByRole('button', { name: 'Stats' })).toBeVisible({ timeout: 20_000 })
|
||||
// The country layer is a canvas; it exists once the borders have arrived.
|
||||
await expect(page.locator('.leaflet-overlay-pane canvas').first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
/** The search box over the map and its dropdown. */
|
||||
const searchInput = (page: Page) => page.getByPlaceholder('Search a country...')
|
||||
const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div[2]')
|
||||
/** A country in the dropdown (its accessible name starts with the flag's alt text). */
|
||||
const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) })
|
||||
/** A geocoded place in the dropdown, under the Places heading. */
|
||||
const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first()
|
||||
/**
|
||||
* The country / region popup: the panel of the shared dialog frame, inside
|
||||
* the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the
|
||||
* two are never open at once.
|
||||
*/
|
||||
const popup = (page: Page) => modal(page).getByRole('dialog')
|
||||
/** The glass panel at the bottom. */
|
||||
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
|
||||
/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */
|
||||
const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first()
|
||||
const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first()
|
||||
/** The detail card the panel grows for a country picked in the search. */
|
||||
const detailCard = (page: Page, country: string) =>
|
||||
panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..')
|
||||
/**
|
||||
* Dawarich's own glass panel, left of the statistics: the one panel with its
|
||||
* name in it. Scoped this way because its two tiles, Wishlist and Countries,
|
||||
* have namesakes in the dialog's tabs once it is open.
|
||||
*/
|
||||
const dawarichPanel = (page: Page) =>
|
||||
page.locator('div.hidden.md\\:flex').filter({ has: page.getByText('Dawarich', { exact: true }) }).first()
|
||||
const tile = (page: Page, label: 'Wishlist' | 'Countries') =>
|
||||
dawarichPanel(page).getByRole('button', { name: label, exact: true })
|
||||
/** The answers in the dialog: every row is one checkbox named after the country or the wish. */
|
||||
const rows = (page: Page) => modal(page).getByRole('checkbox')
|
||||
|
||||
/** The countries the Atlas paints as visited right now, by code. */
|
||||
async function visitedCodes(page: Page): Promise<Set<string>> {
|
||||
const res = await page.request.get('/api/addons/atlas/stats')
|
||||
const { countries } = (await res.json()) as { countries: { code: string; status: string }[] }
|
||||
return new Set(countries.filter(c => c.status === 'visited').map(c => c.code))
|
||||
}
|
||||
/** What was visited before the countries guide confirmed anything, so `cleanup` takes exactly its additions off again. */
|
||||
let visitedBefore = new Set<string>()
|
||||
|
||||
async function searchCountry(page: Page, name: string): Promise<void> {
|
||||
const input = searchInput(page)
|
||||
await input.click()
|
||||
await input.fill('')
|
||||
await typeInto(page, input, name)
|
||||
await expect(countryHit(page, name)).toBeVisible()
|
||||
}
|
||||
|
||||
async function pickCountry(page: Page, name: string): Promise<void> {
|
||||
await countryHit(page, name).click()
|
||||
await page.waitForTimeout(900) // fitBounds animates
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the country popup with the button in its head band. Not with Escape:
|
||||
* after a key press the browser draws its focus ring on the next popup, which
|
||||
* takes the focus as it opens, and that ring would be in the picture.
|
||||
*/
|
||||
async function closePopup(page: Page): Promise<void> {
|
||||
await popup(page).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(popup(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The next unset select of the Add place form in the Bucket List tab: month
|
||||
* and year both read a long dash until picked. The popup's bucket step labels
|
||||
* its two selects instead, so there they are found by Month and Year.
|
||||
*/
|
||||
const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first()
|
||||
|
||||
/** Pick an option in one of the month / year selects. */
|
||||
async function choose(page: Page, trigger: Locator, label: string): Promise<void> {
|
||||
await trigger.click()
|
||||
await page.getByRole('button', { name: label, exact: true }).last().click()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the region path under the middle of the map, so a step can ring it. The
|
||||
* regions are SVG, but no element carries a name; what is at the centre after
|
||||
* flying to a country is what the reader would click first anyway.
|
||||
*/
|
||||
async function tagCentreRegion(page: Page): Promise<void> {
|
||||
// The first region request builds the server's admin-1 index; that can take a while.
|
||||
await expect(page.locator('.leaflet-region-pane path').first()).toBeVisible({ timeout: 90_000 })
|
||||
const map = await page.locator('.leaflet-container').boundingBox()
|
||||
if (!map) throw new Error('no map on screen')
|
||||
const point = { x: map.x + map.width / 2, y: map.y + map.height / 2 }
|
||||
await page.evaluate(({ x, y }) => {
|
||||
document.querySelectorAll('[data-help-target]').forEach(el => el.removeAttribute('data-help-target'))
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!(el instanceof SVGPathElement)) throw new Error(`no region under the map centre, found ${el?.tagName ?? 'nothing'}`)
|
||||
el.setAttribute('data-help-target', '1')
|
||||
}, point)
|
||||
}
|
||||
const centreRegion = (page: Page) => page.locator('path[data-help-target]')
|
||||
|
||||
async function mapCentre(page: Page): Promise<{ x: number; y: number }> {
|
||||
const map = await page.locator('.leaflet-container').boundingBox()
|
||||
if (!map) throw new Error('no map on screen')
|
||||
return { x: map.x + map.width / 2, y: map.y + map.height / 2 }
|
||||
}
|
||||
|
||||
async function hoverMapCentre(page: Page): Promise<void> {
|
||||
const c = await mapCentre(page)
|
||||
await page.mouse.move(c.x, c.y)
|
||||
}
|
||||
|
||||
async function clickMapCentre(page: Page): Promise<void> {
|
||||
const c = await mapCentre(page)
|
||||
await page.mouse.click(c.x, c.y)
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* An invisible box laid over several elements, for a step whose text names
|
||||
* things that have no parent of their own to ring. The runner rings whatever
|
||||
* box the target has, so this gives it one. It takes no pointer events, so the
|
||||
* hover and the click the step makes go to the app underneath, and a step
|
||||
* that uses it needs its own `hover` for the same reason.
|
||||
*/
|
||||
async function spanOver(page: Page, boxes: Box[]): Promise<void> {
|
||||
await page.evaluate(boxes => {
|
||||
document.getElementById('trek-help-span')?.remove()
|
||||
const left = Math.min(...boxes.map(b => b.x))
|
||||
const top = Math.min(...boxes.map(b => b.y))
|
||||
const right = Math.max(...boxes.map(b => b.x + b.width))
|
||||
const bottom = Math.max(...boxes.map(b => b.y + b.height))
|
||||
const el = document.createElement('div')
|
||||
el.id = 'trek-help-span'
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${right - left}px`,
|
||||
height: `${bottom - top}px`,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
document.body.appendChild(el)
|
||||
}, boxes)
|
||||
}
|
||||
const span = (page: Page) => page.locator('#trek-help-span')
|
||||
async function clearSpan(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.getElementById('trek-help-span')?.remove())
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator): Promise<Box> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`no box for ${locator}`)
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* The name the Atlas shows beside the pointer over a region. It is one fixed
|
||||
* div the map moves after the pointer, with no role or name of its own; the
|
||||
* country under the region name is how it is told apart.
|
||||
*/
|
||||
async function regionTooltipBox(page: Page, country: string): Promise<Box> {
|
||||
const box = await page.evaluate(country => {
|
||||
const tip = Array.from(document.querySelectorAll('div')).find(d =>
|
||||
d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country))
|
||||
if (!tip) return null
|
||||
const r = tip.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
||||
}, country)
|
||||
if (!box) throw new Error(`no region tooltip naming ${country} on screen`)
|
||||
return box
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'mark-country': {
|
||||
guide: guide('mark-country'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => { await searchCountry(p, 'Greece') },
|
||||
},
|
||||
{
|
||||
target: p => countryHit(p, 'Greece'),
|
||||
act: async p => {
|
||||
await pickCountry(p, 'Greece')
|
||||
await expect(popup(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'unmark-country': {
|
||||
guide: guide('unmark-country'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Greece')
|
||||
await pickCountry(p, 'Greece')
|
||||
await expect(popup(p).getByText('Remove this country from your visited list?')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: 'Remove', exact: true }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'country-details': {
|
||||
guide: guide('country-details'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => { await searchCountry(p, 'Japan') },
|
||||
},
|
||||
{
|
||||
target: p => countryHit(p, 'Japan'),
|
||||
act: async p => {
|
||||
await pickCountry(p, 'Japan')
|
||||
await expect(detailCard(p, 'Japan')).toBeVisible()
|
||||
// The step promises the card's flag, which is a Twemoji image from a
|
||||
// CDN. Waited for here so the result shows the flag rather than an
|
||||
// empty square; should the CDN be out of reach, the card falls back to
|
||||
// two letters and the image never loads, which fails this step.
|
||||
const flag = detailCard(p, 'Japan').locator('img[alt="JP"]')
|
||||
await expect(flag).toBeVisible({ timeout: 15_000 })
|
||||
await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'planned-countries': {
|
||||
guide: guide('planned-countries'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Show planned countries' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
const toggle = p.getByRole('button', { name: 'Show planned countries' })
|
||||
if ((await toggle.getAttribute('aria-pressed')) !== 'true') await toggle.click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await searchCountry(p, 'Portugal')
|
||||
await pickCountry(p, 'Portugal')
|
||||
await expect(detailCard(p, 'Portugal')).toBeVisible()
|
||||
},
|
||||
target: p => detailCard(p, 'Portugal'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
regions: {
|
||||
guide: guide('regions'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Austria')
|
||||
await pickCountry(p, 'Austria')
|
||||
// An unvisited country opens its popup on arrival; the regions are the point here.
|
||||
await expect(popup(p)).toBeVisible()
|
||||
await closePopup(p)
|
||||
await tagCentreRegion(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The text says hovering names the region. That name floats right of
|
||||
// the pointer and reaches past the region's own box, so a ring round
|
||||
// the region alone ran straight through it. The ring goes round both.
|
||||
prepare: async p => {
|
||||
await hoverMapCentre(p)
|
||||
await settle(p)
|
||||
await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')])
|
||||
},
|
||||
target: span,
|
||||
hover: hoverMapCentre,
|
||||
act: async p => {
|
||||
await clearSpan(p)
|
||||
await clickMapCentre(p)
|
||||
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'search-place': {
|
||||
guide: guide('search-place'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
const input = searchInput(p)
|
||||
await input.click()
|
||||
await typeInto(p, input, 'Munich')
|
||||
await expect(placeHit(p, 'Munich, Bavaria')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => placeHit(p, 'Munich, Bavaria'),
|
||||
act: async p => {
|
||||
await placeHit(p, 'Munich, Bavaria').click()
|
||||
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Mark as visited/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Mark as visited/ }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'bucket-country': {
|
||||
guide: guide('bucket-country'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: searchInput,
|
||||
act: async p => {
|
||||
await searchCountry(p, 'Iceland')
|
||||
await pickCountry(p, 'Iceland')
|
||||
await expect(popup(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => popup(p).getByRole('button', { name: /Add to bucket list/ }),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: /Add to bucket list/ }).click()
|
||||
await expect(popup(p).getByText('When do you plan to visit?')).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await choose(p, popup(p).getByLabel('Month', { exact: true }), 'June')
|
||||
await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1)))
|
||||
},
|
||||
// The whole popup: the step names both selects and the button under them.
|
||||
target: p => popup(p),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click()
|
||||
await expect(popup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
await p.getByRole('button', { name: 'Bucket List' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'bucket-place': {
|
||||
guide: guide('bucket-place'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Bucket List' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Bucket List' }).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add place' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Add place' }).click() },
|
||||
},
|
||||
{
|
||||
// The row, not the field alone: the text has the reader press the
|
||||
// search button beside it as well.
|
||||
target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'),
|
||||
act: async p => {
|
||||
const input = p.getByPlaceholder('Name (country, city, place...)')
|
||||
await typeInto(p, input, 'Petra')
|
||||
await input.locator('xpath=following-sibling::button[1]').click()
|
||||
// The geocoder also knows a Petra in Mallorca and a few streets; the one in Jordan is the wish.
|
||||
const hit = p.locator('body > div.bg-surface-card button').filter({ hasText: 'Jordan' }).first()
|
||||
await expect(hit).toBeVisible({ timeout: 15_000 })
|
||||
await hit.click()
|
||||
await expect(input).toHaveValue(/petra/i)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await choose(p, unsetSelect(panel(p)), 'Sep')
|
||||
await choose(p, unsetSelect(panel(p)), String(year(1)))
|
||||
},
|
||||
// The whole form: framed on the small Add button alone, the picture lost
|
||||
// the name and the month, and looked like Add on an empty form.
|
||||
target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'),
|
||||
hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(panel(p).getByText('Petra').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
stats: {
|
||||
guide: guide('stats'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{ target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() },
|
||||
{
|
||||
// The text covers the continents, the streak and this year's trips.
|
||||
// Those are two groups side by side with no box of their own around
|
||||
// both, so the ring gets one laid over them.
|
||||
prepare: async p => {
|
||||
await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))])
|
||||
},
|
||||
target: span,
|
||||
hover: p => continentStats(p).hover(),
|
||||
act: clearSpan,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'dawarich-countries': {
|
||||
guide: guide('dawarich-countries'),
|
||||
start: async p => {
|
||||
await openAtlas(p)
|
||||
visitedBefore = await visitedCodes(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Countries'),
|
||||
act: async p => {
|
||||
await tile(p, 'Countries').click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Look for countries' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The pitch with its button rather than the button alone: a frame
|
||||
// centred on the button cut off the dialog's head band, and with it the
|
||||
// name of the service this dialog asks.
|
||||
target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Look for countries' }).click()
|
||||
// A year in thirteen 30-day reads, one after another, each of which
|
||||
// Dawarich computes on the spot; the client gives it four minutes.
|
||||
// An empty answer shows a sentence instead of rows and fails here,
|
||||
// which is right: a dialog with nothing to add is no picture.
|
||||
await expect(rows(p).first()).toBeVisible({ timeout: 240_000 })
|
||||
await settle(p)
|
||||
// The flags come from a CDN and land after the rows.
|
||||
await p.waitForTimeout(800)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole dialog: the list step 2 describes (flag, cities, ticked
|
||||
// rows) is only ever pictured here, and a frame centred on the button
|
||||
// cut the rows in half. The button sits in the footer, in the ring too.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click()
|
||||
await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 })
|
||||
// Closed with the button in its head band, not Escape: the focus goes
|
||||
// back to the Countries tile, and after a key press the browser draws
|
||||
// its focus ring there, into the result picture.
|
||||
await modal(p).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// The Atlas re-reads itself after a write; the colours land with the answer.
|
||||
await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size)
|
||||
await settle(p)
|
||||
await p.waitForTimeout(900)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Off the map again, so the next run and the guides after this one start
|
||||
// from the seed. Unmarking tombstones a country; confirming it again from
|
||||
// the dialog lifts that, so a re-run offers the same rows.
|
||||
const after = await visitedCodes(p)
|
||||
for (const code of after) {
|
||||
if (!visitedBefore.has(code)) await p.request.delete(`/api/addons/atlas/country/${code}/mark`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'dawarich-wishes': {
|
||||
guide: guide('dawarich-wishes'),
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{
|
||||
target: p => tile(p, 'Wishlist'),
|
||||
act: async p => {
|
||||
await tile(p, 'Wishlist').click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Check wishlist' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// As in the countries guide: the pitch with its button, so the head band
|
||||
// stays in the frame.
|
||||
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Check wishlist' }).click()
|
||||
// One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra),
|
||||
// fifteen seconds each at worst; New Zealand and Iceland have none and
|
||||
// are counted in the note instead.
|
||||
await expect(rows(p).first()).toBeVisible({ timeout: 120_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole dialog, as in the countries guide: the rows with distance,
|
||||
// stay and day and the note with the 250 m rule are what step 2 quotes.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click()
|
||||
await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 })
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// The result is the list itself: the ticked date under each wish is a
|
||||
// button labelled Undo, there once the list has re-read itself.
|
||||
await p.getByRole('button', { name: 'Bucket List' }).click()
|
||||
await expect(panel(p).getByRole('button', { name: 'Undo' }).first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Every tick that came from a recording goes back, by the route the Undo
|
||||
// button uses, so the wishes are open again for the next run.
|
||||
const res = await p.request.get('/api/addons/atlas/bucket-list')
|
||||
const { items } = (await res.json()) as { items: { id: number; visited_source: string | null }[] }
|
||||
for (const item of items) {
|
||||
if (item.visited_source === 'dawarich') await p.request.delete(`/api/integrations/dawarich/bucket-list/${item.id}/visit`)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureAtlasFixtures(request)
|
||||
// After the Atlas fixtures: the connection adds two wishes with coordinates
|
||||
// (Fushimi Inari Taisha, Wadi Rum) to the list the fixtures create.
|
||||
await ensureDawarichConnection(request)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide: while the addon is on, every Atlas carries the Dawarich
|
||||
// panel left of Stats. This file's pictures show it on purpose; the files
|
||||
// after it do not, so it goes off again here, whatever happened in between.
|
||||
await disconnectDawarich(request)
|
||||
})
|
||||
|
||||
test('every registered atlas guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(atlasContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: atlas', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, atlasContext.id, openAtlas)
|
||||
})
|
||||
|
||||
for (const id of atlasContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
// The Dawarich reads are a year of countries in thirteen pieces and one
|
||||
// look-up per wish: more waiting on another service than four minutes
|
||||
// may hold on a slow instance.
|
||||
if (id.startsWith('dawarich-')) test.slow()
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,790 +0,0 @@
|
||||
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
|
||||
import { ensureExtraTrips } from './fixtures'
|
||||
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Collections guides, keyed by the ids in
|
||||
* `src/help/contexts/collections.ts`. The seed leaves one list, "Kyoto
|
||||
* shortlist", with the Japan trip's three Kyoto places on it; the guides add a
|
||||
* second list, a Kyoto place found by search, three more Kyoto temples taken
|
||||
* from the trip, a label, a member and a file round trip, in the order the
|
||||
* context lists them, and leave the list side of it in place. Whatever they put
|
||||
* into a trip comes out again in that guide's cleanup.
|
||||
*/
|
||||
|
||||
const KYOTO = 'Kyoto shortlist'
|
||||
const LISBON = 'Lisbon coffee'
|
||||
const JAPAN = 'Autumn in Japan'
|
||||
|
||||
/**
|
||||
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
|
||||
* holds Tokyo and the three Kyoto places the list already has, so an import from
|
||||
* it could only add Tokyo to a list called "Kyoto shortlist", and every later
|
||||
* picture would carry that. None of these is on a day, which is also what the
|
||||
* import's third step says starts out selected.
|
||||
*/
|
||||
const KYOTO_EXTRAS = [
|
||||
{ name: 'Kinkaku-ji Temple', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
|
||||
description: 'The Golden Pavilion, mirrored in its pond.' },
|
||||
{ name: 'Tenryu-ji Temple', lat: 35.0158, lng: 135.6737, address: '68 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto',
|
||||
description: 'Zen garden next to the bamboo grove.' },
|
||||
{ name: 'Kiyomizu-dera Temple', lat: 34.9949, lng: 135.785, address: '1-294 Kiyomizu, Higashiyama Ward, Kyoto',
|
||||
description: 'The wooden stage over the maples.' },
|
||||
]
|
||||
/** The two places in Arashiyama, which the labels guide groups under that name. */
|
||||
const ARASHIYAMA_PLACES = ['Arashiyama Bamboo Grove', 'Tenryu-ji Temple']
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = collectionsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const rail = (page: Page) => page.locator('.col-rail')
|
||||
const listRow = (page: Page, name: string) => rail(page).locator('button.col-row-btn').filter({ has: page.locator('.nm').getByText(name, { exact: true }) })
|
||||
const hero = (page: Page) => page.locator('.col-hero')
|
||||
const filterBar = (page: Page) => page.locator('.col-filterbar')
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
/** The rounded panel inside the backdrop, for framing a whole dialog. */
|
||||
const modalCard = (page: Page) => modal(page).locator('> [role="dialog"]').first()
|
||||
/** A trip's tile on the first step of the import from a trip. */
|
||||
const importTrip = (page: Page, title: string) => modal(page).getByRole('button').filter({ hasText: title })
|
||||
/**
|
||||
* The trip tiles on the first step of that import, and the rows of a trip's
|
||||
* places on its second step: each is the dialog's only staggered block while it
|
||||
* is on screen.
|
||||
*/
|
||||
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
|
||||
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
|
||||
const placeRows = (page: Page) => page.locator('.col-lrow')
|
||||
const selectionBar = (page: Page) => page.locator('.col-selbar')
|
||||
/** The detail sheet beside the list. */
|
||||
const detail = (page: Page) => page.locator('.col-detail').first()
|
||||
/** The map beside the list, with its floating controls. */
|
||||
const mapShell = (page: Page) => page.locator('.col-map-shell')
|
||||
/** The map's search pill: the magnifier and the field together. */
|
||||
const mapSearch = (page: Page) => mapShell(page).locator('.col-map-search').first()
|
||||
|
||||
// ── Trip data over the API ────────────────────────────────────────────────────
|
||||
|
||||
async function tripIdOf(page: Page, title: string): Promise<number> {
|
||||
const res = await page.request.get('/api/trips')
|
||||
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
|
||||
const trips = Array.isArray(body) ? body : (body.trips ?? [])
|
||||
const trip = trips.find(t => t.title === title)
|
||||
if (!trip) throw new Error(`trip "${title}" is missing`)
|
||||
return trip.id
|
||||
}
|
||||
|
||||
async function tripPlaces(page: Page, tripId: number): Promise<{ id: number; name: string }[]> {
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
if (!res.ok()) throw new Error(`GET places of trip ${tripId}: ${res.status()}`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
return Array.isArray(body) ? body : (body.places ?? [])
|
||||
}
|
||||
|
||||
async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const res = await page.request.delete(`/api/trips/${tripId}/places/${id}`)
|
||||
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE place ${id} of trip ${tripId}: ${res.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The places the import guide put into the Japan trip, removed again in its cleanup. */
|
||||
let addedToTrip: number[] = []
|
||||
/** The Japan trip's places before the copy guide ran; everything else is the copy's. */
|
||||
let placesBeforeCopy: Set<number> | null = null
|
||||
|
||||
// ── Waiting for the page to look finished ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take focus off whatever holds it. A focused field or a button that got focus
|
||||
* back from a closed dialog draws an outline in the accent, which in a picture
|
||||
* reads as a second step ring.
|
||||
*/
|
||||
async function blurFocus(page: Page): Promise<void> {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.())
|
||||
}
|
||||
|
||||
/**
|
||||
* Let a Leaflet camera move that is under way arrive.
|
||||
*
|
||||
* Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to
|
||||
* the picture day (guide.ts), so a pan started for a picture never gets past its
|
||||
* first frame: the map stays where it was and the place the text says it pans
|
||||
* to is not in view. Moving the fixed time on for a moment lets the running
|
||||
* animation see that its time is up and finish, exactly where it would have
|
||||
* ended; the clock goes back to the picture day straight after.
|
||||
*/
|
||||
async function finishMapMotion(page: Page): Promise<void> {
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
|
||||
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the map has stopped moving and shows its pins in the part of it
|
||||
* that is on screen: every pin, or with `every: false` at least one, for a map
|
||||
* that has just panned to one place and may have left others behind its edge.
|
||||
* Nothing to wait for where the list has no map.
|
||||
*/
|
||||
async function mapReady(page: Page, { every = true } = {}): Promise<void> {
|
||||
if (!(await mapShell(page).count())) return
|
||||
await finishMapMotion(page)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
mapShell(page).evaluate((el, every) => {
|
||||
if (el.querySelector('.leaflet-zoom-anim, .leaflet-pan-anim, .leaflet-cluster-anim')) return 'moving'
|
||||
const map = el.getBoundingClientRect()
|
||||
const box = {
|
||||
left: Math.max(map.left, 0),
|
||||
top: Math.max(map.top, 0),
|
||||
right: Math.min(map.right, window.innerWidth),
|
||||
bottom: Math.min(map.bottom, window.innerHeight),
|
||||
}
|
||||
const icons = Array.from(el.querySelectorAll('.leaflet-marker-icon')).map(i => i.getBoundingClientRect())
|
||||
const inside = icons.filter(r => r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom).length
|
||||
if (!inside) return 'no pins in view'
|
||||
return every && inside < icons.length ? `${icons.length - inside} of ${icons.length} pins out of view` : 'ready'
|
||||
}, every, { timeout: 2_000 }), // short: a map on its way off the page is asked again, not waited for
|
||||
{ message: 'the map shows the pins of the list', timeout: 15_000 },
|
||||
)
|
||||
.toBe('ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame the map on the list that is open.
|
||||
*
|
||||
* The map opens on a camera worked out for the whole window, not for the pane it
|
||||
* sits in (MapView's opening frame), so on this page it starts about one zoom
|
||||
* level too close and every pin is off its edges. It frames the pane properly
|
||||
* whenever the number of places on it changes, so the search pill is used to
|
||||
* empty it and fill it again, which is what a reader who searches and clears the
|
||||
* search sees. The pill ends empty and the page as it was.
|
||||
*/
|
||||
async function frameMap(page: Page): Promise<void> {
|
||||
if (!(await mapShell(page).count())) return
|
||||
const field = mapSearch(page).locator('input')
|
||||
const rows = await placeRows(page).count()
|
||||
await field.fill('zzz')
|
||||
await expect(placeRows(page)).toHaveCount(0)
|
||||
await field.fill('')
|
||||
await expect(placeRows(page)).toHaveCount(rows)
|
||||
await blurFocus(page)
|
||||
await mapReady(page)
|
||||
}
|
||||
|
||||
/** Photo requests of each page still in flight: the lookups and the images they point at. */
|
||||
const photoRequests = new WeakMap<Page, Set<Request>>()
|
||||
|
||||
function trackPhotos(page: Page): void {
|
||||
if (photoRequests.has(page)) return
|
||||
const pending = new Set<Request>()
|
||||
photoRequests.set(page, pending)
|
||||
const isPhoto = (r: Request) => r.url().includes('/api/maps/place-photo/')
|
||||
page.on('request', r => { if (isPhoto(r)) pending.add(r) })
|
||||
page.on('requestfinished', r => pending.delete(r))
|
||||
page.on('requestfailed', r => pending.delete(r))
|
||||
// A cached photo whose file has gone answers with an empty 204, and the avatar
|
||||
// falls back to its pin. Say so in the log, because that is not the app.
|
||||
page.on('response', r => {
|
||||
if (isPhoto(r) && r.url().endsWith('/bytes') && r.status() === 204) console.warn(`[collections] empty place photo: ${r.url()}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the place photos. Each avatar looks its photo up first and loads the
|
||||
* image after that, so the lookups can be quiet for a moment while an image is
|
||||
* still to come; quiet has to hold for a while before it counts. Bounded, so a
|
||||
* lookup that hangs costs time, not the run.
|
||||
*/
|
||||
async function photosReady(page: Page): Promise<void> {
|
||||
const pending = photoRequests.get(page)
|
||||
if (pending) {
|
||||
const until = Date.now() + 30_000
|
||||
let quietSince = 0
|
||||
while (Date.now() < until) {
|
||||
if (pending.size) quietSince = 0
|
||||
else if (!quietSince) quietSince = Date.now()
|
||||
else if (Date.now() - quietSince > 800) break
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
if (pending.size) console.warn(`[collections] place photos still loading: ${[...pending].map(r => r.url()).join(', ')}`)
|
||||
}
|
||||
await page
|
||||
.waitForFunction(() => Array.from(document.querySelectorAll<HTMLImageElement>('.col-lrow img')).every(img => img.complete), undefined, { timeout: 10_000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Map framed, pins drawn, photos in, nothing focused: a page ready for its picture. */
|
||||
async function pageReady(page: Page, { reframe = false, every = true } = {}): Promise<void> {
|
||||
if (reframe) await frameMap(page)
|
||||
else await mapReady(page, { every })
|
||||
await photosReady(page)
|
||||
await blurFocus(page)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openCollections(page: Page): Promise<void> {
|
||||
trackPhotos(page)
|
||||
await page.goto('/collections')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(rail(page)).toBeVisible({ timeout: 20_000 })
|
||||
await expect(listRow(page, KYOTO)).toBeVisible({ timeout: 20_000 })
|
||||
// All saved, which this lands on, always has places, and the map comes with them.
|
||||
await expect(placeRows(page).first()).toBeVisible({ timeout: 20_000 })
|
||||
await pageReady(page, { reframe: true })
|
||||
}
|
||||
|
||||
async function openList(page: Page, name: string): Promise<void> {
|
||||
await openCollections(page)
|
||||
await listRow(page, name).click()
|
||||
await expect(hero(page)).toContainText(name)
|
||||
// Until its places are in, the page can still show the map of the list before.
|
||||
await settle(page)
|
||||
await expect(page.locator('.col-loading')).toHaveCount(0)
|
||||
await pageReady(page, { reframe: true })
|
||||
}
|
||||
|
||||
/** Turn on select mode and tick the named places. */
|
||||
async function selectPlaces(page: Page, names: string[]): Promise<void> {
|
||||
await filterBar(page).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(page)).toBeVisible()
|
||||
for (const name of names) await placeRow(page, name).click()
|
||||
await expect(selectionBar(page)).toContainText(`${names.length} selected`)
|
||||
}
|
||||
|
||||
/** Pick an option in a CustomSelect that has a search box. */
|
||||
async function pickSearchable(page: Page, trigger: Locator, text: string): Promise<void> {
|
||||
await trigger.click()
|
||||
const search = page.getByPlaceholder('...')
|
||||
await search.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
await typeInto(page, search, text)
|
||||
await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click()
|
||||
}
|
||||
|
||||
/** The place the add guide finds by search: not on the list yet, with a real street address. */
|
||||
const SEARCH_FOR = 'Nijo Castle'
|
||||
const FOUND = /Nij[oō]/
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'create-list': {
|
||||
guide: guide('create-list'),
|
||||
start: openCollections,
|
||||
steps: [
|
||||
{
|
||||
target: p => rail(p).getByRole('button', { name: 'New list' }),
|
||||
act: async p => {
|
||||
await rail(p).getByRole('button', { name: 'New list' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: modalCard,
|
||||
act: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Tokyo 2025'), LISBON)
|
||||
await modal(p).getByRole('button', { name: '#f97316' }).click()
|
||||
await typeInto(p, modal(p).getByPlaceholder('Add a description…'), 'Pastéis, miradouros and the best flat white in Alfama.')
|
||||
await beat(p, 400)
|
||||
// The description keeps focus after typing, and its focus border sits
|
||||
// right next to the ring on Create in the next picture.
|
||||
await blurFocus(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(hero(p)).toContainText(LISBON)
|
||||
// The new list is empty: its two ways to fill it replace the list and the map.
|
||||
await expect(p.locator('.col-cta').first()).toBeVisible()
|
||||
await expect(mapShell(p)).toHaveCount(0)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'add-place': {
|
||||
guide: guide('add-place'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Add a place' }),
|
||||
act: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Add a place' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole pill, magnifier included, not the bare input inside it.
|
||||
target: p => modal(p).getByPlaceholder('Search for a place…').locator('xpath=..'),
|
||||
act: async p => {
|
||||
const search = modal(p).getByPlaceholder('Search for a place…')
|
||||
await typeInto(p, search, SEARCH_FOR)
|
||||
await modal(p).getByRole('button', { name: 'Search', exact: true }).click()
|
||||
// The OpenStreetMap hit, whose address runs from the street to the
|
||||
// country; the index can answer the same name with only a ward.
|
||||
const hit = modal(p).locator('button').filter({ hasText: FOUND }).filter({ hasText: /Kyoto.*Japan/ }).first()
|
||||
await expect(hit).toBeVisible({ timeout: 20_000 })
|
||||
await hit.click()
|
||||
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(FOUND)
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(placeRow(p, 'Nij')).toBeVisible({ timeout: 15_000 })
|
||||
// The dialog stays open for the next place; the guide is done with it.
|
||||
await beat(p, 600)
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'import-from-trip': {
|
||||
guide: guide('import-from-trip'),
|
||||
start: async p => {
|
||||
const tripId = await tripIdOf(p, JAPAN)
|
||||
const have = new Set((await tripPlaces(p, tripId)).map(x => x.name))
|
||||
addedToTrip = []
|
||||
for (const place of KYOTO_EXTRAS) {
|
||||
if (have.has(place.name)) continue
|
||||
const res = await p.request.post(`/api/trips/${tripId}/places`, { data: place })
|
||||
if (!res.ok()) throw new Error(`could not add "${place.name}" to ${JAPAN}: ${res.status()} ${await res.text()}`)
|
||||
const body = (await res.json()) as { place?: { id: number }; id?: number }
|
||||
const id = body.place?.id ?? body.id
|
||||
if (id != null) addedToTrip.push(id)
|
||||
}
|
||||
await openList(p, KYOTO)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Import from a trip' }),
|
||||
act: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Import from a trip' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await expect(importTrip(p, JAPAN)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// All the trip tiles, not the one: a tile in the left column puts the
|
||||
// frame's centre there, and the dialog's right half falls off the picture.
|
||||
target: importStagger,
|
||||
act: async p => {
|
||||
await importTrip(p, JAPAN).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places…')).toBeVisible()
|
||||
await expect(importStagger(p).getByRole('button').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole dialog: the rows with their toolbar are taller than the
|
||||
// smallest frame, so a frame centred on them cuts the head band off.
|
||||
target: modalCard,
|
||||
act: async p => {
|
||||
// Nothing to tick: the three temples no day holds are what the dialog
|
||||
// selects by itself, which is what the step's text says.
|
||||
for (const place of KYOTO_EXTRAS) {
|
||||
await expect(importStagger(p).getByRole('button', { name: new RegExp(place.name) })).toHaveAttribute('aria-pressed', 'true')
|
||||
}
|
||||
await expect(modal(p).getByRole('button', { name: `Import ${KYOTO_EXTRAS.length}` })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /^Import \d+$/ }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Import \d+$/ }).click()
|
||||
await expect(modal(p).getByText('Import finished')).toBeVisible({ timeout: 15_000 })
|
||||
// The head band has a Close of its own; the one meant here ends the dialog in its footer.
|
||||
await modal(p).locator('footer').getByRole('button', { name: 'Close' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
for (const place of KYOTO_EXTRAS) await expect(placeRow(p, place.name)).toBeVisible()
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// The list keeps its copies; the trip goes back to what the seed made of it.
|
||||
await deleteTripPlaces(p, await tripIdOf(p, JAPAN), addedToTrip)
|
||||
addedToTrip = []
|
||||
},
|
||||
},
|
||||
|
||||
'place-status': {
|
||||
guide: guide('place-status'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => placeRows(p).first().getByRole('button', { name: 'Idea' }),
|
||||
act: async p => {
|
||||
await placeRows(p).first().getByRole('button', { name: 'Idea' }).click()
|
||||
await expect(placeRows(p).first().getByRole('button', { name: 'Want to go' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => placeRows(p).first().getByRole('button', { name: 'Want to go' }),
|
||||
act: async p => {
|
||||
await placeRows(p).first().getByRole('button', { name: 'Want to go' }).click()
|
||||
await expect(placeRows(p).first().getByRole('button', { name: 'Visited' })).toBeVisible()
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'place-detail': {
|
||||
guide: guide('place-detail'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => placeRows(p).nth(1),
|
||||
act: async p => {
|
||||
await placeRows(p).nth(1).click()
|
||||
await expect(detail(p)).toBeVisible()
|
||||
// The map pans to the place; let that pan arrive before the next picture.
|
||||
// Places at the far side of the list can end up past the map's edge.
|
||||
await pageReady(p, { every: false })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => detail(p).locator('.col-detail-footer'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
labels: {
|
||||
guide: guide('labels'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^(Add label|Manage labels)$/ }).first().click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByPlaceholder('e.g. Berlin').locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Berlin'), 'Arashiyama')
|
||||
await modal(p).getByRole('button', { name: 'Add label' }).click()
|
||||
await expect(modal(p).locator('input[aria-label="Label name"][value="Arashiyama"]')).toBeVisible()
|
||||
await beat(p, 400)
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// Escape hands focus back to the label control, outlined.
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The two places that are in Arashiyama, so the label means what it says.
|
||||
prepare: p => selectPlaces(p, ARASHIYAMA_PLACES),
|
||||
target: p => selectionBar(p).getByRole('button', { name: 'Assign label' }),
|
||||
act: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Assign label' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await modal(p).getByRole('button', { name: 'Arashiyama' }).click()
|
||||
await modal(p).getByRole('button', { name: 'Assign label' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toHaveCount(0)
|
||||
for (const name of ARASHIYAMA_PLACES) await expect(placeRow(p, name).locator('.col-lrow-label')).toContainText('Arashiyama')
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.col-labelfilter').first(),
|
||||
act: async p => {
|
||||
await p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ }).click()
|
||||
await expect(placeRows(p)).toHaveCount(ARASHIYAMA_PLACES.length)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Leave the list unfiltered for the guides that follow.
|
||||
const chip = p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ })
|
||||
if ((await chip.count()) && (await chip.getAttribute('aria-pressed')) === 'true') await chip.click()
|
||||
},
|
||||
},
|
||||
|
||||
'filter-select': {
|
||||
guide: guide('filter-select'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await filterBar(p).locator('button[aria-haspopup="listbox"]').first().click()
|
||||
await expect(filterBar(p).locator('[role="listbox"]')).toBeVisible()
|
||||
},
|
||||
target: p => filterBar(p).locator('button[aria-haspopup="listbox"]').first(),
|
||||
act: async p => {
|
||||
await filterBar(p).locator('[role="option"]').first().click()
|
||||
await expect(filterBar(p).locator('[role="listbox"]')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
|
||||
act: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Select all' }).click()
|
||||
await blurFocus(p)
|
||||
},
|
||||
target: selectionBar,
|
||||
act: pageReady,
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
|
||||
'copy-to-trip': {
|
||||
guide: guide('copy-to-trip'),
|
||||
start: async p => {
|
||||
placesBeforeCopy = new Set((await tripPlaces(p, await tripIdOf(p, JAPAN))).map(x => x.id))
|
||||
await openList(p, KYOTO)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
|
||||
act: async p => {
|
||||
// Two places the Japan trip does not have yet: the one found by search
|
||||
// and a temple whose trip copy the import guide took out again.
|
||||
await selectPlaces(p, ['Nij', 'Kinkaku-ji Temple'])
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => selectionBar(p).getByRole('button', { name: 'Copy to trip' }),
|
||||
act: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Copy to trip' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('button').filter({ hasText: JAPAN }),
|
||||
act: async p => {
|
||||
await modal(p).locator('button').filter({ hasText: JAPAN }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
// The copies leave the trip again, so the trip screens keep the seed's trip.
|
||||
if (placesBeforeCopy) {
|
||||
const tripId = await tripIdOf(p, JAPAN)
|
||||
const before = placesBeforeCopy
|
||||
await deleteTripPlaces(p, tripId, (await tripPlaces(p, tripId)).filter(x => !before.has(x.id)).map(x => x.id))
|
||||
placesBeforeCopy = null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'share-list': {
|
||||
guide: guide('share-list'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => hero(p).getByRole('button', { name: 'Share' }),
|
||||
act: async p => {
|
||||
await hero(p).getByRole('button', { name: 'Share' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The invite row: the user picker, the role picker and Send invite. Each
|
||||
// picker sits in a wrapper that sizes it, so the row is three levels up.
|
||||
target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../../..'),
|
||||
act: async p => {
|
||||
await pickSearchable(p, modal(p).getByRole('button', { name: 'Select a user' }), 'mira')
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Send invite' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Send invite' }).click()
|
||||
await expect(modal(p).getByText('pending invite')).toBeVisible({ timeout: 10_000 })
|
||||
await beat(p, 500)
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// Escape gives focus back to Share, and its outline would read as a ring.
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'export-list': {
|
||||
guide: guide('export-list'),
|
||||
start: p => openList(p, KYOTO),
|
||||
steps: [
|
||||
{
|
||||
target: p => hero(p).getByRole('button', { name: 'Export' }),
|
||||
act: async p => {
|
||||
await hero(p).getByRole('button', { name: 'Export' }).click()
|
||||
await expect(p.getByRole('menu')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('menu'),
|
||||
act: async p => {
|
||||
const download = p.waitForEvent('download')
|
||||
await p.getByRole('menuitem', { name: /TREK list/ }).click()
|
||||
const file = await download
|
||||
exportedFile = path.join(OUT_DIR, 'kyoto.trekcollection.json')
|
||||
await file.saveAs(exportedFile)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'import-file': {
|
||||
guide: guide('import-file'),
|
||||
start: openCollections,
|
||||
steps: [
|
||||
{
|
||||
target: p => rail(p).getByRole('button', { name: 'Import a list from a file' }),
|
||||
act: async p => {
|
||||
await rail(p).getByRole('button', { name: 'Import a list from a file' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Choose a list file' }),
|
||||
act: async p => {
|
||||
if (!exportedFile) throw new Error('the export guide did not leave a file to import')
|
||||
await modal(p).locator('input[type="file"]').setInputFiles(exportedFile)
|
||||
await expect(modal(p).getByRole('button', { name: /^Import$/ })).toBeEnabled({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'New list' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
const name = modal(p).locator('input:not([type="file"])').last()
|
||||
await name.fill('')
|
||||
await typeInto(p, name, 'Kyoto shortlist (from file)')
|
||||
await modal(p).getByRole('button', { name: /^Import$/ }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(hero(p)).toContainText('Kyoto shortlist (from file)')
|
||||
// A new list mounts a new map, which opens on the window's camera.
|
||||
await pageReady(p, { reframe: true })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'edit-list': {
|
||||
guide: guide('edit-list'),
|
||||
start: p => openList(p, LISBON),
|
||||
steps: [
|
||||
{
|
||||
target: p => hero(p).getByRole('button', { name: 'Edit' }),
|
||||
act: async p => {
|
||||
await hero(p).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: modalCard,
|
||||
act: async p => {
|
||||
const desc = modal(p).getByPlaceholder('Add a description…')
|
||||
await desc.fill('')
|
||||
await typeInto(p, desc, 'Coffee first, then the miradouros. For the long weekend in Lisbon.')
|
||||
await modal(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'all-saved': {
|
||||
guide: guide('all-saved'),
|
||||
start: openCollections,
|
||||
steps: [
|
||||
{
|
||||
target: p => rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }),
|
||||
act: async p => {
|
||||
await rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }).click()
|
||||
await expect(hero(p)).toContainText('All saved')
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole pill, magnifier included, not the bare input inside it.
|
||||
target: mapSearch,
|
||||
act: async p => {
|
||||
await typeInto(p, mapSearch(p).locator('input'), 'temple')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** The list file the export guide downloads and the import guide reads back. */
|
||||
let exportedFile: string | null = null
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureExtraTrips(request)
|
||||
})
|
||||
|
||||
test('every registered collections guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(collectionsContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: collections', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, collectionsContext.id, p => openList(p, KYOTO))
|
||||
})
|
||||
|
||||
for (const id of collectionsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { monthName, monthsAhead, pickerLabelOn } from '../dates'
|
||||
import { ensureExtraTrips, coverFixture, card, EXTRA_TRIPS } from './fixtures'
|
||||
import { dashboardGuides, dashboardContext } from '../../src/help/contexts/dashboard'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the dashboard guides. Each script is keyed by the guide id from
|
||||
* `src/help/contexts/dashboard.ts` and has exactly one action per registered
|
||||
* step; the runner refuses a mismatch, so a step added to the registry without
|
||||
* an action here fails this run instead of shipping a raw key.
|
||||
*/
|
||||
|
||||
const LISBON = EXTRA_TRIPS[0].title
|
||||
const NORWAY = EXTRA_TRIPS[1].title
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = dashboardGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
async function openDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.locator('.hero-trip')).toBeVisible()
|
||||
await expect(card(page, LISBON)).toBeVisible()
|
||||
}
|
||||
|
||||
|
||||
/** The trip form, scoped so the cover-search inputs above the title cannot be matched by accident. */
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop')
|
||||
/** The topmost dialog of any kind (confirm, copy, calendar): they all share the backdrop class. */
|
||||
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
|
||||
const titleField = (page: Page) => modal(page).getByPlaceholder('e.g. Summer in Japan')
|
||||
/**
|
||||
* A date picker's trigger by the label above it. The trigger names itself after
|
||||
* its value once one is set (and the end date is filled in the moment the start
|
||||
* is picked), so "End Date" as a button name only works while it is empty.
|
||||
*/
|
||||
const datePicker = (page: Page, label: string) =>
|
||||
modal(page).getByText(label, { exact: true }).locator('xpath=..').getByRole('button').first()
|
||||
|
||||
/** Pick a date in the calendar popup: open it, go to the month, click the day. */
|
||||
async function pickDate(page: Page, trigger: ReturnType<Page['getByRole']>, monthsAhead: number, dayLabel: string): Promise<void> {
|
||||
await trigger.click()
|
||||
for (let i = 0; i < monthsAhead; i++) {
|
||||
await page.getByRole('button', { name: 'Next month' }).click()
|
||||
await beat(page, 350)
|
||||
}
|
||||
await page.getByRole('button', { name: dayLabel, exact: true }).click()
|
||||
}
|
||||
|
||||
/** Hover-revealed action on a card. */
|
||||
const cardAction = (page: Page, title: string, name: string) => card(page, title).locator(`.trip-action-btn[aria-label="${name}"]`)
|
||||
|
||||
/** The trip the create guide makes, eight months out, named after the month it lands in. */
|
||||
const NEW_TRIP = `Amalfi Coast in ${monthName(8)}`
|
||||
|
||||
/** The trip search above the cards (#2190), by its accessible name. */
|
||||
const tripSearch = (page: Page) => page.getByRole('searchbox', { name: 'Search all trips by title, date or place' })
|
||||
const JAPAN = 'Autumn in Japan'
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'search-trips': {
|
||||
guide: guide('search-trips'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => tripSearch(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await typeInto(p, tripSearch(p), 'Nishiki')
|
||||
await expect(card(p, JAPAN)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(card(p, JAPAN).getByText('Nishiki Market')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => card(p, JAPAN),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Clear search' }).click()
|
||||
await expect(card(p, LISBON)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
if (await tripSearch(p).count()) await tripSearch(p).fill('')
|
||||
},
|
||||
},
|
||||
'create-trip': {
|
||||
guide: guide('create-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.add-trip-card'),
|
||||
act: async p => { await p.locator('.add-trip-card').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => titleField(p),
|
||||
act: async p => { await typeInto(p, titleField(p), NEW_TRIP) },
|
||||
},
|
||||
{
|
||||
target: p => datePicker(p, 'Start Date'),
|
||||
act: async p => {
|
||||
await pickDate(p, datePicker(p, 'Start Date'), 8, pickerLabelOn(monthsAhead(8, 8)))
|
||||
await beat(p, 500)
|
||||
// The end date follows the start date, so its calendar already opens on May.
|
||||
await pickDate(p, datePicker(p, 'End Date'), 0, pickerLabelOn(monthsAhead(8, 15)))
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create New Trip' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create New Trip' }).click()
|
||||
await expect(card(p, NEW_TRIP)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// The delete guide removes this trip on camera; until then it stays.
|
||||
void p
|
||||
},
|
||||
},
|
||||
|
||||
'edit-trip': {
|
||||
guide: guide('edit-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Edit'),
|
||||
act: async p => { await cardAction(p, NORWAY, 'Edit').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => titleField(p),
|
||||
act: async p => {
|
||||
await titleField(p).fill('')
|
||||
await typeInto(p, titleField(p), 'Norway Road Trip')
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update' }).click()
|
||||
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'cover-image': {
|
||||
guide: guide('cover-image'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, LISBON, 'Edit'),
|
||||
act: async p => { await cardAction(p, LISBON, 'Edit').click(); await expect(modal(p)).toBeVisible() },
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /Add cover image/ }),
|
||||
act: async p => {
|
||||
await modal(p).locator('input[type="file"]').setInputFiles(await coverFixture())
|
||||
await expect(modal(p).getByRole('button', { name: 'Change' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await settle(p) },
|
||||
target: p => modal(p).getByRole('button', { name: 'Update' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update' }).click()
|
||||
await expect(modal(p)).toBeHidden({ timeout: 15_000 })
|
||||
await expect(card(p, LISBON).locator('.trip-cover img')).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'duplicate-trip': {
|
||||
guide: guide('duplicate-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, LISBON, 'Duplicate'),
|
||||
act: async p => { await cardAction(p, LISBON, 'Duplicate').click() },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Copy trip' }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Copy trip' }).click()
|
||||
await expect(p.locator('.trip-card').filter({ hasText: LISBON })).toHaveCount(2, { timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Drop the copy over the API so the grid is back to the seed for the next
|
||||
// guide. By title, not by position: the list came back in an order that
|
||||
// left the copy standing, and a trip nobody expected then broke a guide
|
||||
// three files later, in a screen that has nothing to do with this one.
|
||||
const res = await p.request.get('/api/trips')
|
||||
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
|
||||
const trips = Array.isArray(body) ? body : (body.trips ?? [])
|
||||
for (const t of trips.filter(x => x.title !== LISBON && x.title.startsWith(LISBON))) {
|
||||
const dropped = await p.request.delete(`/api/trips/${t.id}`)
|
||||
if (!dropped.ok()) throw new Error(`could not drop the copy "${t.title}": ${dropped.status()}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'archive-trip': {
|
||||
guide: guide('archive-trip'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Archive'),
|
||||
act: async p => {
|
||||
await cardAction(p, NORWAY, 'Archive').click()
|
||||
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.seg').getByRole('button', { name: 'Archived' }),
|
||||
act: async p => {
|
||||
await p.locator('.seg').getByRole('button', { name: 'Archived' }).click()
|
||||
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => cardAction(p, NORWAY, 'Restore'),
|
||||
act: async p => {
|
||||
await cardAction(p, NORWAY, 'Restore').click()
|
||||
await expect(card(p, NORWAY)).toBeHidden({ timeout: 15_000 })
|
||||
await beat(p, 400)
|
||||
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
|
||||
await expect(card(p, NORWAY)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'delete-trip': {
|
||||
guide: guide('delete-trip'),
|
||||
start: async p => {
|
||||
await openDashboard(p)
|
||||
await expect(card(p, NEW_TRIP)).toBeVisible()
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, NEW_TRIP, 'Delete'),
|
||||
act: async p => { await cardAction(p, NEW_TRIP, 'Delete').click() },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(card(p, NEW_TRIP)).toBeHidden({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'filter-and-view': {
|
||||
guide: guide('filter-and-view'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.seg'),
|
||||
act: async p => {
|
||||
await p.locator('.seg').getByRole('button', { name: 'Completed' }).click()
|
||||
await beat(p, 900)
|
||||
await p.locator('.seg').getByRole('button', { name: 'Planned' }).click()
|
||||
await expect(card(p, LISBON)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Toggle view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Toggle view' }).click()
|
||||
await expect(p.locator('.trips.list-view')).toBeVisible()
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.getByRole('button', { name: 'Toggle view' }).click() },
|
||||
},
|
||||
|
||||
'calendar-feed': {
|
||||
guide: guide('calendar-feed'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Subscribe to all trips calendar' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Subscribe to all trips calendar' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Enable calendar subscription' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Enable calendar subscription' }).click()
|
||||
await expect(p.getByText(/Add to Google Calendar/i)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
// The subscription dialog sits in the shared dialog frame.
|
||||
target: p => p.locator('.trek-modal-backdrop').last().locator('> [role="presentation"], > div').first(),
|
||||
},
|
||||
],
|
||||
cleanup: async p => { await p.keyboard.press('Escape') },
|
||||
},
|
||||
|
||||
widgets: {
|
||||
guide: guide('widgets'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.locator('nav button').filter({ has: p.locator('svg.lucide-chevron-down') }).first().click()
|
||||
await beat(p, 500)
|
||||
},
|
||||
target: p => p.getByRole('link', { name: 'Settings' }),
|
||||
act: async p => {
|
||||
await p.getByRole('link', { name: 'Settings' }).click()
|
||||
await expect(p).toHaveURL(/\/settings/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Appearance', exact: true }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Appearance', exact: true }).first().click()
|
||||
await expect(p.getByText('Dashboard widgets', { exact: true }).first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByText('Dashboard widgets', { exact: true }).first().scrollIntoViewIfNeeded()
|
||||
await beat(p, 400)
|
||||
},
|
||||
// The Section card itself: the nearest rounded container around its heading.
|
||||
target: p => p.getByText('Dashboard widgets', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('link', { name: 'My Trips' }),
|
||||
act: async p => {
|
||||
await p.getByRole('link', { name: 'My Trips' }).click()
|
||||
await expect(p.locator('.hero-trip')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'currency-widget': {
|
||||
guide: guide('currency-widget'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.locator('.tool').filter({ hasText: 'Currency' }).locator('.fx-input'),
|
||||
act: async p => {
|
||||
const amount = p.locator('.tool').filter({ hasText: 'Currency' }).locator('input.amt').first()
|
||||
await amount.fill('')
|
||||
await typeInto(p, amount, '250')
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Swap currencies' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Swap currencies' }).click() },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'timezones-widget': {
|
||||
guide: guide('timezones-widget'),
|
||||
start: openDashboard,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add timezone' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add timezone' }).click()
|
||||
await beat(p, 600)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Close the picker again so the row's remove button is what the picture shows.
|
||||
await p.getByRole('button', { name: 'Add timezone' }).click()
|
||||
await p.locator('.tz-row').first().hover()
|
||||
},
|
||||
target: p => p.locator('.tz-row').first().locator('.tz-del'),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureExtraTrips(request)
|
||||
})
|
||||
|
||||
test('every registered dashboard guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(dashboardContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: the dashboard', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, dashboardContext.id, openDashboard)
|
||||
})
|
||||
|
||||
for (const id of dashboardContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -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,464 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { PICTURE_DAY_ISO } from '../dates'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Runner for the help-center media (`npm run help:media`).
|
||||
*
|
||||
* A guide is defined once, in `src/help/contexts/<screen>.ts`; the file next to
|
||||
* this one gives every step of it an action against the real app. From that
|
||||
* single script this runner produces everything the panel shows:
|
||||
*
|
||||
* - one framed picture per step, with the element the reader is told to use
|
||||
* ringed and numbered (`step-<n>.png`),
|
||||
* - the state after the last step (`result.png`).
|
||||
*
|
||||
* And because every step has to succeed for the picture to exist, the same run
|
||||
* is the test that the guide still matches the UI.
|
||||
*
|
||||
* Output lands in a staging directory; `promote.mjs` converts and moves it into
|
||||
* `public/help-media/`, so a bad run never clobbers good artwork.
|
||||
*/
|
||||
|
||||
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
|
||||
/**
|
||||
* A common desktop. Smaller frames squeeze the trip cards until their badges
|
||||
* overlap the action buttons, which is not what a reader on a normal screen
|
||||
* sees; the wiki shots use 1440×900 for the docs' narrower column.
|
||||
*/
|
||||
export const VIEWPORT = { width: 1920, height: 1080 }
|
||||
|
||||
/**
|
||||
* The instant every picture is taken at: nine in the morning, UTC, on the
|
||||
* picture day (`e2e/dates.ts`), which is the seeded trip's last day.
|
||||
*
|
||||
* A picture has to look the same whoever runs this and whenever, and the seed
|
||||
* is relative to that day, so the browser's clock is pinned to it: were it
|
||||
* left on the wall clock, a run late in the evening would see the trip's last
|
||||
* stops in the past, and What's Next would have nothing left to list. Only
|
||||
* the browser's `Date` is pinned, so timers keep running and nothing that
|
||||
* waits stops waiting; the server keeps its own clock, which none of the
|
||||
* pictures read.
|
||||
*/
|
||||
export const PICTURE_DAY = new Date(`${PICTURE_DAY_ISO}T09:00:00.000Z`)
|
||||
|
||||
/** Put the page on the day the pictures are taken on. Call before navigating. */
|
||||
async function pinClock(page: Page): Promise<void> {
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
}
|
||||
|
||||
export interface StepAction {
|
||||
/** Bring the screen to where the step starts (runs after the previous step's `act`). */
|
||||
prepare?: (page: Page) => Promise<void>
|
||||
/** The element the reader is told to use; ringed and framed in the picture. */
|
||||
target?: (page: Page) => Locator
|
||||
/**
|
||||
* Where to put the pointer for the picture when the target's centre is not on
|
||||
* it (an SVG region whose bounding-box centre lies in a neighbour). Default:
|
||||
* hover the target.
|
||||
*/
|
||||
hover?: (page: Page) => Promise<void>
|
||||
/**
|
||||
* A drag: the target is what the reader picks up, this is where it goes.
|
||||
* Both are ringed and an arrow joins them, so one still shows the gesture.
|
||||
*/
|
||||
dropTo?: (page: Page) => Locator
|
||||
/** What the step does. */
|
||||
act?: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
export interface GuideScript {
|
||||
guide: HelpGuide
|
||||
/** Where the guide starts: navigate, dismiss notices, wait for data. */
|
||||
start: (page: Page) => Promise<void>
|
||||
/** Exactly one entry per registered step. */
|
||||
steps: StepAction[]
|
||||
/** Undo what the guide changed, so the next one starts from the seed again. */
|
||||
cleanup?: (page: Page) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 'still' takes the pictures at full speed. The mode stays as a switch so a
|
||||
* recorder that plays the same scripts at a watchable pace can be added
|
||||
* without touching them; nothing sets 'paced' today.
|
||||
*/
|
||||
let mode: 'still' | 'paced' = 'still'
|
||||
|
||||
/** A pause a person would make; nothing in still mode. */
|
||||
export async function beat(page: Page, ms = 700): Promise<void> {
|
||||
if (mode === 'paced') await page.waitForTimeout(ms)
|
||||
}
|
||||
|
||||
/** Fill instantly for the pictures; type like a person when paced. */
|
||||
export async function typeInto(page: Page, locator: Locator, text: string): Promise<void> {
|
||||
if (mode === 'paced') {
|
||||
await locator.click()
|
||||
await locator.pressSequentially(text, { delay: 55 })
|
||||
} else {
|
||||
await locator.fill(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stills ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Run a guide and take its pictures. Throws (fails the test) when a step cannot be performed. */
|
||||
export async function captureGuide(page: Page, script: GuideScript): Promise<void> {
|
||||
mode = 'still'
|
||||
await pinClock(page)
|
||||
const { guide } = script
|
||||
if (script.steps.length !== guide.steps) {
|
||||
throw new Error(`guide "${guide.id}" registers ${guide.steps} steps but the script has ${script.steps.length}`)
|
||||
}
|
||||
const dir = path.join(OUT_DIR, guide.id)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
await script.start(page)
|
||||
await settle(page)
|
||||
|
||||
for (let i = 0; i < script.steps.length; i++) {
|
||||
const n = i + 1
|
||||
const step = script.steps[i]
|
||||
await step.prepare?.(page)
|
||||
if (step.target && guide.media.steps) {
|
||||
const target = step.target(page)
|
||||
await expect(target, `guide "${guide.id}" step ${n}: target`).toBeVisible()
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
let box = await target.boundingBox()
|
||||
if (!box) throw new Error(`guide "${guide.id}" step ${n}: target has no box`)
|
||||
if (box.height > VIEWPORT.height - 2 * PAD) {
|
||||
// A block taller than the frame (a whole settings section): show its top
|
||||
// rather than its middle, which is where the heading is.
|
||||
await target.evaluate(el => el.scrollIntoView({ block: 'start' }))
|
||||
await page.evaluate(() => {
|
||||
const nav = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 64
|
||||
window.scrollBy(0, -nav - 24)
|
||||
})
|
||||
} else {
|
||||
// Playwright scrolls only as far as it must, which leaves a target at the
|
||||
// bottom edge and the frame full of whatever sits above it. Centre it so
|
||||
// the picture shows the element in its surroundings. Works on whichever
|
||||
// ancestor scrolls (the dashboard has its own pane, not the document).
|
||||
await target.evaluate(el => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
||||
}
|
||||
if (step.hover) await step.hover(page)
|
||||
else await target.hover()
|
||||
await settle(page)
|
||||
box = (await target.boundingBox()) ?? box
|
||||
if (step.dropTo) {
|
||||
const dest = step.dropTo(page)
|
||||
await expect(dest, `guide "${guide.id}" step ${n}: drop target`).toBeVisible()
|
||||
const to = await dest.boundingBox()
|
||||
if (!to) throw new Error(`guide "${guide.id}" step ${n}: drop target has no box`)
|
||||
await ringDrag(page, box, to, n)
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frameFor(union(box, to)) })
|
||||
} else {
|
||||
const frame = frameFor(box)
|
||||
await ring(page, box, n, frame)
|
||||
await page.screenshot({ path: path.join(dir, `step-${n}.png`), clip: frame })
|
||||
}
|
||||
await unring(page)
|
||||
}
|
||||
await step.act?.(page)
|
||||
}
|
||||
|
||||
if (guide.media.result) {
|
||||
await settle(page)
|
||||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) // nothing hovered in the after-picture
|
||||
await page.waitForTimeout(250)
|
||||
await page.screenshot({ path: path.join(dir, 'result.png') })
|
||||
}
|
||||
await script.cleanup?.(page)
|
||||
}
|
||||
|
||||
/** The screen overview picture for a context. */
|
||||
export async function captureHero(page: Page, contextId: string, start: (page: Page) => Promise<void>): Promise<void> {
|
||||
mode = 'still'
|
||||
await pinClock(page)
|
||||
const dir = path.join(OUT_DIR, 'ctx')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
await start(page)
|
||||
await settle(page)
|
||||
await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4)
|
||||
await page.waitForTimeout(250)
|
||||
await page.screenshot({ path: path.join(dir, `${contextId}.png`) })
|
||||
}
|
||||
|
||||
// ── Framing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAD = 64
|
||||
const MIN_W = 960
|
||||
const RATIO = 16 / 10
|
||||
|
||||
/**
|
||||
* A 16:10 window around the target: at least MIN_W wide, big enough to hold the
|
||||
* element with breathing room, kept inside the viewport. A target too large for
|
||||
* the largest 16:10 frame that fits gets that frame, anchored at its top, so a
|
||||
* long settings section shows its heading rather than its middle.
|
||||
*/
|
||||
export function frameFor(box: { x: number; y: number; width: number; height: number }) {
|
||||
const { width: vw, height: vh } = VIEWPORT
|
||||
const maxH = Math.min(vh, vw / RATIO)
|
||||
const maxW = maxH * RATIO
|
||||
let w = Math.max(MIN_W, box.width + 2 * PAD)
|
||||
let h = Math.max(w / RATIO, box.height + 2 * PAD)
|
||||
w = Math.max(w, h * RATIO)
|
||||
const tall = h > maxH
|
||||
if (w > maxW || tall) {
|
||||
w = maxW
|
||||
h = maxH
|
||||
}
|
||||
const x = clamp(box.x + box.width / 2 - w / 2, 0, vw - w)
|
||||
const y = tall ? clamp(box.y - PAD / 2, 0, vh - h) : clamp(box.y + box.height / 2 - h / 2, 0, vh - h)
|
||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(w), height: Math.round(h) }
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
|
||||
|
||||
/**
|
||||
* Ring the target, dim everything else, number it.
|
||||
*
|
||||
* The ring is the app's accent, which stands out on nearly every surface the
|
||||
* guides walk over. Over the lightbox and the other near-black overlays it does
|
||||
* not: a dark accent on a dark ground is a ring nobody can see. So the surface
|
||||
* behind the ring is measured, and only when it has too little contrast against
|
||||
* the accent does a white hairline go around the outside of the ring and the
|
||||
* badge. Everywhere else the ring keeps the single line it always had.
|
||||
*/
|
||||
async function ring(
|
||||
page: Page,
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
n: number,
|
||||
frame: { x: number; y: number; width: number; height: number },
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ box, n, frame }) => {
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||||
const pad = 6
|
||||
|
||||
const parse = (value: string): [number, number, number, number] | null => {
|
||||
const text = value.trim()
|
||||
if (text.startsWith('#')) {
|
||||
const hex = text.length < 7 ? text.slice(1, 4).replace(/./g, c => c + c) : text.slice(1, 7)
|
||||
const n = Number.parseInt(hex, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]
|
||||
}
|
||||
const inside = /rgba?\(([^)]+)\)/.exec(text)
|
||||
if (!inside) return null
|
||||
const parts = inside[1]
|
||||
.split(/[\s,/]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]
|
||||
}
|
||||
const luminance = (colour: [number, number, number, number]): number => {
|
||||
const channel = (v: number) => {
|
||||
const c = v / 255
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * channel(colour[0]) + 0.7152 * channel(colour[1]) + 0.0722 * channel(colour[2])
|
||||
}
|
||||
// What lies just outside the ring, through any transparent layer.
|
||||
const behind = (x: number, y: number): [number, number, number, number] => {
|
||||
let node = document.elementFromPoint(
|
||||
Math.max(1, Math.min(window.innerWidth - 2, x)),
|
||||
Math.max(1, Math.min(window.innerHeight - 2, y)),
|
||||
)
|
||||
while (node) {
|
||||
const colour = parse(getComputedStyle(node).backgroundColor)
|
||||
if (colour && colour[3] > 0.2) return colour
|
||||
node = node.parentElement
|
||||
}
|
||||
return [255, 255, 255, 1]
|
||||
}
|
||||
// The dim layer darkens that surface before the ring is drawn over it.
|
||||
const ground = luminance(behind(box.x - pad - 4, box.y + box.height / 2)) * 0.7
|
||||
const ink = luminance(parse(accent) || [17, 24, 39, 1])
|
||||
const hairline = (Math.max(ground, ink) + 0.05) / (Math.min(ground, ink) + 0.05) < 3
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.id = 'trek-help-ring'
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
left: `${box.x - pad}px`,
|
||||
top: `${box.y - pad}px`,
|
||||
width: `${box.width + 2 * pad}px`,
|
||||
height: `${box.height + 2 * pad}px`,
|
||||
borderRadius: '12px',
|
||||
boxShadow: `0 0 0 3px ${accent}${hairline ? ', 0 0 0 5px rgba(255,255,255,0.92)' : ''}, 0 0 0 100vmax rgba(0,0,0,0.30)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: '2147483647',
|
||||
})
|
||||
// Outside the corner where that is in shot, and otherwise inside, far
|
||||
// enough in to clear the frame's edge: a row wider than the picture has
|
||||
// no outside left of it, and the ring's own corner is off the picture too.
|
||||
const ringLeft = box.x - pad
|
||||
const ringTop = box.y - pad
|
||||
const left = ringLeft - 14 >= frame.x + 2 ? -14 : Math.max(10, frame.x + 10 - ringLeft)
|
||||
const top = ringTop - 14 >= frame.y + 2 ? -14 : Math.max(10, frame.y + 10 - ringTop)
|
||||
const badge = document.createElement('div')
|
||||
Object.assign(badge.style, {
|
||||
position: 'absolute',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '999px',
|
||||
background: accent,
|
||||
color: accentText,
|
||||
font: '700 13px/28px Poppins, system-ui, sans-serif',
|
||||
textAlign: 'center',
|
||||
boxShadow: hairline
|
||||
? '0 0 0 2px rgba(255,255,255,0.92), 0 2px 8px rgba(0,0,0,0.25)'
|
||||
: '0 2px 8px rgba(0,0,0,0.25)',
|
||||
})
|
||||
badge.textContent = String(n)
|
||||
el.appendChild(badge)
|
||||
document.body.appendChild(el)
|
||||
},
|
||||
{ box, n, frame },
|
||||
)
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** The smallest box around two. */
|
||||
function union(a: Box, b: Box): Box {
|
||||
const x = Math.min(a.x, b.x)
|
||||
const y = Math.min(a.y, b.y)
|
||||
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }
|
||||
}
|
||||
|
||||
/**
|
||||
* The drag picture: a dim over everything but the two boxes, the dragged one
|
||||
* ringed and numbered, the destination ringed in a dashed line, and an arrow
|
||||
* from the one to the other, bowed a little so it reads as a movement.
|
||||
*/
|
||||
async function ringDrag(page: Page, from: Box, to: Box, n: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ from, to, n }) => {
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#111827'
|
||||
const accentText = getComputedStyle(document.documentElement).getPropertyValue('--accent-text').trim() || '#ffffff'
|
||||
const pad = 6
|
||||
const W = window.innerWidth
|
||||
const H = window.innerHeight
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
const el = (tag: string, attrs: Record<string, string | number>) => {
|
||||
const node = document.createElementNS(ns, tag)
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v))
|
||||
return node
|
||||
}
|
||||
const svg = el('svg', { id: 'trek-help-ring', width: W, height: H, viewBox: `0 0 ${W} ${H}` })
|
||||
Object.assign(svg.style, { position: 'fixed', left: '0', top: '0', pointerEvents: 'none', zIndex: '2147483647' })
|
||||
const grow = (b: typeof from) => ({ x: b.x - pad, y: b.y - pad, w: b.width + 2 * pad, h: b.height + 2 * pad })
|
||||
const a = grow(from)
|
||||
const b = grow(to)
|
||||
const defs = el('defs', {})
|
||||
const mask = el('mask', { id: 'trek-help-mask' })
|
||||
mask.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'white' }))
|
||||
mask.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'black' }))
|
||||
mask.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'black' }))
|
||||
defs.appendChild(mask)
|
||||
const marker = el('marker', { id: 'trek-help-arrow', viewBox: '0 0 10 10', refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' })
|
||||
marker.appendChild(el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: accent }))
|
||||
defs.appendChild(marker)
|
||||
svg.appendChild(defs)
|
||||
svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'rgba(0,0,0,0.30)', mask: 'url(#trek-help-mask)' }))
|
||||
svg.appendChild(el('rect', { x: a.x, y: a.y, width: a.w, height: a.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3 }))
|
||||
svg.appendChild(el('rect', { x: b.x, y: b.y, width: b.w, height: b.h, rx: 12, fill: 'none', stroke: accent, 'stroke-width': 3, 'stroke-dasharray': '10 7' }))
|
||||
// From the edge of the dragged box that faces the destination to the facing edge there.
|
||||
const ac = { x: a.x + a.w / 2, y: a.y + a.h / 2 }
|
||||
const bc = { x: b.x + b.w / 2, y: b.y + b.h / 2 }
|
||||
const horizontal = Math.abs(bc.x - ac.x) > Math.abs(bc.y - ac.y)
|
||||
const start = horizontal ? { x: bc.x > ac.x ? a.x + a.w : a.x, y: ac.y } : { x: ac.x, y: bc.y > ac.y ? a.y + a.h : a.y }
|
||||
const end = horizontal ? { x: bc.x > ac.x ? b.x : b.x + b.w, y: bc.y } : { x: bc.x, y: bc.y > ac.y ? b.y : b.y + b.h }
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const bow = Math.min(90, len * 0.18)
|
||||
const ctrl = { x: (start.x + end.x) / 2 - (dy / len) * bow, y: (start.y + end.y) / 2 + (dx / len) * bow }
|
||||
svg.appendChild(el('path', {
|
||||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||||
fill: 'none', stroke: 'rgba(255,255,255,0.9)', 'stroke-width': 8, 'stroke-linecap': 'round',
|
||||
}))
|
||||
svg.appendChild(el('path', {
|
||||
d: `M ${start.x} ${start.y} Q ${ctrl.x} ${ctrl.y} ${end.x} ${end.y}`,
|
||||
fill: 'none', stroke: accent, 'stroke-width': 4, 'stroke-linecap': 'round', 'marker-end': 'url(#trek-help-arrow)',
|
||||
}))
|
||||
const badge = el('g', {})
|
||||
badge.appendChild(el('circle', { cx: a.x, cy: a.y, r: 14, fill: accent }))
|
||||
const text = el('text', { x: a.x, y: a.y + 5, 'text-anchor': 'middle', fill: accentText, 'font-family': 'Poppins, system-ui, sans-serif', 'font-size': 13, 'font-weight': 700 })
|
||||
text.textContent = String(n)
|
||||
badge.appendChild(text)
|
||||
svg.appendChild(badge)
|
||||
document.body.appendChild(svg)
|
||||
},
|
||||
{ from, to, n },
|
||||
)
|
||||
}
|
||||
|
||||
async function unring(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.getElementById('trek-help-ring')?.remove())
|
||||
}
|
||||
|
||||
/**
|
||||
* The release-notes modal greets every user once per version and its overlay
|
||||
* swallows every click underneath. clearNotices handles the plain notices; this
|
||||
* one has its own close button.
|
||||
*/
|
||||
export async function dismissReleaseNotice(page: Page): Promise<void> {
|
||||
const close = page.locator('.rn-close')
|
||||
await close.waitFor({ state: 'visible', timeout: 2_500 }).catch(() => {})
|
||||
for (let i = 0; i < 3 && (await close.isVisible().catch(() => false)); i++) {
|
||||
await close.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
await expect(page.locator('.rn-overlay')).toHaveCount(0)
|
||||
}
|
||||
|
||||
/** Fonts loaded, images decoded, transitions landed, network quiet (bounded: /ws never idles). */
|
||||
export async function settle(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
|
||||
await page.evaluate(async () => { await document.fonts.ready })
|
||||
// Bounded: a picture whose request never answers (a tile, a CDN flag) must
|
||||
// not hold the run; three seconds is longer than anything that does load.
|
||||
await page.evaluate(async () => {
|
||||
const pending = Array.from(document.images)
|
||||
.filter(img => !img.complete)
|
||||
.map(img => new Promise(res => { img.onload = img.onerror = res }))
|
||||
await Promise.race([Promise.all(pending), new Promise(res => setTimeout(res, 3000))])
|
||||
})
|
||||
await showLoadedTiles(page)
|
||||
await page.waitForTimeout(450)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the raster tiles that have arrived.
|
||||
*
|
||||
* Leaflet fades a tile in over 200 ms and works out how far it has got from
|
||||
* `+new Date()`. The clock is pinned to the picture day (see PICTURE_DAY), so
|
||||
* that difference never grows, the tile keeps the opacity 0 it was created
|
||||
* with, and the map is a grey rectangle in the picture even though every tile
|
||||
* came back with a 200. Nothing else on the page fades from the wall clock, so
|
||||
* this only ever touches tiles that are already loaded.
|
||||
*/
|
||||
async function showLoadedTiles(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
// A rule, not an inline style: Leaflet re-runs the fade on every frame and
|
||||
// writes the opacity back, so the only thing that holds is a stylesheet
|
||||
// that outranks it. `leaflet-tile-loaded` is Leaflet's own class, put on a
|
||||
// tile the moment its image arrives, so a tile still in flight stays hidden.
|
||||
if (!document.getElementById('trek-help-tiles')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'trek-help-tiles'
|
||||
style.textContent = '.leaflet-tile-loaded { opacity: 1 !important; }'
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
for (const tile of document.querySelectorAll('img.leaflet-tile')) {
|
||||
const img = tile as HTMLImageElement
|
||||
if (img.complete && img.naturalWidth > 0) img.classList.add('leaflet-tile-loaded')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,61 +0,0 @@
|
||||
// Moves the help-center media from the staging directory into public/help-media/,
|
||||
// re-encoding the PNG captures to WebP on the way.
|
||||
//
|
||||
// Captures are 2x (3840px wide for a full frame). The panel shows a step
|
||||
// picture at ~600px and a full frame at ~1000px, on HiDPI displays, so 1440px
|
||||
// and 1920px keep them sharp at a fraction of the bytes. WebP rather than PNG
|
||||
// on purpose: the service worker precaches `png`, and help pictures must not
|
||||
// ride along in every visitor's first download.
|
||||
//
|
||||
// Usage: node e2e/help/promote.mjs [--dry]
|
||||
import sharp from 'sharp'
|
||||
import { readdirSync, mkdirSync, statSync, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'help-media')
|
||||
const DEST = path.join(process.cwd(), 'public', 'help-media')
|
||||
const STEP_WIDTH = 1440
|
||||
const FRAME_WIDTH = 1920
|
||||
const dry = process.argv.includes('--dry')
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
console.error(`No media in ${SRC} — run \`npm run help:media\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
let before = 0
|
||||
let after = 0
|
||||
let files = 0
|
||||
|
||||
async function picture(src, dest, width) {
|
||||
const bytes = statSync(src).size
|
||||
before += bytes
|
||||
if (dry) {
|
||||
console.log(` ${path.relative(SRC, src)} → ${path.relative(DEST, dest)} (${(bytes / 1024).toFixed(0)} KB)`)
|
||||
return
|
||||
}
|
||||
const img = sharp(src)
|
||||
const meta = await img.metadata()
|
||||
const pipeline = (meta.width ?? 0) > width ? img.resize({ width }) : img
|
||||
await pipeline.webp({ quality: 84, effort: 5 }).toFile(dest)
|
||||
after += statSync(dest).size
|
||||
files++
|
||||
}
|
||||
|
||||
mkdirSync(DEST, { recursive: true })
|
||||
|
||||
for (const dir of readdirSync(SRC)) {
|
||||
const from = path.join(SRC, dir)
|
||||
if (!statSync(from).isDirectory() || dir.startsWith('.')) continue
|
||||
const to = path.join(DEST, dir)
|
||||
mkdirSync(to, { recursive: true })
|
||||
for (const file of readdirSync(from)) {
|
||||
if (path.extname(file) !== '.png') continue
|
||||
const width = file.startsWith('step-') ? STEP_WIDTH : FRAME_WIDTH
|
||||
await picture(path.join(from, file), path.join(to, file.replace(/\.png$/, '.webp')), width)
|
||||
}
|
||||
}
|
||||
|
||||
const mb = (n) => (n / 1024 / 1024).toFixed(1)
|
||||
console.log(dry ? `dry run: ${mb(before)} MB in staging` : `promoted ${files} files: ${mb(before)} MB → ${mb(after)} MB in ${path.relative(process.cwd(), DEST)}`)
|
||||
@@ -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,344 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { settingsContext, settingsTabContexts, settingsGuides } from '../../src/help/contexts/settings'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the settings screens, keyed by the ids in
|
||||
* `src/help/contexts/settings.ts`. One route, one tab per screen; most guides
|
||||
* only frame a section, and the few that change something (theme, an API key,
|
||||
* two-factor authentication) leave the account in a state the run can live
|
||||
* with, because settings is the last file of the run.
|
||||
*/
|
||||
|
||||
const TAB_OF: Record<string, string> = {
|
||||
'settings-display': 'General',
|
||||
'settings-appearance': 'Appearance',
|
||||
'settings-map': 'Map',
|
||||
'settings-notifications': 'Notifications',
|
||||
'settings-integrations': 'Integrations',
|
||||
'settings-offline': 'Offline',
|
||||
'settings-account': 'Account',
|
||||
}
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = settingsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const sidebar = (page: Page) => page.locator('nav').filter({ has: page.getByRole('button', { name: 'General' }) }).first()
|
||||
/** A settings card by the heading of its section. */
|
||||
const section = (page: Page, title: string) =>
|
||||
page.locator('h2').filter({ hasText: title }).first().locator("xpath=ancestor::section[1]")
|
||||
/** A field of a section by its label. */
|
||||
const field = (page: Page, sectionTitle: string, label: string) =>
|
||||
section(page, sectionTitle).getByText(label, { exact: true }).first().locator('xpath=..')
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
/** A sub-section headed by an h3 inside a card (the passkeys block under Account). */
|
||||
const subsection = (page: Page, title: string) => page.locator('h2, h3').filter({ hasText: title }).first().locator('xpath=ancestor::section[1]')
|
||||
|
||||
async function openSettings(page: Page, tab: string): Promise<void> {
|
||||
await page.goto('/settings')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(sidebar(page)).toBeVisible({ timeout: 20_000 })
|
||||
await sidebar(page).getByRole('button', { name: tab, exact: true }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const opener = (contextId: string) => (page: Page) => openSettings(page, TAB_OF[contextId])
|
||||
|
||||
/** RFC 6238 code for the secret the setup shows, so the run can enable 2FA for real. */
|
||||
function totp(secretBase32: string): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const clean = secretBase32.toUpperCase().replace(/[^A-Z2-7]/g, '')
|
||||
let bits = ''
|
||||
for (const ch of clean) bits += alphabet.indexOf(ch).toString(2).padStart(5, '0')
|
||||
const bytes = Buffer.from((bits.match(/.{8}/g) ?? []).map(b => parseInt(b, 2)))
|
||||
const counter = Buffer.alloc(8)
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000)))
|
||||
const mac = createHmac('sha1', bytes).update(counter).digest()
|
||||
const offset = mac[mac.length - 1] & 0x0f
|
||||
const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]
|
||||
return String(code % 1_000_000).padStart(6, '0')
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
// ── General ──────────────────────────────────────────────────────────────
|
||||
'language-region': {
|
||||
guide: guide('language-region'),
|
||||
start: opener('settings-display'),
|
||||
steps: [
|
||||
only(p => field(p, 'Language & region', 'Language')),
|
||||
only(p => section(p, 'Language & region')),
|
||||
],
|
||||
},
|
||||
'travel-map-prefs': {
|
||||
guide: guide('travel-map-prefs'),
|
||||
start: opener('settings-display'),
|
||||
steps: [
|
||||
only(p => field(p, 'Travel & map', 'Always show booking routes').locator('xpath=..')),
|
||||
only(p => field(p, 'Travel & map', 'Blur Booking Codes').locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
startup: {
|
||||
guide: guide('startup'),
|
||||
start: opener('settings-display'),
|
||||
steps: [
|
||||
{
|
||||
target: p => field(p, 'Startup', 'Start page').locator('xpath=..'),
|
||||
// The start tab only exists once the app opens on the active trip.
|
||||
act: async p => { await section(p, 'Startup').getByRole('button', { name: 'Active trip' }).click(); await settle(p) },
|
||||
},
|
||||
only(p => field(p, 'Startup', 'Start tab').locator('xpath=..')),
|
||||
],
|
||||
cleanup: async p => { await section(p, 'Startup').getByRole('button', { name: 'Dashboard', exact: true }).click(); await settle(p) },
|
||||
},
|
||||
|
||||
// ── Appearance ───────────────────────────────────────────────────────────
|
||||
'theme-scheme': {
|
||||
guide: guide('theme-scheme'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'Theme').getByRole('button', { name: 'Dark', exact: true }).locator('xpath=..'),
|
||||
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Light', exact: true }).click(); await beat(p, 400) },
|
||||
},
|
||||
{
|
||||
target: p => section(p, 'Theme').getByText('Color scheme', { exact: true }).locator('xpath=..'),
|
||||
act: async p => { await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
prepare: async p => { await section(p, 'Theme').getByRole('button', { name: 'Custom', exact: true }).click(); await settle(p) },
|
||||
target: p => section(p, 'Theme').getByText('Custom accent', { exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await section(p, 'Theme').getByRole('button', { name: 'Teal', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await section(p, 'Theme').getByRole('button', { name: 'Default', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
readability: {
|
||||
guide: guide('readability'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => section(p, 'Readability').getByText('Transparency', { exact: true }).first().locator('xpath=../..')),
|
||||
only(p => section(p, 'Readability').getByText('Text size', { exact: true }).first().locator('xpath=../..')),
|
||||
],
|
||||
},
|
||||
'dashboard-widgets': {
|
||||
guide: guide('dashboard-widgets'),
|
||||
start: opener('settings-appearance'),
|
||||
steps: [
|
||||
only(p => section(p, 'Dashboard widgets')),
|
||||
only(p => p.getByRole('button', { name: 'Reset to defaults' })),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Map ──────────────────────────────────────────────────────────────────
|
||||
'map-provider': {
|
||||
guide: guide('map-provider'),
|
||||
start: opener('settings-map'),
|
||||
steps: [
|
||||
only(p => section(p, 'Map').getByText('Map Provider', { exact: true }).locator('xpath=..')),
|
||||
only(p => section(p, 'Map').getByText(/^Map (Style|Template)$/).first().locator('xpath=..')),
|
||||
only(p => section(p, 'Map').getByRole('button', { name: 'Save Map' }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
'notification-channels': {
|
||||
guide: guide('notification-channels'),
|
||||
start: opener('settings-notifications'),
|
||||
steps: [
|
||||
only(p => section(p, 'Ntfy')),
|
||||
only(p => section(p, 'Webhook URL')),
|
||||
only(p => page_rows(p)),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Integrations ─────────────────────────────────────────────────────────
|
||||
'photo-providers': {
|
||||
guide: guide('photo-providers'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
only(p => section(p, 'Immich')),
|
||||
only(p => section(p, 'Immich').getByRole('button', { name: 'Test connection' }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
'api-keys': {
|
||||
guide: guide('api-keys'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
{
|
||||
target: p => section(p, 'API Keys').getByRole('button', { name: 'Create key' }),
|
||||
act: async p => {
|
||||
await section(p, 'API Keys').getByRole('button', { name: 'Create key' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Dawarich'), 'Home Assistant')
|
||||
await modal(p).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(modal(p).getByText('API key created')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('> [role="presentation"], > div').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'mcp-oauth': {
|
||||
guide: guide('mcp-oauth'),
|
||||
start: opener('settings-integrations'),
|
||||
steps: [
|
||||
only(p => section(p, 'MCP Configuration').getByText('MCP Endpoint', { exact: true }).locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'OAuth 2.1 Clients' }).click(); await settle(p) },
|
||||
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'New Client' }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
prepare: async p => { await section(p, 'MCP Configuration').getByRole('button', { name: 'API Tokens' }).click(); await settle(p) },
|
||||
target: p => section(p, 'MCP Configuration').getByRole('button', { name: 'Create New Token' }).locator('xpath=../..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Offline ──────────────────────────────────────────────────────────────
|
||||
'offline-prepare': {
|
||||
guide: guide('offline-prepare'),
|
||||
start: opener('settings-offline'),
|
||||
steps: [
|
||||
only(p => section(p, 'What to store offline')),
|
||||
only(p => p.getByRole('button', { name: 'Download for offline use' })),
|
||||
only(p => section(p, 'Offline mode').getByRole('button', { name: 'Force offline mode' }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
'offline-conflicts': {
|
||||
guide: guide('offline-conflicts'),
|
||||
start: opener('settings-offline'),
|
||||
steps: [
|
||||
// The conflicts section only exists while a conflict is open; the cache card shows their count.
|
||||
only(p => section(p, 'Offline cache')),
|
||||
only(p => p.getByRole('button', { name: 'Re-sync now' })),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Account ──────────────────────────────────────────────────────────────
|
||||
profile: {
|
||||
guide: guide('profile'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => section(p, 'Account').getByText('Username', { exact: true }).first().locator('xpath=../..')),
|
||||
only(p => section(p, 'Account').getByRole('button', { name: 'Save Profile' })),
|
||||
],
|
||||
},
|
||||
password: {
|
||||
guide: guide('password'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => section(p, 'Change Password').getByPlaceholder('Current password').locator('xpath=../..')),
|
||||
only(p => section(p, 'Change Password').getByRole('button', { name: 'Update password' })),
|
||||
],
|
||||
},
|
||||
mfa: {
|
||||
guide: guide('mfa'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }),
|
||||
act: async p => {
|
||||
await subsection(p, 'Two-factor authentication (2FA)').getByRole('button', { name: 'Set up authenticator' }).click()
|
||||
await expect(subsection(p, 'Two-factor authentication (2FA)').locator('code')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByPlaceholder('6-digit code').locator('xpath=../..'),
|
||||
act: async p => {
|
||||
const mfa = subsection(p, 'Two-factor authentication (2FA)')
|
||||
const secret = (await mfa.locator('code').first().innerText()).trim()
|
||||
await typeInto(p, mfa.getByPlaceholder('6-digit code'), totp(secret))
|
||||
await mfa.getByRole('button', { name: 'Enable 2FA' }).click()
|
||||
await expect(mfa.getByText('Backup codes').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => subsection(p, 'Two-factor authentication (2FA)').getByText('Backup codes').first().locator('xpath=..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
passkeys: {
|
||||
guide: guide('passkeys'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
// Passkeys need a secure context; the run is plain http, so this guide has no pictures.
|
||||
{},
|
||||
{},
|
||||
],
|
||||
},
|
||||
'delete-account': {
|
||||
guide: guide('delete-account'),
|
||||
start: opener('settings-account'),
|
||||
steps: [
|
||||
only(p => p.getByRole('button', { name: 'Delete account' }).locator('xpath=..')),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** The per-event preference rows under the channels. */
|
||||
function page_rows(p: Page): Locator {
|
||||
// The per-event matrix is the whole Notifications card in the redesigned tab.
|
||||
return section(p, 'Notifications')
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const ALL_CONTEXTS = [settingsContext, ...settingsTabContexts]
|
||||
|
||||
// The photo-provider sections exist only for providers the admin switched on, and the
|
||||
// ntfy and webhook forms only for channels the admin turned on.
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const res = await request.put('/api/admin/addons/immich', { data: { enabled: true } })
|
||||
if (!res.ok()) throw new Error(`could not enable the Immich provider: ${res.status()} ${await res.text()}`)
|
||||
const channels = await request.put('/api/auth/app-settings', { data: { notification_channels: 'ntfy,webhook' } })
|
||||
if (!channels.ok()) throw new Error(`could not enable the notification channels: ${channels.status()} ${await channels.text()}`)
|
||||
})
|
||||
|
||||
test('every registered settings guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(ALL_CONTEXTS.flatMap(c => c.guides).sort())
|
||||
})
|
||||
|
||||
test('hero: settings', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, settingsContext.id, p => openSettings(p, 'General'))
|
||||
})
|
||||
|
||||
for (const ctx of settingsTabContexts) {
|
||||
test(`hero: ${ctx.id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, ctx.id, opener(ctx.id))
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of ALL_CONTEXTS.flatMap(c => c.guides)) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,770 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { dotted } from '../dates'
|
||||
import { seededTrip, ensureBookingsFixtures, bookingPdfFixture, bookingEmlFixture, BOOKING_EML, HOTEL_TO_BOOK, UNATTACHED_FILE } from './fixtures'
|
||||
import { openTrip, modal, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
|
||||
import { requireExtractor, allowEmlUploads } from './external'
|
||||
import { tripBookingsContext, tripBookingsGuides } from '../../src/help/contexts/tripBookings'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Bookings tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripBookings.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with `ensureBookingsFixtures` in front of them, because the seed's only
|
||||
* reservation is a flight and a flight is a transport: without the fixture this
|
||||
* tab is its empty state. What a guide creates or changes it puts back in
|
||||
* `cleanup`, mostly by deleting the booking and letting the fixture build it
|
||||
* again, so the next one starts from the same seven cards.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripBookingsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The tab id in the address is the legacy German one; its label is Bookings. */
|
||||
const openBookings = (page: Page) => openTrip(page, { tab: 'buchungen' })
|
||||
|
||||
/** A booking's card in the Cards view: an article named after the booking. */
|
||||
const bookingCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
||||
/** The pencil and the bin in a card's head band, named by their tooltips. */
|
||||
const cardAction = (page: Page, title: string, name: 'Edit' | 'Delete') =>
|
||||
bookingCard(page, title).getByRole('button', { name, exact: true })
|
||||
|
||||
/** Open a booking's editor from its card, the way the texts tell the reader to. */
|
||||
async function editFromCard(page: Page, title: string): Promise<void> {
|
||||
await cardAction(page, title, 'Edit').click()
|
||||
await expect(modal(page).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── The bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The tab's one bar: its heading, then search, filter, the views and the add buttons. */
|
||||
const toolbar = (page: Page) => page.getByRole('heading', { name: 'Bookings', exact: true, level: 2 }).locator('xpath=..')
|
||||
const searchBox = (page: Page) => toolbar(page).getByRole('textbox', { name: 'Search', exact: true })
|
||||
/** The chip that says how many are left ("3 of 7") once anything filters; a click resets. */
|
||||
const resultsChip = (page: Page) => toolbar(page).getByRole('button', { name: /^\d+ of \d+$/ })
|
||||
const filterButton = (page: Page) => toolbar(page).getByRole('button', { name: 'Filter', exact: true })
|
||||
/** The filter panel under the funnel: Status, Type and Travelers. */
|
||||
const filterMenu = (page: Page) => page.getByRole('menu')
|
||||
const viewSwitch = (page: Page) => page.getByRole('group', { name: 'View', exact: true })
|
||||
const viewButton = (page: Page, name: 'Cards' | 'List' | 'Timeline') => viewSwitch(page).getByRole('button', { name, exact: true })
|
||||
|
||||
// ── Timeline and detail ───────────────────────────────────────────────────────
|
||||
|
||||
/** Trip | Day above the chart. */
|
||||
const zoom = (page: Page) => page.getByRole('group', { name: 'Zoom', exact: true })
|
||||
/** A day's heading on the Trip zoom; a click opens that day by the hour. */
|
||||
const dayHeading = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`Day ${n}$`) })
|
||||
/** A bar of the timeline, named after its booking. */
|
||||
const timelineBar = (page: Page, title: string) => page.getByRole('button', { name: title, exact: true })
|
||||
/** The booking's detail popup, labelled by its title. */
|
||||
const detail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
|
||||
/** The booking the views guide opens: a tour on day 5, with a place and travellers. */
|
||||
const VIEWED = { title: 'Fushimi Inari night walk', day: 5 }
|
||||
|
||||
// ── The editor ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A field of the booking form, by its own label; the block around it is the label's parent. */
|
||||
const label = (page: Page, text: RegExp) => modal(page).locator('label').filter({ hasText: text }).first()
|
||||
const block = (page: Page, text: RegExp) => label(page, text).locator('xpath=..')
|
||||
/** The two-column or three-column row a field shares with its neighbours. */
|
||||
const row = (page: Page, text: RegExp) => block(page, text).locator('xpath=..')
|
||||
/** A labelled block of a dialog's body whose label is not a `<label>` (Who paid?). */
|
||||
const section = (page: Page, name: string) =>
|
||||
modal(page).locator('section').filter({ has: page.getByText(name, { exact: true }) }).first()
|
||||
|
||||
/** CustomSelect portals its menu to the body: a fixed panel at z-index 99999. */
|
||||
const selectMenu = (page: Page) => page.locator('body > div[style*="99999"]').last()
|
||||
|
||||
/** The title is typed into the head band; the field keeps the old placeholder. */
|
||||
const titleBox = (page: Page) => modal(page).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')
|
||||
const codeBox = (page: Page) => modal(page).getByPlaceholder('e.g. ABC12345')
|
||||
const timeBox = (page: Page) => modal(page).getByPlaceholder('00:00')
|
||||
const saveButton = (page: Page, name: 'Add' | 'Update') => modal(page).getByRole('button', { name, exact: true })
|
||||
|
||||
/** The card's delete question is its own portal, with no backdrop class to find it by. */
|
||||
const deleteAsk = (page: Page) => portalDialog(page, page.getByText('Delete booking?', { exact: true }))
|
||||
|
||||
/** The Booking Type is a pill in the head band; its name reads the type after the label. */
|
||||
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
|
||||
/** The pill's list is its own portal, named after the pill's label. */
|
||||
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
|
||||
/** The status pill beside it says what a click turns it into. */
|
||||
const statusPill = (page: Page) => modal(page).getByRole('button', { name: /^Set to / })
|
||||
|
||||
async function openTypeMenu(page: Page): Promise<void> {
|
||||
await typePill(page).click()
|
||||
await expect(typeMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
|
||||
async function pickType(page: Page, type: RegExp): Promise<void> {
|
||||
await typeMenu(page).getByRole('button', { name: type }).first().click()
|
||||
await expect(typeMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function openManualBooking(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Manual Booking' }).click()
|
||||
await expect(modal(page).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** The Travelers field opens its members as a list under it, inside the form. */
|
||||
const travelerList = (page: Page) => block(page, /^Travelers$/).getByRole('listbox')
|
||||
|
||||
/** Open the CustomSelect that belongs to a label and take one option out of it. */
|
||||
async function chooseIn(page: Page, field: RegExp, option: RegExp, search?: string): Promise<void> {
|
||||
await block(page, field).getByRole('button').first().click()
|
||||
await expect(selectMenu(page)).toBeVisible()
|
||||
if (search) await selectMenu(page).locator('input').fill(search)
|
||||
await beat(page, 300)
|
||||
await selectMenu(page).getByRole('button', { name: option }).first().click()
|
||||
await expect(selectMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a date without touching the calendar: every date field has a keyboard
|
||||
* button beside it that swaps in a DD.MM.YYYY box, which is one fill instead of
|
||||
* a month of grid cells that move with the trip's dates.
|
||||
*/
|
||||
async function setDate(page: Page, nth: number, value: string): Promise<void> {
|
||||
await modal(page).getByRole('button', { name: 'Enter date manually' }).nth(nth).click()
|
||||
const box = modal(page).getByPlaceholder('DD.MM.YYYY')
|
||||
await box.fill(value)
|
||||
await box.press('Enter')
|
||||
await expect(box).toHaveCount(0)
|
||||
}
|
||||
|
||||
interface Booking {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
async function bookingsOf(page: Page): Promise<Booking[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
|
||||
const body = (await res.json()) as { reservations?: Booking[] } | Booking[]
|
||||
return Array.isArray(body) ? body : (body.reservations ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete bookings by title. The route takes the stay of an accommodation and
|
||||
* the expense linked to a booking with it, so this undoes those too.
|
||||
*/
|
||||
async function deleteBookings(page: Page, ...titles: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const booking of (await bookingsOf(page)).filter(b => titles.includes(b.title))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/reservations/${booking.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put a changed booking back: drop it, then let the fixture build it again. */
|
||||
async function restore(page: Page, ...titles: string[]): Promise<void> {
|
||||
await deleteBookings(page, ...titles)
|
||||
await ensureBookingsFixtures(page.request)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the tab remembers: the filters per trip for the browser tab, the view,
|
||||
* its grouping, sorting and timeline zoom, and the folded sections in the
|
||||
* browser. Every test has a context of its own, so this is only tidiness for a
|
||||
* guide that runs another after it in the same page.
|
||||
*/
|
||||
async function clearPanelState(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
await page.evaluate(id => {
|
||||
try {
|
||||
sessionStorage.removeItem(`trek-bookings-filters-bookings-${id}`)
|
||||
for (const name of ['view', 'group', 'sort', 'timeline', 'transitApart']) localStorage.removeItem(`trek:bookings-bookings-${name}`)
|
||||
localStorage.removeItem(`trek:bookings-bookings-collapsed:${id}`)
|
||||
localStorage.removeItem('trek.bg-import-tasks')
|
||||
} catch {
|
||||
/* a browser that refuses storage has nothing to clear */
|
||||
}
|
||||
}, tripId)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'booking-views': {
|
||||
guide: guide('booking-views'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
// The three icons are the subject; the pointer rests on List so its name shows.
|
||||
target: viewSwitch,
|
||||
hover: async p => { await viewButton(p, 'List').hover() },
|
||||
act: async p => {
|
||||
await viewButton(p, 'List').click()
|
||||
await expect(viewButton(p, 'List')).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(p.getByRole('button', { name: VIEWED.title, exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => viewButton(p, 'Timeline'),
|
||||
act: async p => {
|
||||
await viewButton(p, 'Timeline').click()
|
||||
await expect(zoom(p)).toBeVisible()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Trip', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => dayHeading(p, VIEWED.day),
|
||||
act: async p => {
|
||||
await dayHeading(p, VIEWED.day).click()
|
||||
await expect(zoom(p).getByRole('button', { name: 'Day', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(timelineBar(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(zoom),
|
||||
{
|
||||
// Hovered for the picture: the bar's card of facts stands beside it.
|
||||
target: p => timelineBar(p, VIEWED.title),
|
||||
act: async p => {
|
||||
await timelineBar(p, VIEWED.title).click()
|
||||
await expect(detail(p, VIEWED.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => detail(p, VIEWED.title)),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await clearPanelState(p)
|
||||
},
|
||||
},
|
||||
'create-booking': {
|
||||
guide: guide('create-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Manual Booking' }),
|
||||
act: openManualBooking,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await pickType(p, /^Event$/)
|
||||
await expect(typePill(p)).toHaveAccessibleName(/^Booking Type:\s*Event$/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, titleBox(p), 'Kabuki at the Minamiza') },
|
||||
target: titleBox,
|
||||
act: async p => {
|
||||
await expect(saveButton(p, 'Add')).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await setDate(p, 0, dotted(-2))
|
||||
await timeBox(p).nth(0).fill('16:30')
|
||||
await setDate(p, 1, dotted(-2))
|
||||
await timeBox(p).nth(1).fill('20:00')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, /^Date$/),
|
||||
act: async p => {
|
||||
await expect(saveButton(p, 'Add')).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, codeBox(p), 'MZ-5521')
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
},
|
||||
target: p => row(p, /^Booking Code$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Add'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kabuki at the Minamiza')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteBookings(p, 'Kabuki at the Minamiza'),
|
||||
},
|
||||
'booking-hotel': {
|
||||
guide: guide('booking-hotel'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await openManualBooking(p)
|
||||
await openTypeMenu(p)
|
||||
},
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await pickType(p, /^Accommodation$/)
|
||||
await expect(label(p, /^Check-in until$/)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await chooseIn(p, /^Accommodation$/, /^Hotel Granvia Kyoto$/, 'Granvia') },
|
||||
target: p => block(p, /^Accommodation$/),
|
||||
act: async p => {
|
||||
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await chooseIn(p, /^From$/, /^Day 5 /)
|
||||
await chooseIn(p, /^To$/, /^Day 8 /)
|
||||
},
|
||||
target: p => row(p, /^To$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await timeBox(p).nth(0).fill('15:00')
|
||||
await timeBox(p).nth(1).fill('23:00')
|
||||
await timeBox(p).nth(2).fill('11:00')
|
||||
await typeInto(p, codeBox(p), 'GRK-40218')
|
||||
},
|
||||
target: p => row(p, /^Check-in$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Add'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteBookings(p, HOTEL_TO_BOOK.name),
|
||||
},
|
||||
'link-booking': {
|
||||
guide: guide('link-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'teamLab Planets timed entry', 'Edit'),
|
||||
act: p => editFromCard(p, 'teamLab Planets timed entry'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Link to day assignment$/).getByRole('button').first().click()
|
||||
await expect(selectMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: selectMenu,
|
||||
act: async p => {
|
||||
await selectMenu(p).getByRole('button', { name: /^2\. teamLab Planets/ }).click()
|
||||
await expect(selectMenu(p)).toHaveCount(0)
|
||||
await expect(block(p, /^Link to day assignment$/).getByRole('button', { name: /teamLab Planets/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await chooseIn(p, /^Place \/ Activity$/, /^teamLab Planets$/, 'teamLab') },
|
||||
target: p => block(p, /^Place \/ Activity$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('Linked to', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => restore(p, 'teamLab Planets timed entry'),
|
||||
},
|
||||
'booking-travelers': {
|
||||
guide: guide('booking-travelers'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'Fushimi Inari night walk')
|
||||
await expect(label(p, /^Travelers$/)).toBeVisible()
|
||||
},
|
||||
target: p => block(p, /^Travelers$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Travelers$/).getByRole('button', { expanded: false }).click()
|
||||
await expect(travelerList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: travelerList,
|
||||
act: async p => {
|
||||
const person = travelerList(p).getByRole('button', { name: /jonas/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
// A click beside the field folds the list away; Escape would close the whole form.
|
||||
await label(p, /^Travelers$/).click()
|
||||
await expect(travelerList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk').getByText('jonas', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The people are a row of chips under the panel's Travelers caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: /jonas$/ }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
const person = filterMenu(p).getByRole('button', { name: /jonas$/ })
|
||||
await person.click()
|
||||
await expect(person).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await clearPanelState(p)
|
||||
await restore(p, 'Fushimi Inari night walk')
|
||||
},
|
||||
},
|
||||
'booking-files': {
|
||||
guide: guide('booking-files'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'teamLab Planets timed entry')
|
||||
await expect(modal(p).getByRole('button', { name: 'Attach file' })).toBeVisible()
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Attach file' }),
|
||||
act: async p => {
|
||||
// Set the hidden input directly: clicking the button opens the file
|
||||
// picker of the operating system, which Playwright cannot photograph.
|
||||
await modal(p).locator('input[type="file"]').setInputFiles(bookingPdfFixture('teamlab-tickets'))
|
||||
await expect(modal(p).getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('teamlab-tickets.pdf').locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Link existing file' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Link existing file' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true }).click()
|
||||
await expect(modal(p).getByRole('button', { name: UNATTACHED_FILE, exact: true })).toHaveCount(0)
|
||||
await expect(modal(p).getByText(UNATTACHED_FILE)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'teamLab Planets timed entry').getByText('teamlab-tickets.pdf')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// Deleting the booking takes the link row to the voucher with it, so the
|
||||
// voucher is unattached again and Link existing file offers it next time.
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/files`)
|
||||
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of files.filter(f => f.original_name === 'teamlab-tickets.pdf')) {
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
}
|
||||
await restore(p, 'teamLab Planets timed entry')
|
||||
},
|
||||
},
|
||||
'booking-cost': {
|
||||
guide: guide('booking-cost'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(modal(p).getByRole('button', { name: 'Create expense' })).toBeVisible()
|
||||
},
|
||||
target: p => block(p, /^Costs$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create expense' }).click()
|
||||
await expect(p.getByRole('heading', { name: 'Add expense' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, /^Total amount$/).locator('input').first().fill('12000')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, /^Total amount$/),
|
||||
act: settle,
|
||||
},
|
||||
only(p => section(p, 'Who paid?')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add expense', exact: true }),
|
||||
act: async p => {
|
||||
const save = modal(p).getByRole('button', { name: 'Add expense', exact: true })
|
||||
// The name comes prefilled from the booking; without it the button is dead.
|
||||
await expect(save).toBeEnabled()
|
||||
await save.click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
// The block only reads Linked expenses once the booking is opened again.
|
||||
await editFromCard(p, 'Kyoto Cycling Tour')
|
||||
await expect(label(p, /^Linked expenses$/)).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await label(p, /^Linked expenses$/).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await restore(p, 'Kyoto Cycling Tour')
|
||||
},
|
||||
},
|
||||
'filter-bookings': {
|
||||
guide: guide('filter-bookings'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, searchBox(p), 'Kyoto')
|
||||
await expect(resultsChip(p)).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
// The box around the field, with its magnifier.
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await searchBox(p).press('Escape')
|
||||
await expect(searchBox(p)).toHaveValue('')
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: filterButton,
|
||||
act: async p => {
|
||||
await filterButton(p).click()
|
||||
await expect(filterMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The three choices sit on one grey track under the Status caption.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'Pending', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => filterMenu(p).getByRole('button', { name: /^Tour\b/ }),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: /^Tour\b/ }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: resultsChip,
|
||||
act: async p => {
|
||||
await resultsChip(p).click()
|
||||
await expect(resultsChip(p)).toHaveCount(0)
|
||||
await expect(bookingCard(p, 'Fushimi Inari night walk')).toBeVisible()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: clearPanelState,
|
||||
},
|
||||
'import-booking-file': {
|
||||
guide: guide('import-booking-file'),
|
||||
// The extractor, and no addon: with it answering, the dialog sends mode
|
||||
// no-ai and no model is asked. The mail has to be an allowed file type or
|
||||
// the review would save the booking and drop the document without a word.
|
||||
start: async p => {
|
||||
await requireExtractor(p.request)
|
||||
await allowEmlUploads(p.request, true)
|
||||
await openBookings(p)
|
||||
},
|
||||
steps: [
|
||||
...importSteps(bookingEmlFixture, BOOKING_EML),
|
||||
{
|
||||
prepare: async p => {
|
||||
// The card is there at once, spinning. The picture wants it finished:
|
||||
// the tick and the Import it offers. The extractor has 30 s in the
|
||||
// backend, and on a Windows media host that is a docker run, so the
|
||||
// wait is generous rather than tight.
|
||||
await expect(importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => importTask(p, BOOKING_EML),
|
||||
act: async p => {
|
||||
await importTask(p, BOOKING_EML).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(titleBox(p)).toHaveValue(HOTEL_TO_BOOK.name)
|
||||
await expect(codeBox(p)).toHaveValue('GRK-40218')
|
||||
await expect(modal(p).getByText(BOOKING_EML)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Add').click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(bookingCard(p, HOTEL_TO_BOOK.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await dismissImportTask(p, BOOKING_EML)
|
||||
await allowEmlUploads(p.request, false)
|
||||
await clearPanelState(p)
|
||||
// The route takes the stay and the expense with the booking; the mail it
|
||||
// attached stays behind in Files like any unlinked document.
|
||||
await deleteBookings(p, HOTEL_TO_BOOK.name)
|
||||
await deleteTripFiles(p, BOOKING_EML)
|
||||
},
|
||||
},
|
||||
'edit-booking': {
|
||||
guide: guide('edit-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'Kyoto Cycling Tour', 'Edit'),
|
||||
act: p => editFromCard(p, 'Kyoto Cycling Tour'),
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, codeBox(p), 'KCT-90412') },
|
||||
target: p => block(p, /^Booking Code$/),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: statusPill,
|
||||
act: async p => {
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Confirmed')
|
||||
await statusPill(p).click()
|
||||
await expect(statusPill(p)).toHaveAccessibleName('Set to Pending')
|
||||
await expect(statusPill(p)).toHaveText('Confirmed')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => saveButton(p, 'Update'),
|
||||
act: async p => {
|
||||
await saveButton(p, 'Update').click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// A confirmed card's status dot offers the way back to Pending.
|
||||
await expect(bookingCard(p, 'Kyoto Cycling Tour').getByRole('button', { name: 'Set to Pending' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => restore(p, 'Kyoto Cycling Tour'),
|
||||
},
|
||||
'delete-booking': {
|
||||
guide: guide('delete-booking'),
|
||||
start: openBookings,
|
||||
steps: [
|
||||
{
|
||||
target: p => cardAction(p, 'Haneda Airport P4', 'Delete'),
|
||||
act: async p => {
|
||||
await cardAction(p, 'Haneda Airport P4', 'Delete').click()
|
||||
await expect(deleteAsk(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(deleteAsk),
|
||||
{
|
||||
target: p => deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async p => {
|
||||
await deleteAsk(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(bookingCard(p, 'Haneda Airport P4')).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => ensureBookingsFixtures(p.request),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureBookingsFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered bookings guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripBookingsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-bookings', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripBookingsContext.id, openBookings)
|
||||
})
|
||||
|
||||
for (const id of tripBookingsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ordinal, short } from '../dates'
|
||||
import { SEED_CHAT } from '../screenshots/seed'
|
||||
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
|
||||
import { openTrip } from './trip-shared'
|
||||
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Collab tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan",
|
||||
* whose collab addon already holds three notes, two polls and a seven message
|
||||
* conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll
|
||||
* for the one guide that closes a poll for good, and the two upcoming stops
|
||||
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
|
||||
* one deliberate exception: a deleted chat message cannot be undeleted, which is
|
||||
* why `trip-chat` is the last guide of the screen.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripCollabGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const NOTE = {
|
||||
title: 'Luggage forwarding',
|
||||
body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
|
||||
category: 'Transport',
|
||||
website: 'https://www.global-yamato.com/en/hc/',
|
||||
}
|
||||
/** An .example host never resolves, so the chip shows its own glyph offline too. */
|
||||
const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' }
|
||||
const POLL = {
|
||||
// Plain text on purpose: a bold run in the question would wrap the paragraph
|
||||
// in one more element and move the card away from `pollCard`'s ancestor depth.
|
||||
question: 'Which evening do we keep for the kaiseki dinner?',
|
||||
options: [`${short(-5).split(',')[0]} ${ordinal(-5)}, after Fushimi Inari`, `${short(-4).split(',')[0]} ${ordinal(-4)}, after Arashiyama`, 'Either, decide on the day'],
|
||||
}
|
||||
/** The seeded poll nobody has voted on yet. */
|
||||
const SEEDED_POLL = 'Ryokan or city hotel in Hakone?'
|
||||
const CHAT = {
|
||||
mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
|
||||
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
|
||||
emoji: 'Two weeks to go ',
|
||||
flag: '🎌',
|
||||
theirs: 'Drop bags. I want to be at Senso-ji before the crowds.',
|
||||
answer: 'Agreed, bags at the hotel first, then Asakusa.',
|
||||
reactTo: SEED_CHAT.teamlab,
|
||||
reaction: '👍',
|
||||
}
|
||||
|
||||
// ── Locators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One of the five cards of the tab, found from the name in its head band. The
|
||||
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
|
||||
* the match ignores case; the card is the nearest rounded ancestor (the cards
|
||||
* inside a panel, notes and polls, are never an ancestor of that heading).
|
||||
*/
|
||||
const panel = (page: Page, name: RegExp) =>
|
||||
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
||||
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
|
||||
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
|
||||
const linksPanel = (page: Page) => panel(page, /^Links$/i)
|
||||
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
|
||||
|
||||
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
|
||||
/** The row that holds the emoji button, the image button, the box and the arrow. */
|
||||
const composer = (page: Page) => chatBox(page).locator('xpath=..')
|
||||
/**
|
||||
* The quoted message above the box while a reply is being written. With no reply
|
||||
* pending the same position is the composer row itself, which is what the step
|
||||
* after the send asserts against.
|
||||
*/
|
||||
const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first()
|
||||
/** The send button and the emoji button carry no text and no label at all. */
|
||||
const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)')
|
||||
const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)')
|
||||
/** The emoji picker is a portal; its three category tabs are the sure thing in it. */
|
||||
const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..')
|
||||
/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */
|
||||
const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last()
|
||||
/** One message, by the relative wrapper that also carries its Reply and Delete. */
|
||||
const bubble = (page: Page, text: string) =>
|
||||
page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]')
|
||||
/**
|
||||
* Put the pointer on a message's Reply or Delete. The row holding them is
|
||||
* `pointer-events: none` until its own bubble is hovered, and it sits at
|
||||
* `top: -14px`, outside that bubble's box: hovering the button alone would land
|
||||
* on whatever is behind it. `captureGuide` scrolls the target to the middle of
|
||||
* the frame between `prepare` and the shot, and that scroll can take the bubble
|
||||
* out from under the pointer, so the hover is made again here, bubble first.
|
||||
*/
|
||||
const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise<void> => {
|
||||
await page.getByText(text).first().hover()
|
||||
await beat(page, 300)
|
||||
await bubble(page, text).getByRole('button', { name }).hover()
|
||||
}
|
||||
|
||||
/**
|
||||
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
|
||||
* field only each of them has. The whole panel is the dialog: the head band
|
||||
* with the title typed into it, the body, and the footer with the buttons.
|
||||
*/
|
||||
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
|
||||
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
|
||||
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
|
||||
/** A field of an editor with the eyebrow label over it: the box's parent. */
|
||||
const field = (box: Locator) => box.locator('xpath=..')
|
||||
|
||||
/** A note card is an article named by the note's title. */
|
||||
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
||||
/** A poll card is an article too, without a name: found from its question. */
|
||||
const pollCard = (page: Page, question: string) =>
|
||||
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
|
||||
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
|
||||
/** A What's Next row, from the place name in it: name → details → row. */
|
||||
const nextRow = (page: Page, place: string) =>
|
||||
nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..')
|
||||
|
||||
// ── Start and cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
const start = async (page: Page): Promise<void> => {
|
||||
await openTrip(page, { tab: 'collab' })
|
||||
// The Collab panel is a lazy chunk: everything below needs it mounted.
|
||||
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
|
||||
|
||||
async function listOf<T>(page: Page, path: string, key: string): Promise<T[]> {
|
||||
const res = await page.request.get(`${collab()}/${path}`)
|
||||
const body = (await res.json()) as Record<string, T[] | undefined>
|
||||
return body[key] ?? []
|
||||
}
|
||||
|
||||
async function deleteNote(page: Page, title: string): Promise<void> {
|
||||
const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes')
|
||||
for (const note of notes.filter(n => n.title === title)) {
|
||||
await page.request.delete(`${collab()}/notes/${note.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLink(page: Page, title: string): Promise<void> {
|
||||
const links = await listOf<{ id: number; title: string }>(page, 'links', 'links')
|
||||
for (const link of links.filter(l => l.title === title)) {
|
||||
await page.request.delete(`${collab()}/links/${link.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePoll(page: Page, question: string): Promise<void> {
|
||||
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
||||
for (const poll of polls.filter(p => p.question === question)) {
|
||||
await page.request.delete(`${collab()}/polls/${poll.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voting for an option you already chose deletes the row, so one call puts the
|
||||
* seeded, unvoted poll back the way the next guide expects it.
|
||||
*/
|
||||
async function resetVote(page: Page, question: string, optionIndex: number): Promise<void> {
|
||||
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
||||
const poll = polls.find(p => p.question === question)
|
||||
if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } })
|
||||
}
|
||||
|
||||
const only = (target: (page: Page) => Locator) => ({ target })
|
||||
|
||||
// ── Scripts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'write-note': {
|
||||
guide: guide('write-note'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
|
||||
act: async page => {
|
||||
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
|
||||
await expect(noteDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The title is typed into the dialog's head band, under the New Note eyebrow.
|
||||
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
|
||||
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
|
||||
// The Content eyebrow and the box under it.
|
||||
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
|
||||
},
|
||||
{
|
||||
// Picked before the shot, so the pill and the head band already wear its colour.
|
||||
prepare: async page => {
|
||||
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
|
||||
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await beat(page, 300)
|
||||
},
|
||||
// The Category eyebrow and the row of pills: pill, row, field.
|
||||
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
|
||||
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
|
||||
},
|
||||
{
|
||||
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
|
||||
act: async page => {
|
||||
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deleteNote(page, NOTE.title),
|
||||
},
|
||||
'shared-links': {
|
||||
guide: guide('shared-links'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
|
||||
act: async page => {
|
||||
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
// The title sits in the head band, the address in the Link field under it.
|
||||
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
|
||||
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: linkDialog,
|
||||
act: async page => {
|
||||
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
|
||||
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => { await linkChip(page, LINK.title).hover() },
|
||||
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'),
|
||||
},
|
||||
{
|
||||
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'),
|
||||
act: async page => {
|
||||
// Exact: an accessible name is matched as a substring, and "Unpin link"
|
||||
// holds "Pin link", so the loose form would find both buttons.
|
||||
await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click()
|
||||
await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/)
|
||||
await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deleteLink(page, LINK.title),
|
||||
},
|
||||
'create-poll': {
|
||||
guide: guide('create-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
|
||||
// The Question eyebrow, the box and the Markdown supported hint under it.
|
||||
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
|
||||
},
|
||||
// The Options field: box, its row, the column of rows, the field with its eyebrow.
|
||||
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
|
||||
},
|
||||
{
|
||||
// The label drops the string's own "+"; the button draws a plus icon instead.
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
|
||||
act: async page => {
|
||||
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
|
||||
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
|
||||
await beat(page, 300)
|
||||
},
|
||||
},
|
||||
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
|
||||
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
|
||||
{
|
||||
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
|
||||
act: async page => {
|
||||
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
|
||||
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: page => deletePoll(page, POLL.question),
|
||||
},
|
||||
'vote-poll': {
|
||||
guide: guide('vote-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }),
|
||||
act: async page => {
|
||||
await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click()
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
// Both bars and both percentages, so the step about reading them has them.
|
||||
only(page => pollCard(page, SEEDED_POLL).locator('> div').last()),
|
||||
{
|
||||
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }),
|
||||
act: async page => {
|
||||
await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click()
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
|
||||
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
|
||||
],
|
||||
cleanup: page => resetVote(page, SEEDED_POLL, 0),
|
||||
},
|
||||
'close-poll': {
|
||||
guide: guide('close-poll'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
|
||||
{
|
||||
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
|
||||
act: async page => {
|
||||
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The last step normally already removed it; this is for a run that stopped short.
|
||||
cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question),
|
||||
},
|
||||
'whats-next': {
|
||||
guide: guide('whats-next'),
|
||||
start,
|
||||
steps: [
|
||||
only(nextPanel),
|
||||
// The time column is the first child of a row; the name, the address and the chips are its last.
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
|
||||
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
|
||||
],
|
||||
// Read-only panel: the guide writes nothing.
|
||||
},
|
||||
'trip-chat': {
|
||||
guide: guide('trip-chat'),
|
||||
start,
|
||||
steps: [
|
||||
{
|
||||
prepare: page => typeInto(page, chatBox(page), CHAT.mine),
|
||||
target: composer,
|
||||
act: async page => {
|
||||
await chatBox(page).press('Enter')
|
||||
await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await typeInto(page, chatBox(page), CHAT.emoji)
|
||||
await emojiButton(page).click()
|
||||
await expect(emojiPicker(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: emojiPicker,
|
||||
act: async page => {
|
||||
await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click()
|
||||
await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click()
|
||||
await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`)
|
||||
await sendButton(page).click()
|
||||
await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The hover row is pointer-events:none until the bubble under it is hovered,
|
||||
// so the bubble has to be hovered before the button can be reached at all.
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.theirs).first().hover()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }),
|
||||
hover: hoverMessageAction(CHAT.theirs, 'Reply'),
|
||||
act: async page => {
|
||||
await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click()
|
||||
await expect(replyQuote(page)).toContainText(CHAT.theirs)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: page => typeInto(page, chatBox(page), CHAT.answer),
|
||||
target: replyQuote,
|
||||
act: async page => {
|
||||
await chatBox(page).press('Enter')
|
||||
await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(replyQuote(page)).not.toContainText(CHAT.theirs)
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.reactTo).first().click({ button: 'right' })
|
||||
await expect(reactionMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: reactionMenu,
|
||||
act: async page => {
|
||||
await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click()
|
||||
await expect(
|
||||
bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async page => {
|
||||
await page.getByText(CHAT.mine).first().hover()
|
||||
await beat(page, 300)
|
||||
},
|
||||
target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }),
|
||||
hover: hoverMessageAction(CHAT.mine, 'Delete'),
|
||||
act: async page => {
|
||||
await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click()
|
||||
// The bubble shrinks for 400 ms before the request goes out.
|
||||
await page.waitForTimeout(1200)
|
||||
await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
],
|
||||
// No cleanup, deliberately: deleting a message is a soft delete and there is
|
||||
// no undelete over the API, so undoing anything here would only add more
|
||||
// tombstones. This is why trip-chat is the last guide of the screen.
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureCollabFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered collab guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-collab', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripCollabContext.id, start)
|
||||
})
|
||||
|
||||
for (const id of tripCollabContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { day, long, short } from '../dates'
|
||||
import { seededTrip, ensureCostsFixtures } from './fixtures'
|
||||
import { openTrip, modal } from './trip-shared'
|
||||
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Costs tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan",
|
||||
* which keeps its books in JPY with one expense in EUR and one transfer already
|
||||
* recorded, so the frozen rate and the settle-up column both have something
|
||||
* real to show. What a guide writes it takes away again in `cleanup`, the
|
||||
* settlements included, because the next guide reads the same balances.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripCostsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */
|
||||
const UNPAID = 'Airport transfer (Narita Express)'
|
||||
/** The seeded expense in euros, the one carrying a frozen rate. */
|
||||
const SEEDED_EUR = 'Flights FRA → HND'
|
||||
/** The seeded flight booking, on the Transports tab because its type is flight. */
|
||||
const BOOKING = 'LH716 FRA → HND'
|
||||
/** A seeded expense costs-table gives its days to. */
|
||||
const JR_PASS = 'JR Pass (14 days)'
|
||||
const DINNER = 'Kaiseki dinner in Gion'
|
||||
const UPGRADE = 'Shinkansen seat upgrade'
|
||||
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: day(-4) }
|
||||
/** The row costs-table adds to Food & drink; the table names a new row this until it is renamed. */
|
||||
const MATCHA = 'Matcha in Uji'
|
||||
const NEW_ROW = 'New Entry'
|
||||
/** Where the tab keeps the list/table choice (CostsPanel's COSTS_VIEW_KEY). */
|
||||
const VIEW_KEY = 'trek:costs-view'
|
||||
|
||||
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
|
||||
const openCosts = async (p: Page): Promise<void> => {
|
||||
await openTrip(p, { tab: 'finanzplan' })
|
||||
await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
}
|
||||
|
||||
/** A row of the ledger by the expense's name. */
|
||||
const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first()
|
||||
/**
|
||||
* The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child,
|
||||
* so a locator scoped to the row itself would never find it.
|
||||
*/
|
||||
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
|
||||
/**
|
||||
* The transfer settle-up records for jonas, in the display currency. The seed
|
||||
* already holds one from jonas, in euros, which carries the converted line;
|
||||
* leaving that one out keeps the guide off the seed's own payment.
|
||||
*/
|
||||
const newPayRow = (page: Page) =>
|
||||
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: 'jonas' }).filter({ hasNotText: '€' }).first()
|
||||
/**
|
||||
* A block of the expense editor, found by its own label: the <section> of
|
||||
* Who paid? and Split, the grey group that holds Total amount, Currency and Day.
|
||||
*/
|
||||
const block = (page: Page, label: string) =>
|
||||
modal(page)
|
||||
.getByText(label, { exact: true })
|
||||
.first()
|
||||
.locator('xpath=ancestor::*[self::section or contains(@class,"rounded-[16px]")][1]')
|
||||
/** A card of the right-hand column: each is a <section> named by its heading. */
|
||||
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
|
||||
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
|
||||
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
|
||||
|
||||
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
|
||||
/** The name, typed into the head of the expense editor. */
|
||||
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
|
||||
/** The category pill under the name, and the list it opens (a portal named after it). */
|
||||
const categoryPill = (page: Page) => modal(page).getByRole('button', { name: /^Category:/ })
|
||||
const categoryList = (page: Page) => page.getByRole('group', { name: 'Category', exact: true })
|
||||
/** The bar's button; the editor's own save carries the same words, so it is scoped to the modal. */
|
||||
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
|
||||
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
|
||||
const settleButtons = (page: Page) => card(page, 'Settle up').getByRole('button', { name: 'Settle', exact: true })
|
||||
/**
|
||||
* One suggested transfer in the Settle up card, by the initial on its first
|
||||
* avatar ("J" for jonas; the names are only in the tooltip). The card lists the
|
||||
* flows in whatever order the settlement endpoint returns them, so taking the
|
||||
* first Settle button would settle whichever transfer happened to come first.
|
||||
*/
|
||||
const settleRow = (page: Page, initial: string) =>
|
||||
card(page, 'Settle up').locator(`xpath=.//div[span[1][normalize-space(.)="${initial}"]]/..`)
|
||||
|
||||
// ── The bar's filters ────────────────────────────────────────────────────────
|
||||
const searchBox = (page: Page) => page.getByPlaceholder('Search expenses…')
|
||||
const filterButton = (page: Page) => page.getByRole('button', { name: 'Filter', exact: true })
|
||||
/** The popover the funnel opens: the owner switch, the categories and the days. */
|
||||
const filterMenu = (page: Page) => page.getByRole('menu')
|
||||
async function openFilter(page: Page): Promise<void> {
|
||||
if (!(await filterMenu(page).isVisible().catch(() => false))) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
async function closeFilter(page: Page): Promise<void> {
|
||||
if (await filterMenu(page).isVisible().catch(() => false)) await filterButton(page).click()
|
||||
await expect(filterMenu(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
// ── The table view ───────────────────────────────────────────────────────────
|
||||
const table = (page: Page) => page.getByRole('table')
|
||||
/** A category's group of the table: a <tbody> opened by a button with its name and count. */
|
||||
const group = (page: Page, category: string) =>
|
||||
table(page)
|
||||
.locator('tbody')
|
||||
.filter({ has: page.getByRole('button', { name: new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\d+$`) }) })
|
||||
const tableRow = (page: Page, name: string) => table(page).getByRole('row').filter({ hasText: name }).first()
|
||||
const summary = (page: Page) => page.getByRole('region', { name: 'Summary', exact: true })
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* A dialog that closes hands the focus back to the button that opened it, and
|
||||
* a focused button wears its outline and its tooltip, the tooltip at wherever
|
||||
* the button stood before the next picture scrolled: both would stand in that
|
||||
* picture as if they were part of it.
|
||||
*/
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
await dropFocus(page)
|
||||
}
|
||||
}
|
||||
|
||||
type BudgetRow = { id: number; name: string; days?: number | null }
|
||||
|
||||
/** What the JR Pass's Days held before costs-table typed into it; cleanup puts it back. */
|
||||
let passDaysBefore: number | null = null
|
||||
|
||||
async function budgetItems(page: Page): Promise<BudgetRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget`)
|
||||
return ((await res.json()) as { items: BudgetRow[] }).items
|
||||
}
|
||||
|
||||
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const item of (await budgetItems(page)).filter(i => names.includes(i.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createExpense(
|
||||
page: Page,
|
||||
item: { name: string; category: string; total_price: number; currency: string; expense_date: string },
|
||||
): Promise<void> {
|
||||
const { tripId, memberIds } = seededTrip()
|
||||
const created = await page.request.post(`/api/trips/${tripId}/budget`, {
|
||||
data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] },
|
||||
})
|
||||
if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
|
||||
async function settlementIds(page: Page): Promise<number[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`)
|
||||
const { settlements } = (await res.json()) as { settlements: { id: number }[] }
|
||||
return settlements.map(s => s.id)
|
||||
}
|
||||
|
||||
/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */
|
||||
let transfersBefore: number[] = []
|
||||
|
||||
/** Open the expense editor's currency list, search for a code and take it. */
|
||||
async function pickCurrency(page: Page, code: string): Promise<void> {
|
||||
await modal(page).getByRole('button', { name: /^JPY/ }).first().click()
|
||||
await beat(page, 300)
|
||||
await dropdown(page).locator('input').first().fill(code)
|
||||
await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click()
|
||||
await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'add-expense': {
|
||||
guide: guide('add-expense'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
target: addExpense,
|
||||
act: async p => {
|
||||
await addExpense(p).click()
|
||||
await expect(nameBox(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, nameBox(p), DINNER)
|
||||
await settle(p)
|
||||
},
|
||||
target: nameBox,
|
||||
},
|
||||
{
|
||||
// The list open under the pill, so the picture shows what there is to pick.
|
||||
prepare: async p => {
|
||||
await categoryPill(p).click()
|
||||
await expect(categoryList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: categoryPill,
|
||||
act: async p => {
|
||||
await categoryList(p).getByRole('button', { name: 'Food & drink' }).click()
|
||||
await expect(categoryList(p)).toHaveCount(0)
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Food & drink/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await amounts(p).first().fill('18600')
|
||||
await expect(saveExpense(p)).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
// The grey group: Total amount, then Currency and Day beside it.
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
only(p => block(p, 'Who paid?')),
|
||||
{
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, DINNER),
|
||||
},
|
||||
|
||||
'expense-payers': {
|
||||
guide: guide('expense-payers'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(block(p, 'Who paid?')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Who paid?'),
|
||||
},
|
||||
// Ringed, not clicked: the Ryokan keeps its payer.
|
||||
only(p => modal(p).getByRole('radio', { name: 'No one paid yet' })),
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'Multiple people paid' }).click()
|
||||
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
|
||||
await modal(p).getByTestId('payer-toggle').nth(1).click()
|
||||
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Who paid?'),
|
||||
act: async p => {
|
||||
await block(p, 'Who paid?').getByRole('button', { name: 'One person paid' }).click()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => expRow(p, UNPAID),
|
||||
// On the flag, whose tooltip says what it means; the row's centre is the note.
|
||||
hover: async p => {
|
||||
await expRow(p, UNPAID).getByText('Unfinished').hover()
|
||||
},
|
||||
act: async p => {
|
||||
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'split-expense': {
|
||||
guide: guide('split-expense'),
|
||||
start: async p => {
|
||||
await createExpense(p, LUNCH)
|
||||
await openCosts(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(block(p, 'Split')).toBeVisible()
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'false')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split'),
|
||||
act: async p => {
|
||||
await block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true }).click()
|
||||
await expect(block(p, 'Split').getByRole('checkbox', { name: 'jonas', exact: true })).toHaveAttribute('aria-checked', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The badges under the list: how many share it, and what one share is.
|
||||
only(p => block(p, 'Split').getByText(/ per person$/).locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await block(p, 'Split').getByRole('button', { name: 'Custom', exact: true }).click()
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(0).fill('1000')
|
||||
await expect(block(p, 'Split').getByText(/^Sum of splits: /)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split').getByText(/^Sum of splits: /),
|
||||
act: async p => {
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(1).fill('1900')
|
||||
await block(p, 'Split').locator('input[inputmode="decimal"]').nth(2).fill('1900')
|
||||
await expect(block(p, 'Split').getByText('Split matches total')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const split = block(p, 'Split')
|
||||
await split.getByRole('button', { name: 'Ticket', exact: true }).click()
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').first(), 'Tamagoyaki')
|
||||
await split.locator('input[inputmode="decimal"]').nth(0).fill('1200')
|
||||
await split.getByRole('button', { name: 'Add item' }).click()
|
||||
await typeInto(p, split.getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
|
||||
await split.locator('input[inputmode="decimal"]').nth(1).fill('900')
|
||||
// The second line is shared by two of the three travelers.
|
||||
await split
|
||||
.getByPlaceholder('Item name')
|
||||
.nth(1)
|
||||
.locator('xpath=ancestor::div[2]')
|
||||
.getByRole('checkbox', { name: /jonas/ })
|
||||
.click()
|
||||
await expect(amounts(p).first()).toHaveValue('2100.00')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Split').getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByText('Individual shares', { exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, LUNCH.name),
|
||||
},
|
||||
|
||||
'expense-currency': {
|
||||
guide: guide('expense-currency'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await addExpense(p).click()
|
||||
await expect(nameBox(p)).toBeVisible()
|
||||
await typeInto(p, nameBox(p), UPGRADE)
|
||||
await amounts(p).first().fill('48')
|
||||
// The editor opens on Food & drink; a seat upgrade filed under it would
|
||||
// read wrong on the ledger row this guide's last picture is of.
|
||||
await categoryPill(p).click()
|
||||
await categoryList(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(categoryPill(p)).toHaveAccessibleName(/Transport/)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => block(p, 'Total amount'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: /^JPY/ }).first().click()
|
||||
await beat(p, 300)
|
||||
await dropdown(p).locator('input').first().fill('EUR')
|
||||
await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible()
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(),
|
||||
act: async p => {
|
||||
await dropdown(p).getByRole('button', { name: /^EUR/ }).click()
|
||||
await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible()
|
||||
await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'),
|
||||
act: async p => {
|
||||
// Both sides have to be there: an offline run would show the amount unconverted.
|
||||
await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => expRow(p, UPGRADE),
|
||||
act: async p => {
|
||||
await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
||||
await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteExpenses(p, UPGRADE),
|
||||
},
|
||||
|
||||
'filter-costs': {
|
||||
guide: guide('filter-costs'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, searchBox(p), 'pass')
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await searchBox(p).fill('')
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The owner switch at the top of the popover, on Paid by me.
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).click()
|
||||
await expect(filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'Paid by me', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
||||
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The category list, with the pick marked and the funnel's count above it.
|
||||
target: p => filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await filterMenu(p).getByRole('button', { name: 'All categories', exact: true }).click()
|
||||
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: short(-9), exact: true }).click()
|
||||
// Folded away, or the popover would sit over the banner it produced.
|
||||
await closeFilter(p)
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByText(new RegExp(`^${long(-9)}$`)).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await openFilter(p)
|
||||
await filterMenu(p).getByRole('button', { name: 'Reset filters' }).click()
|
||||
await expect(p.getByText(new RegExp(`^${long(-9)}$`))).toHaveCount(0)
|
||||
await closeFilter(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Clicking it would start a download; the button is what the step is about.
|
||||
only(p => p.getByRole('button', { name: 'Export CSV' })),
|
||||
],
|
||||
},
|
||||
|
||||
'settle-up': {
|
||||
guide: guide('settle-up'),
|
||||
start: async p => {
|
||||
await openCosts(p)
|
||||
transfersBefore = await settlementIds(p)
|
||||
},
|
||||
steps: [
|
||||
only(p => card(p, 'Settle up')),
|
||||
{
|
||||
target: p => settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }),
|
||||
act: async p => {
|
||||
await settleRow(p, 'J').getByRole('button', { name: 'Settle', exact: true }).click()
|
||||
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
|
||||
await expect(newPayRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(newPayRow),
|
||||
{
|
||||
target: p => newPayRow(p).locator('xpath=..').locator('.exp-actions'),
|
||||
act: async p => {
|
||||
await newPayRow(p).locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, 'Settle up').getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Add payment' })).toBeVisible()
|
||||
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('dialog', { name: 'Add payment' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add payment' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Ringed and left alone: one click would record every open transfer and
|
||||
// flatten the balances every later picture is taken against.
|
||||
only(p => p.getByRole('button', { name: 'Settle up', exact: true })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
for (const id of await settlementIds(p)) {
|
||||
if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'final-budget': {
|
||||
guide: guide('final-budget'),
|
||||
start: openCosts,
|
||||
steps: [
|
||||
only(p => card(p, 'Balances')),
|
||||
only(p => card(p, 'Final budget')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(p.getByText('Expenses paid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => card(p, 'Final budget').locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
|
||||
},
|
||||
{
|
||||
// The second "Expenses paid" is the heading of the list the figure is made of.
|
||||
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await card(p, 'Final budget').getByRole('button').first().click()
|
||||
await expect(card(p, 'Final budget').locator('[aria-expanded="true"]')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'expense-from-booking': {
|
||||
guide: guide('expense-from-booking'),
|
||||
start: async p => {
|
||||
await openTrip(p, { tab: 'transports' })
|
||||
await expect(p.getByRole('article', { name: BOOKING }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create expense' }).click()
|
||||
await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await amounts(p).first().fill('890')
|
||||
await pickCurrency(p, 'EUR')
|
||||
},
|
||||
target: saveExpense,
|
||||
act: async p => {
|
||||
await saveExpense(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await p.getByRole('article', { name: BOOKING }).first().getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByText('Linked expenses', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
// The block sits at the foot of a long form: bring it into the picture.
|
||||
await modal(p).getByText('Linked expenses', { exact: true }).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await deleteExpenses(p, BOOKING)
|
||||
},
|
||||
},
|
||||
|
||||
'costs-table': {
|
||||
guide: guide('costs-table'),
|
||||
start: async p => {
|
||||
// What the pass's Days cell holds before the guide types into it.
|
||||
const pass = (await budgetItems(p)).find(i => i.name === JR_PASS)
|
||||
if (!pass) throw new Error(`the seed has no "${JR_PASS}"`)
|
||||
passDaysBefore = pass.days ?? null
|
||||
await openCosts(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Table', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Table', exact: true }).click()
|
||||
await expect(table(p)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(tableRow(p, SEEDED_EUR)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// One category as a whole: its head with the count and the subtotal, its rows, its add button.
|
||||
only(p => group(p, 'Transport')),
|
||||
{
|
||||
// The pass is good for fourteen days, and its Days cell is still empty:
|
||||
// typed in, Per Day and P. p / Day fill in beside it.
|
||||
prepare: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('button', { name: /^Days/ }).click()
|
||||
const field = tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' })
|
||||
await expect(field).toBeFocused()
|
||||
await field.fill('14')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }),
|
||||
act: async p => {
|
||||
await tableRow(p, JR_PASS).getByRole('textbox', { name: 'Days' }).press('Enter')
|
||||
await expect(tableRow(p, JR_PASS).getByRole('button', { name: 'Days: 14' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hovered for the picture, so the lock's reason stands above it.
|
||||
target: p => tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }),
|
||||
act: async p => {
|
||||
await tableRow(p, SEEDED_EUR).getByRole('button', { name: /^Total: / }).click()
|
||||
await expect(p.getByRole('dialog', { name: 'Edit expense' })).toBeVisible()
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }),
|
||||
act: async p => {
|
||||
await group(p, 'Food & drink').getByRole('button', { name: 'Add expense' }).click()
|
||||
const name = table(p).getByRole('textbox', { name: 'Name' })
|
||||
await expect(name.or(tableRow(p, NEW_ROW)).first()).toBeVisible({ timeout: 15_000 })
|
||||
if (!(await name.isVisible().catch(() => false))) {
|
||||
await tableRow(p, NEW_ROW).getByRole('button', { name: /^Name/ }).click()
|
||||
}
|
||||
await name.fill(MATCHA)
|
||||
await name.press('Enter')
|
||||
await expect(tableRow(p, MATCHA)).toBeVisible({ timeout: 15_000 })
|
||||
// A total too, the same way, so the row reads like a real one.
|
||||
await tableRow(p, MATCHA).getByRole('button', { name: /^Total/ }).click()
|
||||
const total = tableRow(p, MATCHA).getByRole('textbox', { name: 'Total' })
|
||||
await total.fill('1800')
|
||||
await total.press('Enter')
|
||||
await expect(tableRow(p, MATCHA).getByRole('button', { name: /^Total: .*1,800/ })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await summary(p).getByRole('button', { name: 'Payer', exact: true }).click()
|
||||
await expect(summary(p).getByText('No payer yet')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: summary,
|
||||
},
|
||||
],
|
||||
// Back to the ledger, so every other guide finds it, and the seed as it was.
|
||||
cleanup: async p => {
|
||||
const list = p.getByRole('button', { name: 'List', exact: true })
|
||||
if (await list.isVisible().catch(() => false)) await list.click()
|
||||
await p.evaluate(key => { try { localStorage.setItem(key, 'list') } catch { /* no storage: nothing to put back */ } }, VIEW_KEY)
|
||||
await deleteExpenses(p, MATCHA, NEW_ROW)
|
||||
const { tripId } = seededTrip()
|
||||
for (const item of (await budgetItems(p)).filter(i => i.name === JR_PASS)) {
|
||||
await p.request.put(`/api/trips/${tripId}/budget/${item.id}`, { data: { days: passDaysBefore } })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureCostsFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered costs guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-costs', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripCostsContext.id, openCosts)
|
||||
})
|
||||
|
||||
for (const id of tripCostsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureDayDetailFixtures, KYOTO_HOTEL, TOKYO_HOTEL, EVENING_WALK } from './fixtures'
|
||||
import { openTrip } from './trip-shared'
|
||||
import { tripDayDetailContext, tripDayDetailGuides } from '../../src/help/contexts/tripDayDetail'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the day details panel of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripDayDetail.ts`. They run on the seeded "Autumn in
|
||||
* Japan" with the day fixtures in place, so day 1 carries the flight and the
|
||||
* Tokyo hotel and day 5 the train and its evening walk; what a guide writes it
|
||||
* takes back in `cleanup`, so the next one starts from the same seed.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripDayDetailGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The name the rename guide gives day 5, and takes away again. */
|
||||
const DAY_NAME = 'Arrival in Kyoto'
|
||||
|
||||
/** The panel itself: the only `fixed z-50` in the client. */
|
||||
const panel = (page: Page) => page.locator('div.fixed.z-50')
|
||||
/** A day's header in the days column; its name is number, weather, title, date. */
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
/**
|
||||
* The hotel picker is a portal of its own with neither `trek-modal-backdrop`
|
||||
* nor `trek-backdrop-enter`, so `modal()` and `confirmDialog()` never see it;
|
||||
* the card is the second `role="presentation"` and holds the day range.
|
||||
*/
|
||||
const picker = (page: Page) => page.getByRole('dialog').filter({ hasText: 'Apply to days' })
|
||||
/** The row of the stay's three times, with Confirmation right under it. */
|
||||
const times = (page: Page) => picker(page).getByPlaceholder('14:00').locator('xpath=ancestor::div[contains(@class,"grid-cols-3")][1]')
|
||||
/**
|
||||
* A block of the panel by its heading. Every block is a <section> whose first
|
||||
* row carries the heading, so the section is the block with everything in it.
|
||||
*/
|
||||
const block = (page: Page, heading: string) =>
|
||||
panel(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
|
||||
/** The accommodation block: the stays and the add button. */
|
||||
const stays = (page: Page) => block(page, 'Accommodation')
|
||||
/** One booked stay: an <article> card inside that block. */
|
||||
const stayCard = (page: Page, name: string) => stays(page).locator('article').filter({ hasText: name }).first()
|
||||
/** The caption that opens the weather block. */
|
||||
const forecast = (page: Page) => panel(page).getByText(/^Forecast for /)
|
||||
/** The weather's pills (rain, wind, sunrise, sunset), at the right end of the summary row. */
|
||||
const chips = (page: Page) => panel(page).locator('svg.lucide-sunrise').locator('xpath=ancestor::div[1]')
|
||||
/** The summary row's numbers: the temperature, the low and high, the condition. */
|
||||
const summary = (page: Page) => forecast(page).locator('xpath=following-sibling::div[1]/div[1]')
|
||||
/** The hourly strip under its own heading. */
|
||||
const hourly = (page: Page) => panel(page).getByText('Hourly Forecast', { exact: true }).locator('xpath=..')
|
||||
/** The day's reservations block. */
|
||||
const bookings = (page: Page) => block(page, 'Reservations')
|
||||
/** One booking row: on the desktop a button that opens the booking, named with its title. */
|
||||
const bookingRow = (page: Page, title: string) => panel(page).getByRole('button', { name: title })
|
||||
/** The booking a row opens, named by its title. */
|
||||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
|
||||
/**
|
||||
* Select a day by its title rather than by the middle of its header: a day with
|
||||
* a night booked over it carries a hotel button there, and that button opens the
|
||||
* place instead of selecting the day.
|
||||
*/
|
||||
const selectDay = async (page: Page, n: number): Promise<void> => {
|
||||
await dayHeader(page, n).getByText(`Day ${n}`, { exact: true }).click()
|
||||
await expect(panel(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Open the trip with day `n`'s panel open; `openTrip`'s own day closes it again. */
|
||||
const openDay = async (page: Page, n: number): Promise<void> => {
|
||||
await openTrip(page, { day: null })
|
||||
await selectDay(page, n)
|
||||
}
|
||||
|
||||
interface Stay {
|
||||
id: number
|
||||
place_id: number | null
|
||||
place_name?: string | null
|
||||
start_day_id: number
|
||||
end_day_id: number
|
||||
}
|
||||
|
||||
async function stayOf(page: Page, name: string): Promise<Stay | undefined> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/accommodations`)
|
||||
const { accommodations } = (await res.json()) as { accommodations: Stay[] }
|
||||
return accommodations.find(a => a.place_name === name)
|
||||
}
|
||||
|
||||
/** Cancel a night again. The stop it put on the day and its Hotel booking go with it. */
|
||||
async function removeStay(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const stay = await stayOf(page, name)
|
||||
if (stay) await page.request.delete(`/api/trips/${tripId}/accommodations/${stay.id}`)
|
||||
}
|
||||
|
||||
/** The update route reads every field off the body, so the whole stay goes back. */
|
||||
async function restoreTokyoStay(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const stay = await stayOf(page, TOKYO_HOTEL)
|
||||
if (!stay) return
|
||||
await page.request.put(`/api/trips/${tripId}/accommodations/${stay.id}`, {
|
||||
data: {
|
||||
place_id: stay.place_id,
|
||||
start_day_id: stay.start_day_id,
|
||||
end_day_id: stay.end_day_id,
|
||||
check_in: '15:00',
|
||||
check_in_end: null,
|
||||
check_out: '11:00',
|
||||
confirmation: 'GRC-7731',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Give day 5 its number back: `selectDay` finds a day by "5 … Day 5 ", which a
|
||||
* renamed day no longer answers to, and every later guide uses it.
|
||||
*/
|
||||
async function resetDayTitle(page: Page, index: number): Promise<void> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
await page.request.put(`/api/trips/${tripId}/days/${dayIds[index]}`, { data: { title: null } })
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'day-panel': {
|
||||
guide: guide('day-panel'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => dayHeader(p, 1),
|
||||
act: async p => {
|
||||
await selectDay(p, 1)
|
||||
await expect(stays(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
only(p => panel(p).getByText('Day 1', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
// The forecast is asked for when the panel opens, and it is a round trip
|
||||
// to Open-Meteo; without the wait the picture catches the spinner.
|
||||
prepare: async p => { await expect(forecast(p)).toBeVisible({ timeout: 20_000 }) },
|
||||
target: p => forecast(p).locator('xpath=..'),
|
||||
},
|
||||
only(bookings),
|
||||
only(stays),
|
||||
{
|
||||
target: p => panel(p).getByRole('button', { name: 'Collapse' }),
|
||||
act: async p => {
|
||||
// Every day card carries a chevron with the same label, so the panel's
|
||||
// own is reached through the panel.
|
||||
await panel(p).getByRole('button', { name: 'Collapse' }).click()
|
||||
await expect(panel(p).getByRole('button', { name: 'Expand' })).toBeVisible()
|
||||
await expect(stays(p)).toBeHidden()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'day-weather': {
|
||||
guide: guide('day-weather'),
|
||||
start: async p => {
|
||||
await openDay(p, 5)
|
||||
// Day 5 has one located stop, so the forecast is that stop's.
|
||||
await expect(panel(p).getByText('Forecast for Fushimi Inari Taisha')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
steps: [
|
||||
only(forecast),
|
||||
only(summary),
|
||||
only(chips),
|
||||
only(hourly),
|
||||
],
|
||||
},
|
||||
'rename-day': {
|
||||
guide: guide('rename-day'),
|
||||
start: p => openDay(p, 5),
|
||||
steps: [
|
||||
only(p => panel(p).getByText('Day 5', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
// Every booking row of the open day carries an Edit of its own in the
|
||||
// days column (DayPlanSidebar), so the header's pencil is reached
|
||||
// through the panel; inside it, it is the only one.
|
||||
target: p => panel(p).getByRole('button', { name: 'Edit', exact: true }),
|
||||
act: async p => {
|
||||
await panel(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(panel(p).getByPlaceholder('Day 5')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, panel(p).getByPlaceholder('Day 5'), DAY_NAME) },
|
||||
target: p => panel(p).getByPlaceholder('Day 5'),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Enter')
|
||||
await expect(panel(p).getByText(DAY_NAME, { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('button', { name: new RegExp(`^5 .*${DAY_NAME} `) })),
|
||||
],
|
||||
cleanup: p => resetDayTitle(p, 4),
|
||||
},
|
||||
'add-accommodation': {
|
||||
guide: guide('add-accommodation'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
// The fixture put the hotel in the trip as a place; the picker offers
|
||||
// nothing else, and the rows of the column are options.
|
||||
only(p => p.getByRole('option', { name: new RegExp(`^${KYOTO_HOTEL.name}`) })),
|
||||
{
|
||||
prepare: async p => { await selectDay(p, 5) },
|
||||
target: p => p.getByRole('button', { name: 'Add accommodation' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add accommodation' }).click()
|
||||
await expect(picker(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The two day selects are named Start and End; their options are
|
||||
// portalled to the body, outside the picker.
|
||||
prepare: async p => {
|
||||
await picker(p).getByRole('button', { name: 'End', exact: true }).click()
|
||||
await p.getByRole('button', { name: /^Day 7/ }).last().click()
|
||||
await expect(picker(p).getByRole('button', { name: 'End', exact: true })).toContainText('Day 7')
|
||||
await settle(p)
|
||||
},
|
||||
// The row of Start, End and All: the All button's parent.
|
||||
target: p => picker(p).getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
// The three time fields normalise what is typed; the labels carry no
|
||||
// `for`, so every field is found by its placeholder.
|
||||
prepare: async p => {
|
||||
await picker(p).getByPlaceholder('14:00').fill('15:00')
|
||||
await picker(p).getByPlaceholder('22:00').fill('20:00')
|
||||
await picker(p).getByPlaceholder('11:00').fill('11:00')
|
||||
await typeInto(p, picker(p).getByPlaceholder('ABC-12345'), 'KNR-8842')
|
||||
await settle(p)
|
||||
},
|
||||
target: times,
|
||||
},
|
||||
{
|
||||
// The category is a pill over the list; its choices open in a list of their own.
|
||||
prepare: async p => {
|
||||
await picker(p).getByRole('button', { name: /^Category:/ }).click()
|
||||
await p.getByRole('group', { name: 'Category' }).getByRole('button', { name: 'Hotel', exact: true }).click()
|
||||
await expect(p.getByRole('group', { name: 'Category' })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: new RegExp(`^${KYOTO_HOTEL.name}`) }).click()
|
||||
await expect(picker(p).getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(picker(p)).toHaveCount(0)
|
||||
await expect(stayCard(p, KYOTO_HOTEL.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => removeStay(p, KYOTO_HOTEL.name),
|
||||
},
|
||||
'edit-accommodation': {
|
||||
guide: guide('edit-accommodation'),
|
||||
start: async p => {
|
||||
// The day fixtures book the Tokyo hotel over the first nights, so day 1
|
||||
// is its check-in day.
|
||||
await openDay(p, 1)
|
||||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
steps: [
|
||||
only(p => stayCard(p, TOKYO_HOTEL)),
|
||||
{
|
||||
// Neither the pencil nor the × carries a label; the icon is the handle.
|
||||
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)'),
|
||||
act: async p => {
|
||||
await stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-pencil)').click()
|
||||
await expect(picker(p).getByText('Edit accommodation')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await picker(p).getByPlaceholder('11:00').fill('10:00')
|
||||
await picker(p).getByPlaceholder('ABC-12345').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: times,
|
||||
},
|
||||
{
|
||||
target: p => picker(p).getByRole('button', { name: 'Save' }),
|
||||
act: async p => {
|
||||
await picker(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(picker(p)).toHaveCount(0)
|
||||
// Day 1 is the arrival: the card shows the check-in window, not the new check-out.
|
||||
await expect(stayCard(p, TOKYO_HOTEL)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Deliberately not performed: the × removes the stay without a question,
|
||||
// and with it the seed's hotel booking and the stop it put on the day.
|
||||
prepare: async p => { await stayCard(p, TOKYO_HOTEL).hover() },
|
||||
target: p => stayCard(p, TOKYO_HOTEL).locator('button:has(svg.lucide-x)'),
|
||||
},
|
||||
],
|
||||
cleanup: restoreTokyoStay,
|
||||
},
|
||||
'day-bookings': {
|
||||
guide: guide('day-bookings'),
|
||||
start: p => openDay(p, 5),
|
||||
steps: [
|
||||
only(bookings),
|
||||
only(p => bookingRow(p, EVENING_WALK)),
|
||||
only(p => bookingRow(p, 'Nozomi 21')),
|
||||
{
|
||||
// A row opens its booking over the plan; Edit at its foot is where it changes.
|
||||
prepare: async p => {
|
||||
await bookingRow(p, 'Nozomi 21').click()
|
||||
await expect(bookingDetail(p, 'Nozomi 21')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => bookingDetail(p, 'Nozomi 21').getByRole('button', { name: 'Edit', exact: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDayDetailFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered day-detail guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDayDetailContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-day-detail', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
// Day 1 is the one day that has all four blocks at once: the forecast for its
|
||||
// first stop, the flight, and the hotel it checks into.
|
||||
await captureHero(page, tripDayDetailContext.id, p => openDay(p, 1))
|
||||
})
|
||||
|
||||
for (const id of tripDayDetailContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureDaysFixtures } from './fixtures'
|
||||
import { openTrip, openTripOnDay, modal, portalDialog, selectDay, closeDayDetails, closeMenu, dayDetails } from './trip-shared'
|
||||
import { tripDaysContext, tripDaysGuides } from '../../src/help/contexts/tripDays'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the days column of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripDays.ts`. The seeded trip gets a timed stop, a
|
||||
* reservation at a stop, a train and a hotel first, so the cards show every
|
||||
* kind of row; a guide that changes the plan puts it back in `cleanup`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripDaysGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
|
||||
/** The note the day-note guide writes: a name for the card, markdown underneath. */
|
||||
const NOTE = { title: 'Rain plan', body: '**Tokyo National Museum** instead of the shrine, tickets at the gate.' }
|
||||
|
||||
/** A day card's header button. */
|
||||
/**
|
||||
* The day the route guides read. Day 6 is the only one whose two stops sit next
|
||||
* to each other: day 1's are separated by the flight, so it has no leg of its
|
||||
* own and its only connector is the one to the booked night, which depends on a
|
||||
* fixture. Day 6 also carries a reservation at a stop, so one card shows every
|
||||
* row the guide talks about.
|
||||
*/
|
||||
const ROUTED_DAY = 6
|
||||
/** The pencil that renames the open day, in its details panel. */
|
||||
const renameDay = (page: Page) => dayDetails(page).getByRole('button', { name: 'Edit', exact: true })
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
/**
|
||||
* A stop row. Every row of a day card is a div with `role="button"`; a stop is
|
||||
* the one whose accessible name opens with the label of the lock on its
|
||||
* picture, which is what tells it from the note and booking rows beside it.
|
||||
*/
|
||||
const stop = (page: Page, name: string) =>
|
||||
page
|
||||
.getByRole('button', { name: /^(Keep position during route optimization|Click to unlock) / })
|
||||
.filter({ hasText: name })
|
||||
.first()
|
||||
/** A booking row of a day: its phase (Departure / Arrival) and title make its name. */
|
||||
const bookingRow = (page: Page, name: RegExp) => page.getByRole('button', { name })
|
||||
/** The note the day-note guide left on day 2, as a row of the card. */
|
||||
const noteRow = (page: Page) => page.getByRole('button', { name: new RegExp(`^${NOTE.title}`) })
|
||||
/** A row of the places column. */
|
||||
const placeRow = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
/** The "+ Day" at the end of a places row: it puts the place on the open day. */
|
||||
const toOpenDay = (page: Page, name: string) => placeRow(page, name).getByRole('button', { name: '+ Day' })
|
||||
/** The "+" in a day's head band; the note, the transports and the stay are in the menu it opens. */
|
||||
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
|
||||
/**
|
||||
* The context menu of a stop, a note or a leg. Tooltip renders into the same
|
||||
* `.trek-popover-enter` class and a click on a connector leaves its tooltip
|
||||
* standing, so the menu is the popover that is not one.
|
||||
*/
|
||||
const menu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
|
||||
/** The route bar of the open day: Route, the two hand-offs, Optimize, the modes. */
|
||||
const routeBar = (page: Page) => page.getByRole('button', { name: 'Route', exact: true }).locator('xpath=..')
|
||||
const toolbar = (page: Page) => page.getByRole('button', { name: 'Export' }).locator('xpath=..')
|
||||
/**
|
||||
* A connector between two stops, once the day's route is computed. The hotel
|
||||
* bookend legs carry the same label and are not between two stops, so they are
|
||||
* filtered out by the hotel icon only they have.
|
||||
*/
|
||||
/**
|
||||
* A leg between two rows of a day, ringed by its travel time. Day 1's is the one
|
||||
* to the booked night: the flight sits between its two stops, so they have no
|
||||
* leg of their own, and the day fixtures put a hotel on the first four nights.
|
||||
*/
|
||||
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
|
||||
/** The note dialog, known by its body field. */
|
||||
const noteBody = (page: Page) => page.getByPlaceholder('Details, links, reminders…')
|
||||
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: noteBody(page) })
|
||||
/** The question a timed stop asks before it moves; also a portal of its own. */
|
||||
const timeConfirm = (page: Page) => portalDialog(page, page.getByText('Remove time?'))
|
||||
/** The reorder popup's panel, the list and its footer, or the question a bin asks in its place. */
|
||||
const reorderPopup = (page: Page) => page.locator('.trek-modal-enter').filter({ hasText: 'Reorder days' })
|
||||
|
||||
async function createSpare(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
|
||||
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip on day 1 and draw its route, so the connectors between the
|
||||
* stops exist. The day stays selected with its details panel up: the panel is
|
||||
* the selection, and the route tools render for the selected day only.
|
||||
*/
|
||||
async function openWithRoute(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, ROUTED_DAY)
|
||||
await page.getByRole('button', { name: 'Route', exact: true }).click()
|
||||
await expect(connector(page)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/** The menu a right-click on a day's head opens. */
|
||||
/** The question before a day is cleared (a ConfirmDialog, which carries no dialog role). */
|
||||
const clearDialog = (page: Page) => page.locator('.trek-modal-enter').filter({ has: page.getByRole('heading', { name: /^Clear .+\?$/ }) }).last()
|
||||
const dayMenu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Clear day' }) }).last()
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'clear-day': {
|
||||
guide: guide('clear-day'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await dayHeader(p, 2).click({ button: 'right' })
|
||||
await expect(dayMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: dayMenu,
|
||||
act: async p => {
|
||||
await dayMenu(p).getByRole('button', { name: 'Clear day' }).click()
|
||||
await expect(p.getByRole('heading', { name: /^Clear .+\?$/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: clearDialog,
|
||||
act: async p => {
|
||||
await clearDialog(p).getByRole('button', { name: 'Clear day' }).click()
|
||||
await expect(clearDialog(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// Undo puts the seed's stops back on the day for the guides after this one.
|
||||
const undo = p.getByRole('button', { name: 'Undo' }).last()
|
||||
if (await undo.isVisible()) await undo.click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
'read-day-plan': {
|
||||
guide: guide('read-day-plan'),
|
||||
start: openWithRoute,
|
||||
steps: [
|
||||
only(p => dayHeader(p, ROUTED_DAY)),
|
||||
only(p => stop(p, 'Arashiyama Bamboo Grove')),
|
||||
only(p => stop(p, 'Nishiki Market')),
|
||||
only(connector),
|
||||
only(routeBar),
|
||||
],
|
||||
},
|
||||
'place-onto-day': {
|
||||
guide: guide('place-onto-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
// Day 1 stays open: the "+" of a place row and "To day" exist only while
|
||||
// a day is selected, and steps 2 and 4 are about those two.
|
||||
await openTripOnDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => placeRow(p, SPARE.name),
|
||||
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await placeRow(p, SPARE.name).dragTo(dayHeader(p, 2))
|
||||
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The "+" offers the OPEN day, so it is still there with the place on
|
||||
// day 2 — and the place stays on a day, which is what the result says.
|
||||
prepare: async p => { await placeRow(p, SPARE.name).hover() },
|
||||
target: p => toOpenDay(p, SPARE.name),
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Add place to this day' }).first()),
|
||||
only(p => p.getByRole('button', { name: 'Add to the open day' })),
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'reorder-stops': {
|
||||
guide: guide('reorder-stops'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets').locator('svg.lucide-grip-vertical').locator('xpath=..'),
|
||||
dropTo: p => stop(p, 'Senso-ji Temple'),
|
||||
},
|
||||
{
|
||||
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
|
||||
target: p => stop(p, 'teamLab Planets').locator('button:has(svg.lucide-chevron-up)'),
|
||||
},
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets'),
|
||||
dropTo: p => dayHeader(p, 2).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Only a move that breaks the day's chronology asks, and the arrows
|
||||
// never do here: the timed stop is the first row of the day. Dropping
|
||||
// it below the 13:05 departure does, and Cancel leaves the day alone.
|
||||
const booking = bookingRow(p, /Departure.*LH716/)
|
||||
const box = await booking.boundingBox()
|
||||
if (!box) throw new Error('the departure row of day 1 has no box')
|
||||
await stop(p, 'Senso-ji Temple').dragTo(booking, { targetPosition: { x: box.width / 2, y: box.height - 4 } })
|
||||
await expect(timeConfirm(p)).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: timeConfirm,
|
||||
act: async p => {
|
||||
await timeConfirm(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByText('Remove time?')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'set-stop-times': {
|
||||
guide: guide('set-stop-times'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await stop(p, 'teamLab Planets').click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: menu,
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByText('Start', { exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The time pickers are text fields that parse on blur.
|
||||
const times = modal(p).getByPlaceholder(/^(00:00|2:30 PM)$/)
|
||||
await times.first().fill('13:00')
|
||||
await times.first().blur()
|
||||
await times.nth(1).fill('15:00')
|
||||
await times.nth(1).blur()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByText('Start', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Update', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(stop(p, 'teamLab Planets').getByText('13:00')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[0]}/assignments`)
|
||||
const body = (await res.json()) as { assignments?: { id: number; place?: { name: string }; place_name?: string }[] }
|
||||
for (const a of body.assignments ?? []) {
|
||||
if ((a.place?.name ?? a.place_name) === 'teamLab Planets') {
|
||||
await p.request.put(`/api/trips/${tripId}/assignments/${a.id}/time`, { data: { place_time: null, end_time: null } })
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
'remove-from-day': {
|
||||
guide: guide('remove-from-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
// The "+" puts the place on the OPEN day, so day 1 stays selected.
|
||||
await openTripOnDay(p, 1)
|
||||
await placeRow(p, SPARE.name).hover()
|
||||
await toOpenDay(p, SPARE.name).click()
|
||||
await expect(stop(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await stop(p, SPARE.name).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Remove from day' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => menu(p).getByRole('button', { name: 'Remove from day' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Remove from day' }).click()
|
||||
await expect(stop(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => placeRow(p, SPARE.name)),
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'lock-stop': {
|
||||
guide: guide('lock-stop'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await stop(p, 'teamLab Planets').hover() },
|
||||
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }),
|
||||
act: async p => {
|
||||
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Keep position during route optimization' }).click()
|
||||
await expect(stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }),
|
||||
act: async p => {
|
||||
await stop(p, 'teamLab Planets').getByRole('button', { name: 'Click to unlock' }).click()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'day-note': {
|
||||
guide: guide('day-note'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
// The "+" of the day's head band opens the menu; Add Note is its last entry.
|
||||
prepare: async p => {
|
||||
await addToDay(p, 2).click()
|
||||
await expect(menu(p).getByRole('button', { name: 'Add Note' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => menu(p).getByRole('button', { name: 'Add Note' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Add Note' }).click()
|
||||
await expect(noteBody(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The name is typed into the head band and is what the day card shows,
|
||||
// the only required field; Daily Note is the markdown the toolbar formats.
|
||||
await typeInto(p, noteDialog(p).getByPlaceholder('Note', { exact: true }), NOTE.title)
|
||||
await typeInto(p, noteBody(p), NOTE.body)
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: noteDialog,
|
||||
},
|
||||
{
|
||||
target: p => noteDialog(p).getByText('Icon', { exact: true }).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await noteDialog(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(noteDialog(p)).toHaveCount(0)
|
||||
await expect(noteRow(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await noteRow(p).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: noteRow,
|
||||
act: closeMenu,
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/days/${dayIds[1]}/notes`)
|
||||
const body = (await res.json()) as { notes?: { id: number; text: string }[] } | { id: number; text: string }[]
|
||||
const notes = Array.isArray(body) ? body : (body.notes ?? [])
|
||||
for (const note of notes.filter(n => n.text.startsWith(NOTE.title))) {
|
||||
await p.request.delete(`/api/trips/${tripId}/days/${dayIds[1]}/notes/${note.id}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
'day-route': {
|
||||
guide: guide('day-route'),
|
||||
start: p => openTripOnDay(p, ROUTED_DAY),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Route', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Route', exact: true }).click()
|
||||
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => routeBar(p).getByRole('button', { name: 'Driving' }).locator('xpath=..')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await connector(p).click()
|
||||
await expect(menu(p).getByRole('button', { name: 'Use day default' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: menu,
|
||||
act: closeMenu,
|
||||
},
|
||||
only(p => p.getByRole('button', { name: 'Optimize' })),
|
||||
// The two hand-offs are siblings with no wrapper of their own: their
|
||||
// parent is the whole route bar, which is already read-day-plan's step 5.
|
||||
only(p => p.getByRole('button', { name: 'Open in Google Maps' })),
|
||||
],
|
||||
},
|
||||
'manage-days': {
|
||||
guide: guide('manage-days'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
only(p => dayHeader(p, 1)),
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Reorder days' }).click()
|
||||
await expect(p.getByText('Reorder days').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// The whole popup: the rows with their arrows and bins, and the two ways
|
||||
// of adding a day in its footer.
|
||||
target: reorderPopup,
|
||||
},
|
||||
{
|
||||
// The question before a day goes, answered with Cancel: the picture shows
|
||||
// what would go with the second day, its places and its landing, and the
|
||||
// seed keeps all of its days.
|
||||
prepare: async p => {
|
||||
await reorderPopup(p).getByRole('button', { name: 'Delete day' }).nth(1).click()
|
||||
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: reorderPopup,
|
||||
act: async p => {
|
||||
await reorderPopup(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByRole('heading', { name: /^Delete .+\?$/ })).toHaveCount(0)
|
||||
await reorderPopup(p).getByRole('button', { name: 'Close', exact: true }).last().click()
|
||||
await expect(reorderPopup(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The steps before leave the first day selected, and a click on a
|
||||
// selected day folds its panel away: a second one brings it back.
|
||||
await dayHeader(p, 1).click({ position: { x: 22, y: 20 } })
|
||||
await settle(p)
|
||||
if (!(await dayDetails(p).isVisible())) await selectDay(p, 1)
|
||||
await expect(renameDay(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
// Scoped to the panel: a stop row carries an Edit of its own, so the
|
||||
// bare name matches two buttons as soon as any day is unfolded. The
|
||||
// ring goes round the panel's head band, title and pencil together: a
|
||||
// ring round the pencil alone sits on the day's name beside it.
|
||||
target: p => renameDay(p).locator('xpath=ancestor::div[@role="presentation"][1]'),
|
||||
hover: p => renameDay(p).hover(),
|
||||
act: closeDayDetails,
|
||||
},
|
||||
only(p => p.getByRole('button', { name: /Expand all days|Collapse all days/ })),
|
||||
],
|
||||
},
|
||||
'bookings-in-plan': {
|
||||
guide: guide('bookings-in-plan'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: 5 })
|
||||
},
|
||||
steps: [
|
||||
// The flight, not the train: Departure and Arrival are what a booking
|
||||
// gets when it crosses days, and the train starts and ends on one.
|
||||
only(p => bookingRow(p, /^LH716 FRA → HND Departure/)),
|
||||
{
|
||||
prepare: async p => { await selectDay(p, 6); await closeDayDetails(p) },
|
||||
target: p => stop(p, 'Nishiki Market'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await selectDay(p, 1)
|
||||
await expect(p.getByText('Accommodation', { exact: true }).first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByText('Accommodation', { exact: true }).first().locator('xpath=ancestor::section[1]'),
|
||||
act: closeDayDetails,
|
||||
},
|
||||
only(p => toolbar(p).getByRole('button', { name: /Show all booking routes|Hide all booking routes/ })),
|
||||
{
|
||||
prepare: async p => { await stop(p, 'Senso-ji Temple').hover() },
|
||||
target: p => stop(p, 'Senso-ji Temple').getByRole('button', { name: 'Add booking' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
'export-plan': {
|
||||
guide: guide('export-plan'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Export' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Export' }).click()
|
||||
await expect(modal(p).getByText('Document', { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => modal(p).getByText('Document', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
only(p => modal(p).getByText('Calendar', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
only(p => modal(p).getByText(/^Maps & GPS/).locator('xpath=ancestor::section[1]')),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureDaysFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered days guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripDaysContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-days', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
// The plan as it opens: the details panel is trip-day-detail's subject and the
|
||||
// route is day-route's, so the hero shows neither.
|
||||
await captureHero(page, tripDaysContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripDaysContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
|
||||
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Files tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so
|
||||
* `ensureFilesFixtures` puts a small set of drawn ones in before the first
|
||||
* picture; what a guide uploads, links, stars or throws away it undoes in
|
||||
* `cleanup`, so the next one starts from that same set.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripFilesGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The fixture documents each guide acts on, by the name their row carries. */
|
||||
const STARRED = 'JR-Pass-voucher.pdf'
|
||||
const LINKED = 'LH716-boarding-pass.pdf'
|
||||
const DOOMED = 'old-draft-itinerary.pdf'
|
||||
const PHOTO = 'nishiki-market.jpg'
|
||||
/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */
|
||||
const UPLOADED_PDF = 'kyoto-bus-pass.pdf'
|
||||
const UPLOADED_PHOTO = 'arashiyama.jpg'
|
||||
const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO]
|
||||
/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */
|
||||
const PLACE = 'teamLab Planets'
|
||||
const FLIGHT = 'LH716 FRA → HND'
|
||||
const NOTE = 'Seat 34K, boarding 12:20'
|
||||
|
||||
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
|
||||
|
||||
/**
|
||||
* The file rows, in the order the list shows them. A row is the `.group` card
|
||||
* whose last child holds the row's icon actions (`.file-actions`); matching on
|
||||
* that keeps any other hover group on the trip page out of the count.
|
||||
*/
|
||||
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
|
||||
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
|
||||
/** A row's actions are icons; each takes its accessible name from its tooltip. */
|
||||
const action = (page: Page, name: string, label: string) =>
|
||||
row(page, name).getByRole('button', { name: label, exact: true })
|
||||
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
|
||||
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
|
||||
/** The filter row, found through the one tab that is always there. */
|
||||
const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..')
|
||||
/** The star tab has no text: its accessible name is the count alone. */
|
||||
const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first()
|
||||
/**
|
||||
* The number on a filter tab. Reading it rather than counting the fixture keeps
|
||||
* the step honest about the one thing it is really claiming: that the tab shows
|
||||
* exactly as many rows as its badge promises, and it survives another screen's
|
||||
* fixture adding a file of its own.
|
||||
*/
|
||||
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
|
||||
/**
|
||||
* The trash icon at the right end of the bar, named by its tooltip. Trash and
|
||||
* Empty Trash both contain "Trash", so it needs `exact`.
|
||||
*/
|
||||
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
|
||||
/**
|
||||
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
|
||||
* its head band (Assign File, over the file's name) is what labels it.
|
||||
*/
|
||||
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
|
||||
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
|
||||
/**
|
||||
* The lightbox, found by the one header button only it and the document preview
|
||||
* carry, and they are never open at the same time. Its three direct children
|
||||
* are the header, the picture and the strip of thumbnails.
|
||||
*/
|
||||
const lightbox = (page: Page) =>
|
||||
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
|
||||
/**
|
||||
* The preview for everything that is not a picture: a DialogShell panel with
|
||||
* the file's name in the head band. For a PDF it holds the browser's embedded
|
||||
* viewer, the one `<object>` only it renders; the markdown preview
|
||||
* (`.collab-note-md`) has no step of its own.
|
||||
*/
|
||||
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
|
||||
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
|
||||
/** The round × in a dialog's head band, named Close by its tooltip. */
|
||||
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
|
||||
/**
|
||||
* A DialogShell hands the focus back to whatever opened it when it closes,
|
||||
* and a Tooltip opens on focus as well as on hover. The opener of the assign
|
||||
* dialog and of document sync is an icon with a tooltip, so without this the
|
||||
* after-picture shows "Assign" or "Document sync" floating under the pointer
|
||||
* that has long since moved away.
|
||||
*/
|
||||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
|
||||
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
|
||||
/** The head band's title; the trip's name stands under it as a line of its own. */
|
||||
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
|
||||
/**
|
||||
* A store row in the dialog's sidebar. Its accessible name runs on into the
|
||||
* subtitle (Files in a folder), so it is matched by its start.
|
||||
*/
|
||||
const storeRow = (page: Page, name: string) => modal(page).getByRole('button', { name: new RegExp(`^${name}\\b`) })
|
||||
/**
|
||||
* A box of the connection form, by the placeholder the store's field carries.
|
||||
* The labels are no good as locators here: the hint under Username says
|
||||
* "email address", so a lookup by the label Address finds two boxes.
|
||||
*/
|
||||
const formBox = (page: Page, placeholder: string) => dialog(page).getByPlaceholder(placeholder, { exact: true })
|
||||
/** The folder picker's row for the folder the fixture put in the store; the Create button is not in a list. */
|
||||
const folderRow = (page: Page) => dialog(page).locator('li button').filter({ hasText: NEXTCLOUD_FOLDER }).first()
|
||||
/** The binding card, the one <article> in the dialog once a store is bound. */
|
||||
const bindingCard = (page: Page) => dialog(page).locator('article').first()
|
||||
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
|
||||
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
|
||||
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
|
||||
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
|
||||
|
||||
interface ApiFile {
|
||||
id: number
|
||||
original_name: string
|
||||
starred?: number
|
||||
}
|
||||
|
||||
async function listFiles(page: Page, trash = false): Promise<ApiFile[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`)
|
||||
const body = (await res.json()) as { files?: ApiFile[] }
|
||||
return body.files ?? []
|
||||
}
|
||||
|
||||
/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */
|
||||
async function removeFiles(page: Page, names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
}
|
||||
for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the note and every link the guide made back to nothing. */
|
||||
async function unlinkFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||||
if (!file) return
|
||||
await page.request.put(`/api/trips/${tripId}/files/${file.id}`, {
|
||||
data: { place_id: null, reservation_id: null, description: '' },
|
||||
})
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`)
|
||||
const { links = [] } = (await res.json()) as { links?: { id: number }[] }
|
||||
for (const link of links) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The star route toggles, so it is only called back when the file really is starred. */
|
||||
async function unstarFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||||
if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`)
|
||||
}
|
||||
|
||||
/** Bring a file the guide threw away back out of the trash, if it is still in there. */
|
||||
async function untrashFile(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const file = (await listFiles(page, true)).find(f => f.original_name === name)
|
||||
if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`)
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'files-upload': {
|
||||
guide: guide('files-upload'),
|
||||
// Starts on the plan, because the first step is reaching the tab.
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Files', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Files', exact: true }).click()
|
||||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(dropzone(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dropzone,
|
||||
// The hidden input takes the files directly: clicking the box would open
|
||||
// the operating system's picker, which Playwright cannot answer.
|
||||
act: async p => {
|
||||
await dropzone(p).locator('input[type="file"]').setInputFiles([
|
||||
filesPdfFixture('kyoto-bus-pass'),
|
||||
await filesImageFixture('arashiyama'),
|
||||
])
|
||||
await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole panel: the head band with the file's name, the note and
|
||||
// the two lists are what the step's text walks through.
|
||||
target: assignDialog,
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
await expect(assignDialog(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, UPLOADED_PHOTO),
|
||||
},
|
||||
],
|
||||
cleanup: p => removeFiles(p, UPLOADED),
|
||||
},
|
||||
'files-link': {
|
||||
guide: guide('files-link'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, LINKED, 'Assign'),
|
||||
act: async p => {
|
||||
await action(p, LINKED, 'Assign').click()
|
||||
await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, noteBox(p), NOTE)
|
||||
},
|
||||
// The label above the box belongs in the picture, so the step rings the
|
||||
// block rather than the input.
|
||||
target: p => noteBox(p).locator('xpath=..'),
|
||||
// The note is written on blur, and Enter blurs; the row behind the
|
||||
// dialog picks it up, which is the proof that it was saved.
|
||||
act: async p => {
|
||||
await noteBox(p).press('Enter')
|
||||
await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
// Exact: the bookings fixture names a booking after this place, and the
|
||||
// dialog lists both under the same opening words.
|
||||
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
|
||||
act: async p => {
|
||||
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
|
||||
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => assignDialog(p).getByRole('button', { name: FLIGHT }),
|
||||
act: async p => {
|
||||
await assignDialog(p).getByRole('button', { name: FLIGHT }).click()
|
||||
await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => closeX(assignDialog(p)),
|
||||
act: async p => {
|
||||
await closeX(assignDialog(p)).click()
|
||||
await expect(assignDialog(p)).toHaveCount(0)
|
||||
await dropFocus(p)
|
||||
// A badge carries the name alone now; what it links to is its tooltip.
|
||||
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unlinkFile(p, LINKED),
|
||||
},
|
||||
'files-star': {
|
||||
guide: guide('files-star'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, STARRED, 'Star'),
|
||||
act: async p => {
|
||||
await action(p, STARRED, 'Star').click()
|
||||
await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sorting is the server's (starred first, then newest first), so the
|
||||
// list is only right once the reload has come back.
|
||||
prepare: async p => {
|
||||
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => rows(p).first(),
|
||||
},
|
||||
{
|
||||
target: starTab,
|
||||
act: async p => {
|
||||
const starred = await tabCount(starTab(p))
|
||||
await starTab(p).click()
|
||||
await expect(rows(p)).toHaveCount(starred)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unstarFile(p, STARRED),
|
||||
},
|
||||
'files-filter': {
|
||||
guide: guide('files-filter'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
only(tabRow),
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^PDFs \d+$/ }),
|
||||
act: async p => {
|
||||
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
|
||||
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
|
||||
await expect(rows(p)).toHaveCount(pdfs)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click()
|
||||
await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^All \d+$/ }),
|
||||
act: async p => {
|
||||
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
|
||||
await p.getByRole('button', { name: /^All \d+$/ }).click()
|
||||
await expect(rows(p)).toHaveCount(all)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'files-preview': {
|
||||
guide: guide('files-preview'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
// Two buttons in the row carry the file's name: the thumbnail and the
|
||||
// name itself. The reader is told about the name, which comes second.
|
||||
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
|
||||
act: async p => {
|
||||
await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click()
|
||||
await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => lightbox(p).locator('> div').last(),
|
||||
// Which way the picture can move depends on where it sits in the strip,
|
||||
// and the right chevron is there only while there is a next one.
|
||||
act: async p => {
|
||||
const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0
|
||||
await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft')
|
||||
await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The header's button group, not the whole header bar: the bar runs the
|
||||
// full width of the frame, so ringing it rings mostly empty space.
|
||||
target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'),
|
||||
// The group's own centre falls between its buttons, where the picture
|
||||
// underneath takes the pointer, so hovering it never lands. The pointer
|
||||
// goes on a button instead; the ring is still round the group.
|
||||
hover: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).hover(),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// A PDF this time rather than the markdown file: the browser's own
|
||||
// viewer draws the page inside the card, which is what a reader sees
|
||||
// for a ticket. The card gets its source only once the signed download
|
||||
// link is minted (useFileManager.ts: getAuthUrl → setPreviewFileUrl),
|
||||
// and no DOM event says when the viewer has painted, so the step waits
|
||||
// for the file's own response and then a moment more, as the satellite
|
||||
// step of the map guide does for its tiles.
|
||||
prepare: async p => {
|
||||
const fetched = p
|
||||
.waitForResponse(r => r.url().includes('/download?token=') && r.ok(), { timeout: 30_000 })
|
||||
.catch(() => null)
|
||||
await row(p, LINKED).getByRole('button', { name: LINKED }).last().click()
|
||||
await expect(pdfPreview(p)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(pdfViewer(p)).toHaveAttribute('data', /\/download\?token=.+#view=FitH$/, { timeout: 30_000 })
|
||||
await fetched
|
||||
// The fallback link inside the <object> is drawn only where the
|
||||
// browser has no viewer; hidden, it proves the picture shows a page.
|
||||
await expect(pdfPreview(p).getByRole('button', { name: 'Download PDF' })).toBeHidden()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
target: pdfPreview,
|
||||
act: async p => {
|
||||
await closeX(pdfPreview(p)).click()
|
||||
await expect(pdfPreview(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => action(p, STARRED, 'Download'),
|
||||
// The button fetches the bytes and clicks a synthetic anchor, so the
|
||||
// browser reports a real download.
|
||||
act: async p => {
|
||||
const download = p.waitForEvent('download', { timeout: 30_000 })
|
||||
await action(p, STARRED, 'Download').click()
|
||||
await (await download).delete()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'files-trash': {
|
||||
guide: guide('files-trash'),
|
||||
start: openFiles,
|
||||
steps: [
|
||||
{
|
||||
target: p => action(p, DOOMED, 'Delete'),
|
||||
act: async p => {
|
||||
await action(p, DOOMED, 'Delete').click()
|
||||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: trashTab,
|
||||
act: async p => {
|
||||
await trashTab(p).click()
|
||||
await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => row(p, DOOMED)),
|
||||
{
|
||||
target: p => action(p, DOOMED, 'Restore'),
|
||||
act: async p => {
|
||||
await action(p, DOOMED, 'Restore').click()
|
||||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Empty Trash is there because the fixture leaves one document in the
|
||||
// trash. It asks with the browser's own confirm, which Playwright
|
||||
// dismisses, so clicking it would do nothing at all; the step shows the
|
||||
// button and leaves the trash by the trash icon instead.
|
||||
target: p => p.getByRole('button', { name: 'Empty Trash' }),
|
||||
act: async p => {
|
||||
await trashTab(p).click()
|
||||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => untrashFile(p, DOOMED),
|
||||
},
|
||||
|
||||
'files-sync': {
|
||||
guide: guide('files-sync'),
|
||||
// The store's address and app password come from media.env (external.ts).
|
||||
// They are read inside the guide rather than at import, so a run without
|
||||
// the file fails this one guide and not the whole screen.
|
||||
start: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await resetDocSync(p.request, tripId)
|
||||
await ensureNextcloudFolder(drawPdf)
|
||||
await openFiles(p)
|
||||
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: syncButton,
|
||||
act: async p => {
|
||||
await syncButton(p).click()
|
||||
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
|
||||
// The stores arrive after a spinner; the heading over them says the list is in.
|
||||
await expect(modal(p).getByText('Connect a provider', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => storeRow(p, 'Nextcloud'),
|
||||
act: async p => {
|
||||
await storeRow(p, 'Nextcloud').click()
|
||||
// The connection dialog stacks on top, so `modal` is now that one.
|
||||
await expect(modal(p).getByRole('heading', { name: 'Nextcloud', exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(formBox(p, 'https://cloud.example.com')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const store = NEXTCLOUD_CONNECTION()
|
||||
await typeInto(p, formBox(p, 'https://cloud.example.com'), store.baseUrl)
|
||||
await typeInto(p, formBox(p, 'username'), store.credentials.login_name)
|
||||
await typeInto(p, formBox(p, 'app password'), store.credentials.app_password)
|
||||
await typeInto(p, formBox(p, '/TREK'), store.credentials.base_path)
|
||||
await beat(p, 300)
|
||||
},
|
||||
// The whole form, as the create-user step does: the labels and hints belong in the picture.
|
||||
target: dialog,
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Test connection' }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Test connection' }).click()
|
||||
await expect(
|
||||
dialog(p).getByText(`Reached it, signed in as ${NEXTCLOUD_CONNECTION().credentials.login_name}`),
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Connect saves the credentials and opens the folder picker on top of
|
||||
// the dialog; the picture is the picker, with the folder the reader is
|
||||
// told to click, because that is the screen the step is about.
|
||||
prepare: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Connect', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Where should this trip live in Nextcloud?' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(folderRow(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: folderRow,
|
||||
act: async p => {
|
||||
await folderRow(p).click()
|
||||
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Binding starts a run of its own, which the button cannot join: a Sync
|
||||
// now while it is going answers busy and moves nothing, and the card
|
||||
// only learns of that first run from the ping it sends when it is
|
||||
// through. So the click is repeated until the card reports In sync,
|
||||
// which is a dot named by its tooltip once the state is good.
|
||||
prepare: async p => {
|
||||
await expect(async () => {
|
||||
await syncNow(p).click({ timeout: 15_000 })
|
||||
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
|
||||
}).toPass({ timeout: 120_000, intervals: [3_000] })
|
||||
await settle(p)
|
||||
},
|
||||
target: bindingCard,
|
||||
// Closed with the × in the head band, as the step's text says.
|
||||
act: async p => {
|
||||
await closeX(dialog(p)).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await dropFocus(p)
|
||||
// The run's file:created events put the pulled documents at the top of the list.
|
||||
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await resetDocSync(p.request, tripId)
|
||||
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
|
||||
// The run pushed the trip's own documents into the folder; back to its two.
|
||||
await ensureNextcloudFolder(drawPdf)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureFilesFixtures(request)
|
||||
// A store on offer puts Document sync beside the trash icon for the trip's
|
||||
// owner, which is how bullet 6 of the screen text describes the bar; on for
|
||||
// the whole file, so the hero and all seven guides show the same bar.
|
||||
await toggleAddon(request, 'nextcloud', true)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, not trip-scoped: every store ships off, and the other
|
||||
// screens' pictures must not grow the button. Here rather than in the last
|
||||
// guide's cleanup, so a failed run puts it back too (as trip-roadtrip does).
|
||||
await toggleAddon(request, 'nextcloud', false)
|
||||
})
|
||||
|
||||
test('every registered files guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-files', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripFilesContext.id, openFiles)
|
||||
})
|
||||
|
||||
for (const id of tripFilesContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { pickerLabel } from '../dates'
|
||||
import { seededTrip, ensureListsFixtures } from './fixtures'
|
||||
import { openTrip, modal } from './trip-shared'
|
||||
import { tripListsContext, tripListsGuides } from '../../src/help/contexts/tripLists'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Lists tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripLists.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the fixtures' packing template and two extra tasks in place, so Apply
|
||||
* template exists and the to-do sidebar has more than one filled filter; what
|
||||
* a guide creates it takes away again in `cleanup`, so the next one starts the
|
||||
* same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripListsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** What the guides make and unmake. Nothing here is in the seed. */
|
||||
const NEW_LIST = 'Toiletries'
|
||||
const FIRST_ITEM = 'Toothbrush'
|
||||
const RENAMED_ITEM = 'Toothbrush and paste'
|
||||
const MEMBER = 'mira'
|
||||
const SHARED_ITEM = 'Camera'
|
||||
const BAG_ITEM = 'Rain jacket'
|
||||
const BAG = 'Cabin bag'
|
||||
const SAVED_TEMPLATE = 'Autumn in Japan'
|
||||
const NEW_TASK = 'Pick up the pocket wifi'
|
||||
const NEW_TASK_NOTE = 'Counter is past customs, on the right, open until 22:00.'
|
||||
const TASK_LIST = 'On arrival'
|
||||
const OTHER_TASK_LIST = 'Before departure'
|
||||
const EDITED_TASK = 'Activate JR Pass'
|
||||
const ASSIGNEE = 'jonas'
|
||||
/** The date picker labels a day cell with the whole date, in the run's English. */
|
||||
const DUE_DAY = pickerLabel(4)
|
||||
/** The fixtures' template, and the lists it brings with it. */
|
||||
const TEMPLATE = { name: 'Japan autumn basics', category: 'Rain kit', items: ['Umbrella', 'Quick-dry towel', 'Dry bag', 'SIM card', 'Plug adapter'] }
|
||||
const IMPORT_LINES = 'Toiletries, Toothbrush\nToiletries, Sunscreen, 90\nHealth, Plasters, 30'
|
||||
const IMPORTED = ['Toothbrush', 'Sunscreen', 'Plasters']
|
||||
/** The seed's packing items, in the order it created them. */
|
||||
const SEED_ORDER = ['Passport', 'JR Pass voucher', 'Travel insurance', 'Rain jacket', 'Walking shoes', 'Light layers', 'Type-A adapter', 'Power bank', 'Camera']
|
||||
const DOCUMENTS = ['Passport', 'JR Pass voucher', 'Travel insurance']
|
||||
/** The count in the label moves with what is ticked, so it is matched, not spelled. */
|
||||
const CLEAR_CHECKED = /^Remove \d+ checked$/
|
||||
|
||||
// ── Locators ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A category card: the list's name is a span in the card's header row. */
|
||||
const cat = (page: Page, name: string): Locator =>
|
||||
page.getByText(name, { exact: true }).first().locator('xpath=ancestor::div[2]')
|
||||
/**
|
||||
* The header row of a card. The ⋯ has to be taken from here: every item row
|
||||
* carries a ⋯ of its own, so the card as a whole holds several of them.
|
||||
*/
|
||||
const catHeader = (page: Page, name: string): Locator => cat(page, name).locator('> div').first()
|
||||
const catMenu = (page: Page, name: string): Locator =>
|
||||
catHeader(page, name).locator('button:has(svg.lucide-more-horizontal)')
|
||||
/** The open ⋯ menu is a fixed popover with no class of its own; its last entry names it. */
|
||||
const catMenuCard = (page: Page): Locator => page.getByRole('button', { name: 'Delete List' }).locator('xpath=..')
|
||||
const item = (page: Page, name: string): Locator =>
|
||||
page.locator('.packing-item-row').filter({ hasText: name }).first()
|
||||
/** The only item of the list the first guide builds; its name changes mid-guide. */
|
||||
const onlyRow = (page: Page): Locator => cat(page, NEW_LIST).locator('.packing-item-row').first()
|
||||
/** An item's own ⋯, which holds Move to List, Sharing, Rename and Delete. */
|
||||
const rowMenu = (page: Page, name: string): Locator => item(page, name).locator('.packing-row-overflow button')
|
||||
/** The open item menu; Sharing is a whole line in it. */
|
||||
const rowMenuSharing = (page: Page): Locator =>
|
||||
page.locator('.trek-menu-enter').getByRole('button', { name: /^Sharing/ })
|
||||
/** The name dialog that Add list and Save as template open. */
|
||||
const nameDialog = (page: Page, confirm: string): Locator => page.getByRole('button', { name: confirm, exact: true })
|
||||
/** The sharing dropdown is portalled to <body>, so the hint's parent is the card itself. */
|
||||
const shareMenu = (page: Page): Locator =>
|
||||
page.getByText('In the group pool, visible to everyone').locator('xpath=ancestor::div[1]')
|
||||
/** Its click-catcher, the only way back out of the menu once a name is ticked. */
|
||||
const shareOverlay = (page: Page): Locator => page.locator('div[role="presentation"][style*="z-index: 1099"]').last()
|
||||
/** The bulk-import dialog, a DialogShell whose head band carries its title. */
|
||||
const importCard = (page: Page): Locator =>
|
||||
page.getByRole('dialog').filter({ has: page.getByText('Import Packing List', { exact: true }) })
|
||||
/**
|
||||
* The bag picker of a row and the member picker of a bag both hang off the
|
||||
* body now, so they are found by their caption rather than inside the row.
|
||||
*/
|
||||
const bagPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Bags' }).last()
|
||||
const bagMemberPicker = (page: Page): Locator => page.locator('.trek-menu-enter').filter({ hasText: 'Assign members' }).last()
|
||||
/** The calendar a date field opens, a portal of its own. */
|
||||
const datePicker = (page: Page): Locator => page.getByRole('dialog', { name: 'Date picker' })
|
||||
/** The Export button's menu: print, Markdown and CSV. */
|
||||
const exportMenu = (page: Page): Locator => page.getByRole('menu')
|
||||
/** The printable page inside the preview, a srcdoc frame of its own. */
|
||||
const printFrame = (page: Page) => page.frameLocator('iframe[title^="Packing List"]')
|
||||
/** The Bags card beside the lists. Total weight is a span in its foot band. */
|
||||
/** The bag sidebar: the innermost box holding the guide's bag and the weight summary below the cards. */
|
||||
const bagSidebar = (page: Page): Locator =>
|
||||
page.locator('div')
|
||||
.filter({ has: page.getByText('Total weight', { exact: true }) })
|
||||
.filter({ has: page.getByText(BAG, { exact: true }) })
|
||||
.last()
|
||||
/** The detail pane on the right; its header reads Task. */
|
||||
const todoPane = (page: Page): Locator => page.getByText('Task', { exact: true }).locator('xpath=ancestor::div[2]')
|
||||
/** A task row is a div with role=button whose name starts with the task's own. */
|
||||
const task = (page: Page, name: string): Locator =>
|
||||
page.getByRole('button', { name: new RegExp(`^${name}`) }).first()
|
||||
const sidebarFilter = (page: Page, name: string): Locator =>
|
||||
page.getByRole('button', { name: new RegExp(`^${name}`) })
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
// ── Getting there ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The tab id in the address is the legacy 'listen'; the tab itself reads Lists. */
|
||||
async function openLists(page: Page): Promise<void> {
|
||||
await openTrip(page, { tab: 'listen' })
|
||||
await expect(page.getByRole('button', { name: /^Packing List/ })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-tab is remembered in sessionStorage, which Playwright never restores,
|
||||
* so every guide opens on Packing List and the to-do ones switch over.
|
||||
*/
|
||||
async function openTodos(page: Page): Promise<void> {
|
||||
await openLists(page)
|
||||
await page.getByRole('button', { name: /^To-Do/ }).click()
|
||||
await expect(page.getByRole('button', { name: 'Add new task' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── Putting the seed back ────────────────────────────────────────────────────
|
||||
|
||||
interface PackingRow {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
}
|
||||
|
||||
async function packingItems(page: Page): Promise<PackingRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/packing`)
|
||||
const body = (await res.json()) as { items?: PackingRow[] }
|
||||
return body.items ?? []
|
||||
}
|
||||
|
||||
async function deleteItems(page: Page, match: (row: PackingRow) => boolean): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const row of (await packingItems(page)).filter(match)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/packing/${row.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-creates the three Documents items the guide really deleted, then writes
|
||||
* the seed's order back: without it the re-created rows sort last, the
|
||||
* Documents card slides to the end of the two-column grid, and every later
|
||||
* guide's picture changes.
|
||||
*/
|
||||
async function restoreDocuments(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const present = await packingItems(page)
|
||||
for (const name of DOCUMENTS) {
|
||||
if (present.some(row => row.name === name)) continue
|
||||
await page.request.post(`/api/trips/${tripId}/packing`, { data: { name, category: 'Documents', visibility: 'common' } })
|
||||
}
|
||||
const after = await packingItems(page)
|
||||
const orderedIds = SEED_ORDER.map(name => after.find(row => row.name === name)?.id).filter((id): id is number => id !== undefined)
|
||||
await page.request.put(`/api/trips/${tripId}/packing/reorder`, { data: { orderedIds } })
|
||||
}
|
||||
|
||||
async function deleteTemplate(page: Page, name: string): Promise<void> {
|
||||
const res = await page.request.get('/api/admin/packing-templates')
|
||||
const body = (await res.json()) as { templates?: { id: number; name: string }[] }
|
||||
for (const template of (body.templates ?? []).filter(row => row.name === name)) {
|
||||
await page.request.delete(`/api/admin/packing-templates/${template.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/todo`)
|
||||
const body = (await res.json()) as { items?: { id: number; name: string }[] }
|
||||
for (const row of (body.items ?? []).filter(x => x.name === name)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/todo/${row.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function unassignTodo(page: Page, name: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/todo`)
|
||||
const body = (await res.json()) as { items?: { id: number; name: string }[] }
|
||||
const row = (body.items ?? []).find(x => x.name === name)
|
||||
if (row) await page.request.put(`/api/trips/${tripId}/todo/${row.id}`, { data: { assigned_user_id: null } })
|
||||
}
|
||||
|
||||
// ── Scripts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'packing-categories': {
|
||||
guide: guide('packing-categories'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add list' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add list' }).click()
|
||||
const field = p.getByPlaceholder('List name (e.g. Clothing)')
|
||||
await typeInto(p, field, NEW_LIST)
|
||||
await nameDialog(p, 'Add').click()
|
||||
await expect(cat(p, NEW_LIST)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }),
|
||||
act: async p => {
|
||||
await cat(p, NEW_LIST).getByRole('button', { name: 'Add item' }).click()
|
||||
const field = p.getByPlaceholder('Item name...')
|
||||
await typeInto(p, field, FIRST_ITEM)
|
||||
// Enter renames the list's '...' row rather than adding a second one,
|
||||
// and leaves the field open; Escape is how it closes.
|
||||
await field.press('Enter')
|
||||
await expect(item(p, FIRST_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await field.press('Escape')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await onlyRow(p).hover() },
|
||||
// A click on the name renames it; the row has no pencil of its own.
|
||||
target: p => onlyRow(p).getByRole('button', { name: FIRST_ITEM }),
|
||||
act: async p => {
|
||||
await onlyRow(p).getByRole('button', { name: FIRST_ITEM }).click()
|
||||
// While editing, the name is the row's first input; the quantity and
|
||||
// the weight come after it.
|
||||
const field = onlyRow(p).locator('input').first()
|
||||
await typeInto(p, field, RENAMED_ITEM)
|
||||
await field.press('Enter')
|
||||
await expect(item(p, RENAMED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)'),
|
||||
act: async p => {
|
||||
const plus = catHeader(p, NEW_LIST).locator('button:has(svg.lucide-user-plus)')
|
||||
await plus.click()
|
||||
await catHeader(p, NEW_LIST).getByRole('button', { name: MEMBER }).click()
|
||||
await expect(catHeader(p, NEW_LIST).locator('.assignee-chip')).toHaveCount(1, { timeout: 20_000 })
|
||||
await plus.click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, NEW_LIST).click()
|
||||
await expect(p.getByRole('button', { name: 'Delete List' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: catMenuCard,
|
||||
// The pointer rests on the three dots that opened it, not on an entry.
|
||||
// Moved by coordinates: the menu's click-catcher covers the button.
|
||||
hover: async p => {
|
||||
const b = await catMenu(p, NEW_LIST).boundingBox()
|
||||
if (b) await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2)
|
||||
},
|
||||
act: async p => {
|
||||
// The menu sits over a full-viewport catcher; a click in the corner
|
||||
// closes it without deleting the list the picture was just taken of.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(p.getByRole('button', { name: 'Delete List' })).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await deleteItems(p, row => row.category === NEW_LIST)
|
||||
await p.request.put(`/api/trips/${tripId}/packing/category-assignees/${encodeURIComponent(NEW_LIST)}`, { data: { user_ids: [] } })
|
||||
},
|
||||
},
|
||||
'check-off-packing': {
|
||||
guide: guide('check-off-packing'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
// The grip is a div, so the tick box is the row's first button.
|
||||
target: p => item(p, 'Passport').getByRole('button').first(),
|
||||
act: async p => {
|
||||
await item(p, 'Passport').getByRole('button').first().click()
|
||||
await expect(item(p, 'Passport').locator('.packing-check')).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The count and the percentage, with the bar beside them: the whole card is
|
||||
// wider than any frame and would lose both at its edges.
|
||||
only(p => p.getByText('/9', { exact: true }).locator('xpath=ancestor::div[2]')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await catMenu(p, 'Documents').click()
|
||||
await expect(p.getByRole('button', { name: 'Check All', exact: true })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Check All', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Check All', exact: true }).click()
|
||||
await expect(cat(p, 'Documents').getByText('3/3', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Open', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Open', exact: true }).click()
|
||||
await expect(p.getByText('Documents', { exact: true })).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: CLEAR_CHECKED }),
|
||||
act: async p => {
|
||||
// A native window.confirm, which Playwright dismisses unless something
|
||||
// accepts it first. The browser's own dialog cannot be pictured, so
|
||||
// the step shows the button and the state it leaves behind.
|
||||
p.once('dialog', d => { void d.accept() })
|
||||
await p.getByRole('button', { name: CLEAR_CHECKED }).click()
|
||||
await expect(p.getByRole('button', { name: CLEAR_CHECKED })).toHaveCount(0, { timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: restoreDocuments,
|
||||
},
|
||||
'apply-packing-template': {
|
||||
guide: guide('apply-packing-template'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Apply template' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Apply template' }).click()
|
||||
await expect(p.locator('.trek-menu-enter').last()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.trek-menu-enter').last(),
|
||||
act: async p => {
|
||||
await p.locator('.trek-menu-enter').last().getByRole('button', { name: new RegExp(`^${TEMPLATE.name}`) }).click()
|
||||
await expect(cat(p, TEMPLATE.category)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('button', { name: /^Shared/ })),
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Save as template' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Save as template' }).click()
|
||||
const field = p.getByPlaceholder('Template name')
|
||||
await typeInto(p, field, SAVED_TEMPLATE)
|
||||
await nameDialog(p, 'Save').click()
|
||||
await expect(p.getByText('Packing list saved as template')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await deleteItems(p, row => TEMPLATE.items.includes(row.name))
|
||||
await deleteTemplate(p, SAVED_TEMPLATE)
|
||||
},
|
||||
},
|
||||
'import-packing-list': {
|
||||
guide: guide('import-packing-list'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
// An icon at the right end of the bar, named Import by its label and tooltip.
|
||||
target: p => p.getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importCard(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The step's text points at the grey sample, which is the textarea's
|
||||
// placeholder: it is only on screen while the box is empty, so the lines
|
||||
// are typed after the picture, not before it.
|
||||
target: importCard,
|
||||
act: async p => {
|
||||
await typeInto(p, importCard(p).locator('textarea'), IMPORT_LINES)
|
||||
await expect(importCard(p).locator('textarea')).toHaveValue(IMPORT_LINES, { timeout: 20_000 })
|
||||
// Out of the box again, or its dark focus ring reads as a second ring
|
||||
// in the next two pictures.
|
||||
await importCard(p).locator('textarea').blur()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// In the dialog's footer, on the left. Clicking it would open the operating
|
||||
// system's file chooser, which is not part of the picture; the button is
|
||||
// shown, not pressed.
|
||||
only(p => importCard(p).getByRole('button', { name: 'Load CSV/TXT/MD' })),
|
||||
{
|
||||
target: p => importCard(p).getByRole('button', { name: /^Import \d+$/ }),
|
||||
act: async p => {
|
||||
await importCard(p).getByRole('button', { name: /^Import \d+$/ }).click()
|
||||
await expect(importCard(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(item(p, 'Sunscreen')).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteItems(p, row => IMPORTED.includes(row.name)),
|
||||
},
|
||||
'export-packing-list': {
|
||||
guide: guide('export-packing-list'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Export', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Export', exact: true }).click()
|
||||
await expect(exportMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The two file entries download at once; the step shows them, it does not press them.
|
||||
only(exportMenu),
|
||||
{
|
||||
target: p => exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }),
|
||||
act: async p => {
|
||||
await exportMenu(p).getByRole('menuitem', { name: 'Print or save as PDF' }).click()
|
||||
await expect(printFrame(p).locator('h1')).toBeVisible({ timeout: 15_000 })
|
||||
// The page carries the app's own Poppins; the result picture waits for it.
|
||||
await printFrame(p).locator('body').evaluate(async () => { await document.fonts.ready })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Pressing it opens the operating system's print dialog, which is not part
|
||||
// of the picture; the button is shown, not pressed. The result picture is the
|
||||
// preview itself.
|
||||
only(p => p.getByRole('button', { name: 'Print or save as PDF' })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await p.getByRole('button', { name: 'Close', exact: true }).first().click()
|
||||
},
|
||||
},
|
||||
'share-packing-item': {
|
||||
guide: guide('share-packing-item'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^My list/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^My list/ }).click()
|
||||
await expect(p.getByText('No items match this filter')).toBeVisible({ timeout: 20_000 })
|
||||
await p.getByRole('button', { name: /^Shared/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await expect(rowMenuSharing(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: rowMenuSharing,
|
||||
act: async p => {
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: shareMenu,
|
||||
act: async p => {
|
||||
await shareMenu(p).getByRole('button', { name: /^Personal/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^My list/ }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^My list/ }).click()
|
||||
await expect(item(p, SHARED_ITEM)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await item(p, SHARED_ITEM).hover()
|
||||
await rowMenu(p, SHARED_ITEM).click()
|
||||
await rowMenuSharing(p).click()
|
||||
await expect(shareMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => shareMenu(p).getByRole('button', { name: MEMBER }),
|
||||
act: async p => {
|
||||
await shareMenu(p).getByRole('button', { name: MEMBER }).click()
|
||||
// Ticking a name leaves the menu open on purpose; its catcher closes it,
|
||||
// and the item menu under it has one of its own.
|
||||
await shareOverlay(p).click({ position: { x: 5, y: 5 } })
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(p.locator('.trek-menu-enter')).toHaveCount(0)
|
||||
await expect(item(p, SHARED_ITEM).getByLabel('shared with 1')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const row = (await packingItems(p)).find(x => x.name === SHARED_ITEM)
|
||||
if (row) {
|
||||
await p.request.put(`/api/trips/${tripId}/packing/${row.id}/sharing`, { data: { visibility: 'common', recipient_ids: [] } })
|
||||
}
|
||||
},
|
||||
},
|
||||
'packing-bags': {
|
||||
guide: guide('packing-bags'),
|
||||
start: openLists,
|
||||
steps: [
|
||||
{
|
||||
// The weight field's placeholder really is a dash: it is the app's own
|
||||
// string, not prose, so it is matched exactly as rendered.
|
||||
target: p => item(p, BAG_ITEM).getByPlaceholder('\u2014'),
|
||||
act: async p => {
|
||||
const field = item(p, BAG_ITEM).getByPlaceholder('\u2014')
|
||||
await typeInto(p, field, '620')
|
||||
await expect(field).toHaveValue('620', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await item(p, BAG_ITEM).hover() },
|
||||
// The circle is dashed with a parcel in it while the item is in no bag.
|
||||
target: p => item(p, BAG_ITEM).locator('button:has(svg.lucide-package)'),
|
||||
act: async p => {
|
||||
await item(p, BAG_ITEM).locator('button:has(svg.lucide-package)').click()
|
||||
await expect(bagPicker(p).getByRole('button', { name: 'Add bag' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => bagPicker(p).getByRole('button', { name: 'Add bag' }),
|
||||
act: async p => {
|
||||
await bagPicker(p).getByRole('button', { name: 'Add bag' }).click()
|
||||
const field = bagPicker(p).getByPlaceholder('Bag name...')
|
||||
await typeInto(p, field, BAG)
|
||||
await field.press('Enter')
|
||||
await expect(bagSidebar(p)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(bagPicker(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(bagSidebar),
|
||||
{
|
||||
target: p => bagSidebar(p).getByRole('button', { name: 'Set limit' }),
|
||||
act: async p => {
|
||||
await bagSidebar(p).getByRole('button', { name: 'Set limit' }).click()
|
||||
// Entered in kilograms, stored in grams.
|
||||
// The sidebar locator needs Set limit, which the open editor replaces: look page-wide.
|
||||
const field = p.locator('input[aria-label="Weight limit"]')
|
||||
await typeInto(p, field, '8')
|
||||
await field.press('Enter')
|
||||
await expect(p.getByText('/ 8.0 kg').first()).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The card's member picker is the dashed plus beside the bag's name; the
|
||||
// column's other plus, + Bags in its head, adds a bag.
|
||||
target: p => bagSidebar(p).locator('button[style*="dashed"]').first(),
|
||||
act: async p => {
|
||||
await bagSidebar(p).locator('button[style*="dashed"]').first().click()
|
||||
await bagMemberPicker(p).getByRole('button', { name: MEMBER }).click()
|
||||
// The picker closes on a click anywhere beside it.
|
||||
await p.mouse.click(20, VIEWPORT.height - 40)
|
||||
await expect(bagSidebar(p).getByRole('button', { name: MEMBER })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/packing/bags`)
|
||||
const body = (await res.json()) as { bags?: { id: number; name: string }[] }
|
||||
for (const bag of (body.bags ?? []).filter(x => x.name === BAG)) {
|
||||
// bag_id is ON DELETE SET NULL, so the item is freed with it.
|
||||
await p.request.delete(`/api/trips/${tripId}/packing/bags/${bag.id}`)
|
||||
}
|
||||
const row = (await packingItems(p)).find(x => x.name === BAG_ITEM)
|
||||
if (row) await p.request.put(`/api/trips/${tripId}/packing/${row.id}`, { data: { weight_grams: null } })
|
||||
},
|
||||
},
|
||||
'create-todo': {
|
||||
guide: guide('create-todo'),
|
||||
start: openTodos,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add new task' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add new task' }).click()
|
||||
await expect(modal(p).getByPlaceholder('Task name')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Task name'), NEW_TASK)
|
||||
await typeInto(p, modal(p).getByPlaceholder('Description (optional)'), NEW_TASK_NOTE)
|
||||
// Out of the field, so its dark focus border does not read as a second ring.
|
||||
await modal(p).getByPlaceholder('Description (optional)').blur()
|
||||
await settle(p)
|
||||
},
|
||||
// The name is typed into the dialog's head band, where Task name stands.
|
||||
target: p => modal(p).getByPlaceholder('Task name'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'No list' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'No list' }).click()
|
||||
// The select's list is portalled to <body>, outside the dialog.
|
||||
await p.getByRole('button', { name: TASK_LIST, exact: true }).click()
|
||||
await expect(modal(p).getByRole('button', { name: TASK_LIST })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'P1', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'P2', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Exact: the keyboard button beside it is "Enter date manually", which a
|
||||
// loose name match also finds.
|
||||
await modal(p).getByRole('button', { name: 'Date', exact: true }).click()
|
||||
await expect(datePicker(p)).toBeVisible()
|
||||
// The calendar opens on the picture day's month, the last of the trip,
|
||||
// and four days later can already be in the next one.
|
||||
if (!(await datePicker(p).getByRole('button', { name: DUE_DAY }).isVisible())) {
|
||||
await datePicker(p).getByRole('button', { name: 'Next month' }).click()
|
||||
}
|
||||
await expect(datePicker(p).getByRole('button', { name: DUE_DAY })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: datePicker,
|
||||
// The pointer on the day about to be picked, not on whatever the middle is.
|
||||
hover: async p => { await datePicker(p).getByRole('button', { name: DUE_DAY }).hover() },
|
||||
act: async p => {
|
||||
await datePicker(p).getByRole('button', { name: DUE_DAY }).click()
|
||||
await modal(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await p.getByRole('button', { name: ASSIGNEE }).click()
|
||||
await expect(modal(p).getByRole('button', { name: ASSIGNEE })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create task' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create task' }).click()
|
||||
await expect(p.locator('.trek-modal-backdrop')).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(task(p, NEW_TASK)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteTodo(p, NEW_TASK),
|
||||
},
|
||||
'todo-filters': {
|
||||
guide: guide('todo-filters'),
|
||||
start: openTodos,
|
||||
steps: [
|
||||
{
|
||||
target: p => sidebarFilter(p, 'Overdue'),
|
||||
act: async p => {
|
||||
await sidebarFilter(p, 'Overdue').click()
|
||||
await expect(p.getByRole('heading', { name: 'Overdue' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidebarFilter(p, OTHER_TASK_LIST),
|
||||
act: async p => {
|
||||
await sidebarFilter(p, OTHER_TASK_LIST).click()
|
||||
await expect(p.getByRole('heading', { name: OTHER_TASK_LIST })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidebarFilter(p, 'All').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Priority', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Priority', exact: true }).click()
|
||||
await expect(p.locator('div[role="button"][data-no-press]').first()).toContainText(EDITED_TASK, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => task(p, EDITED_TASK),
|
||||
act: async p => {
|
||||
await task(p, EDITED_TASK).click()
|
||||
await expect(todoPane(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await todoPane(p).getByRole('button', { name: 'Unassigned' }).click()
|
||||
await p.getByRole('button', { name: ASSIGNEE }).click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => todoPane(p).getByRole('button', { name: 'Save changes' }),
|
||||
act: async p => {
|
||||
await todoPane(p).getByRole('button', { name: 'Save changes' }).click()
|
||||
// The row names its assignee as an avatar, labelled with the name.
|
||||
await expect(task(p, EDITED_TASK).getByLabel(ASSIGNEE)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unassignTodo(p, EDITED_TASK),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureListsFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered lists guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripListsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-lists', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripListsContext.id, openLists)
|
||||
})
|
||||
|
||||
for (const id of tripListsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,809 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, typeInto, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureMapFixtures } from './fixtures'
|
||||
import { openTrip, openTripOnDay, selectDay, modal, dialog } from './trip-shared'
|
||||
import { ensureDawarichConnection, disconnectDawarich } from './external'
|
||||
import { tripMapContext, tripMapGuides } from '../../src/help/contexts/tripMap'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the map in the middle of a trip's plan, keyed by the ids in
|
||||
* `src/help/contexts/tripMap.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Tokyo to Kyoto train added up front, so the bulk booking-route
|
||||
* toggle has a second route to draw inside the frame; what a guide changes it
|
||||
* puts back in `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripMapGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The Leaflet map: the whole middle of the plan, with both columns floating over it. */
|
||||
const map = (page: Page) => page.locator('#trek-map')
|
||||
/** A cluster bubble. Its only text is the number of places folded into it. */
|
||||
const clusters = (page: Page) => page.locator('#trek-map .marker-cluster-custom')
|
||||
/**
|
||||
* A place pin. Cluster bubbles and the explore results are markers too, so both
|
||||
* are excluded: a bubble holds `.marker-cluster-custom`, and only a result pin
|
||||
* carries an `aria-label` (a place marker is a bare divIcon with no name at all,
|
||||
* which is why a specific place can only be found through its order badge).
|
||||
*/
|
||||
const pins = (page: Page) =>
|
||||
page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
|
||||
/** A place pin wearing the place's photo rather than its category icon. */
|
||||
const photoPins = (page: Page) => pins(page).filter({ has: page.locator('img') })
|
||||
/**
|
||||
* The pin the markers guide works on after its bubble opened, marked when it
|
||||
* was chosen: a photo pin the pointer can reach, or any reachable pin when no
|
||||
* photo has landed.
|
||||
*/
|
||||
const markerPin = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[data-help-pin]')
|
||||
/** The pin of the stop that is number `n` in the open day. */
|
||||
const badged = (page: Page, n: number) => pins(page).filter({ hasText: new RegExp(`^\\s*${n}\\s*$`) }).first()
|
||||
/** An explore result: the one kind of marker with an accessible name. */
|
||||
const poiPins = (page: Page) => page.locator('#trek-map .leaflet-marker-icon[aria-label]')
|
||||
/** The end of a drawn booking route. */
|
||||
const endpoints = (page: Page) => page.locator('#trek-map .trek-endpoint-marker')
|
||||
/** The card that follows the pointer over a pin. */
|
||||
const hoverCard = (page: Page) => page.getByTestId('tooltip')
|
||||
/**
|
||||
* A category of the explore pill. The phone portal renders a second copy of the
|
||||
* whole pill, hidden at this width, so this goes by role rather than by
|
||||
* `aria-label`, which would match both.
|
||||
*/
|
||||
const category = (page: Page, label: string) => page.getByRole('button', { name: label, exact: true })
|
||||
/** The pill itself: the nearest block holding the first category and the last. */
|
||||
const categoryRow = (page: Page) =>
|
||||
category(page, 'Restaurants').locator('xpath=ancestor::div[.//button[@aria-label="Activities"]][1]')
|
||||
/** The route icon on a booking's row, and the one in the toolbar above the days. */
|
||||
const connection = (page: Page, shown: boolean) =>
|
||||
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} booking routes"]`)
|
||||
const allConnections = (page: Page, shown: boolean) =>
|
||||
page.locator(`button[aria-label="${shown ? 'Hide' : 'Show'} all booking routes"]`)
|
||||
const overviewPanel = (page: Page) => page.getByTestId('trip-overview-panel')
|
||||
/** The round Dawarich button under the whole-trip one; its label is its state. */
|
||||
const trailPill = (page: Page) => page.getByTestId('dawarich-trail-pill')
|
||||
/**
|
||||
* The recorded route. Leaflet writes the dash pattern onto the path, and "6 5"
|
||||
* is this layer's alone: the pending booking routes use "6, 6", the picked
|
||||
* alternative "2 7", so the dashed days are the one thing on the map with it.
|
||||
*/
|
||||
const trail = (page: Page) => page.locator('#trek-map path[stroke-dasharray="6 5"]')
|
||||
/**
|
||||
* The GL renderer. There is no `#trek-map` on it: `MapViewGL` mounts a bare
|
||||
* `div.w-full.h-full` and MapLibre puts its own canvas inside, so the compass
|
||||
* guide goes by that class. The map itself is reached through
|
||||
* `window.__trek_map`, which `MapViewGL` publishes on build for exactly this.
|
||||
*/
|
||||
const glCanvas = (page: Page) => page.locator('.maplibregl-canvas')
|
||||
/**
|
||||
* The compass button and the frosted pill around it. The planner renders the
|
||||
* pill twice, once in the desktop cluster beside the category row and once for
|
||||
* phones, `md:hidden` at this width; `getByRole` leaves the hidden copy out.
|
||||
*/
|
||||
const resetNorth = (page: Page) => page.getByRole('button', { name: 'Reset north' })
|
||||
const compassPill = (page: Page) => resetNorth(page).locator('xpath=..')
|
||||
/**
|
||||
* The booking detail an endpoint opens: a DialogShell portal, so `dialog` finds
|
||||
* its panel, and its Close is the round X in the head band.
|
||||
*/
|
||||
const bookingDetail = (page: Page) => dialog(page)
|
||||
const bookingDetailClose = (page: Page) => bookingDetail(page).getByRole('button', { name: 'Close', exact: true })
|
||||
/** A row of the places column. */
|
||||
const row = (page: Page, name: string) => page.getByRole('option').filter({ hasText: name }).first()
|
||||
/**
|
||||
* The card that opens under the map when a place is selected. By its own test
|
||||
* id, not by a button in it: the collection button reads Save to Collection or
|
||||
* Saved depending on whether that place is already in a list, and the seed puts
|
||||
* the three Kyoto places in one, so a locator on the label matches for some
|
||||
* pins and for others not.
|
||||
*/
|
||||
const inspector = (page: Page) => page.getByTestId('inspector-scroll')
|
||||
/** The details panel's collapse: it keeps the day selected, where its close would drop it. */
|
||||
const collapseDayDetails = (page: Page) => page.locator('button:has(svg.lucide-chevrons-down)')
|
||||
/** A day card's header in the days column, the drop target of a dragged pin. */
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
|
||||
/**
|
||||
* Fold the day details away and take the focus off the fold button. The
|
||||
* button keeps the focus after the click, and TREK's tooltip shows on focus,
|
||||
* so its Expand would otherwise stand at the foot of every later picture.
|
||||
*/
|
||||
async function foldDayDetails(page: Page): Promise<void> {
|
||||
await collapseDayDetails(page).last().click()
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of the map between the two floating columns, in page pixels. A
|
||||
* pin under a column can still be hovered at its edge, but a picture of it is
|
||||
* a picture of the column.
|
||||
*/
|
||||
async function openCorridor(page: Page): Promise<{ left: number; right: number }> {
|
||||
const box = await map(page).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
return { left: box.x + 420, right: box.x + box.width - 420 }
|
||||
}
|
||||
|
||||
/** Like `reachable`, but the pin also has to stand in the open part of the map when one does. */
|
||||
async function reachableInView(page: Page, locator: Locator, fromEnd = false): Promise<number> {
|
||||
const { left, right } = await openCorridor(page)
|
||||
const total = await locator.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const index = fromEnd ? total - 1 - i : i
|
||||
const b = await locator.nth(index).boundingBox()
|
||||
if (!b || b.x < left || b.x + b.width > right || b.y < 260) continue
|
||||
try {
|
||||
await locator.nth(index).hover({ trial: true, timeout: 1500 })
|
||||
return index
|
||||
} catch {
|
||||
// Covered by another marker.
|
||||
}
|
||||
}
|
||||
return reachable(locator, fromEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot on the map with nothing on it: right of the days column, left of the
|
||||
* explore pill, and clear of everything the plan floats over the lower half of
|
||||
* the map. Map-relative, so it is passed as a click `position`.
|
||||
*/
|
||||
const EMPTY = { x: 470, y: 80 }
|
||||
|
||||
/**
|
||||
* The first marker of a set the pointer can actually reach. Leaflet stacks
|
||||
* markers that overlap and the two columns float over the edges of the map, so
|
||||
* `.first()` is regularly one that nothing can hover, let alone click. Explore
|
||||
* results are searched from the end because Leaflet draws the southernmost
|
||||
* marker on top.
|
||||
*/
|
||||
async function reachable(locator: Locator, fromEnd = false): Promise<number> {
|
||||
const total = await locator.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
const index = fromEnd ? total - 1 - i : i
|
||||
try {
|
||||
await locator.nth(index).hover({ trial: true, timeout: 1500 })
|
||||
return index
|
||||
} catch {
|
||||
// Covered by another marker or by one of the floating columns.
|
||||
}
|
||||
}
|
||||
throw new Error(`no reachable marker among ${total}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the map the way a reader would, so "Search this area" appears.
|
||||
*
|
||||
* Not the arrow keys: Leaflet only listens for them while its container has
|
||||
* focus, and focusing it from here did not take, so the map never moved and the
|
||||
* button that only exists once it has never appeared. The drag starts from a
|
||||
* point checked to be empty, because a press on a marker drags the place onto a
|
||||
* day instead of moving the map.
|
||||
*/
|
||||
async function panMap(page: Page, by: { x: number; y: number } = { x: -176, y: -72 }): Promise<void> {
|
||||
const box = await map(page).boundingBox()
|
||||
if (!box) throw new Error('the map has no box to drag')
|
||||
const candidates = [0.3, 0.7, 0.5].flatMap(fx => [0.75, 0.25].map(fy => ({
|
||||
x: Math.round(box.x + box.width * fx),
|
||||
y: Math.round(box.y + box.height * fy),
|
||||
})))
|
||||
let from: { x: number; y: number } | undefined
|
||||
for (const point of candidates) {
|
||||
const clear = await page.evaluate(({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
return !!el && !el.closest('.leaflet-marker-icon, button, [role="button"], .leaflet-control')
|
||||
}, point)
|
||||
if (clear) { from = point; break }
|
||||
}
|
||||
if (!from) throw new Error('found nowhere on the map to take hold of')
|
||||
await page.mouse.move(from.x, from.y)
|
||||
await page.mouse.down()
|
||||
// In steps, so Leaflet reads a drag rather than a click.
|
||||
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + (by.x * i) / 8, from.y + (by.y * i) / 8, { steps: 2 })
|
||||
await page.mouse.up()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip framed on day 1's two Tokyo stops.
|
||||
*
|
||||
* The frame is what the three guides starting here have in common: one
|
||||
* right-clicks a fixed point on the map and needs a street under it, one asks
|
||||
* the map what it can see for cafes, one puts imagery under the pins. The
|
||||
* whole-trip fit spans Japan, where that fixed point is the Sea of Japan as
|
||||
* often as it is a street and the address look-up comes back empty, and zooming
|
||||
* into whichever cluster the pointer reaches first lands on Tokyo or on Kyoto by
|
||||
* chance. Day 1 is the one frame the seed guarantees is over a city.
|
||||
*
|
||||
* The details panel is folded rather than closed: closing it deselects the day
|
||||
* (`onClose` in `TripPlannerPage` clears both) and the map goes straight back to
|
||||
* the whole trip.
|
||||
*/
|
||||
async function openOnTokyo(page: Page): Promise<void> {
|
||||
await openTripOnDay(page, 1)
|
||||
await foldDayDetails(page)
|
||||
await expect(badged(page, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which renderer draws the planner and the journals. On the account, not on
|
||||
* the trip: `MapViewAuto` reads `settings.map_provider`, and `loadSettings`
|
||||
* runs on every page load, so a `page.goto` after this sees the new map.
|
||||
*/
|
||||
async function setMapProvider(page: Page, provider: 'leaflet' | 'maplibre-gl'): Promise<void> {
|
||||
const res = await page.request.post('/api/settings/bulk', { data: { settings: { map_provider: provider } } })
|
||||
if (!res.ok()) throw new Error(`could not set map_provider=${provider}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
|
||||
/** The slice of the GL map the compass guide reads and moves, on `window.__trek_map`. */
|
||||
interface TrekGlMap {
|
||||
getBearing: () => number
|
||||
getPitch: () => number
|
||||
loaded: () => boolean
|
||||
areTilesLoaded: () => boolean
|
||||
isMoving: () => boolean
|
||||
easeTo: (o: { bearing: number; pitch: number; duration: number }) => unknown
|
||||
}
|
||||
|
||||
/** Wait until the GL map has its style, its tiles and a still camera. */
|
||||
async function glMapIdle(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && m.loaded() && m.areTilesLoaded() && !m.isMoving()
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the raster basemap has actually painted.
|
||||
*
|
||||
* `toBeVisible()` on a tile is not enough: Leaflet puts the <img> in the DOM
|
||||
* with its position and its size before the byte arrives, so a picture taken on
|
||||
* that signal catches the grey underneath. A tile counts only once the browser
|
||||
* has decoded it, and a screenful needs several of them.
|
||||
*/
|
||||
async function rasterTilesPainted(page: Page, atLeast = 6): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
min => {
|
||||
const tiles = Array.from(document.querySelectorAll('#trek-map img.leaflet-tile'))
|
||||
const painted = tiles.filter(t => {
|
||||
const img = t as HTMLImageElement
|
||||
return img.complete && img.naturalWidth > 0
|
||||
})
|
||||
return painted.length >= min
|
||||
},
|
||||
atLeast,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The markers the running guide works on. Which one is reachable is decided
|
||||
* once, by `reachable`, and the steps that follow reuse the answer; `pin` holds
|
||||
* a place pin, an explore result or a route endpoint, whichever the guide is
|
||||
* about. `found` is the name of the explore result a guide took into the trip,
|
||||
* so `cleanup` can take exactly that one out again.
|
||||
*/
|
||||
let bubble = 0
|
||||
let pin = 0
|
||||
let found = ''
|
||||
|
||||
const NEW_PLACE = 'Lunch stop'
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'map-lock': {
|
||||
guide: guide('map-lock'),
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByTestId('map-lock-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('map-lock-pill').click()
|
||||
await expect(p.getByRole('button', { name: 'Let the map follow the selection' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const unlock = p.getByRole('button', { name: 'Let the map follow the selection' })
|
||||
if (await unlock.count()) await unlock.click()
|
||||
},
|
||||
},
|
||||
'map-markers': {
|
||||
guide: guide('map-markers'),
|
||||
start: async p => {
|
||||
await openTrip(p)
|
||||
await expect(clusters(p).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
bubble = await reachable(clusters(p))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => clusters(p).nth(bubble),
|
||||
act: async p => {
|
||||
await clusters(p).nth(bubble).click()
|
||||
await expect.poll(() => pins(p).count(), { timeout: 20_000 }).toBeGreaterThan(2)
|
||||
// The pin's own photo is a thumb cut after the page has loaded, and
|
||||
// until it lands the pin is a bare icon beside a card that already
|
||||
// shows the photo. Bounded: a place with no photo anywhere stays an icon.
|
||||
await expect.poll(() => photoPins(p).count(), { timeout: 20_000 }).toBeGreaterThan(0).catch(() => {})
|
||||
await settle(p)
|
||||
// Marked rather than counted: more thumbs keep landing, and an index
|
||||
// among the photo pins would point at another pin a moment later.
|
||||
const photoPin = await reachable(photoPins(p)).catch(() => -1)
|
||||
const chosen = photoPin >= 0 ? photoPins(p).nth(photoPin) : pins(p).nth(await reachable(pins(p)))
|
||||
await chosen.evaluate(el => el.setAttribute('data-help-pin', ''))
|
||||
},
|
||||
},
|
||||
{
|
||||
// captureGuide hovers the target itself; this only makes sure the card
|
||||
// is up before the shot rather than a frame after it.
|
||||
prepare: async p => {
|
||||
await markerPin(p).hover()
|
||||
await expect(hoverCard(p)).toBeVisible({ timeout: 10_000 })
|
||||
},
|
||||
target: markerPin,
|
||||
},
|
||||
{
|
||||
// The picture worth having is the panel the click opens, not the pin
|
||||
// again, so the click is the preparation and the panel is the target.
|
||||
prepare: async p => {
|
||||
await markerPin(p).click()
|
||||
await expect(inspector(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await map(p).click({ position: EMPTY })
|
||||
await expect(inspector(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await selectDay(p, 1)
|
||||
// The panel IS the selection, so it is folded away rather than closed:
|
||||
// closing it would take the numbers off the pins again.
|
||||
await foldDayDetails(p)
|
||||
await expect(badged(p, 1)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => badged(p, 1),
|
||||
},
|
||||
{
|
||||
// Picture only, like the other drags in this family: HTML5 drag is what
|
||||
// the map hands the day plan, and a synthesised one does not start it.
|
||||
// Day 1's second stop onto day 3, the first empty day: a drop there
|
||||
// plainly plans something new, where one onto a day that already holds
|
||||
// the place would read as a move.
|
||||
prepare: async p => {
|
||||
await expect(badged(p, 2)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => badged(p, 2),
|
||||
dropTo: p => dayHeader(p, 3).locator('xpath=..'),
|
||||
},
|
||||
],
|
||||
},
|
||||
'map-nearby-places': {
|
||||
guide: guide('map-nearby-places'),
|
||||
// A search covers what is on screen, and day 1's fit is the two Tokyo stops
|
||||
// with the city between them: enough of a district for the sixty hits to
|
||||
// spread over the frame instead of piling up in the middle of it.
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{ target: categoryRow },
|
||||
{
|
||||
target: p => category(p, 'Cafés'),
|
||||
act: async p => {
|
||||
await category(p, 'Cafés').click()
|
||||
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Towards the right. The search covers the whole map, the part under
|
||||
// the two columns included, and on day 1's frame most of the first
|
||||
// answer lay under the days column; dragged this way, the west of the
|
||||
// view is open map when it is searched again, and the new pins are
|
||||
// where a reader can see them.
|
||||
await panMap(p, { x: 440, y: 96 })
|
||||
await expect(p.getByRole('button', { name: 'Search this area' })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Search this area' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Search this area' }).click()
|
||||
await expect(poiPins(p).first()).toBeVisible({ timeout: 40_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
pin = await reachableInView(p, poiPins(p), true)
|
||||
},
|
||||
target: p => poiPins(p).nth(pin),
|
||||
act: async p => {
|
||||
await poiPins(p).nth(pin).click()
|
||||
const name = modal(p).getByPlaceholder('e.g. Eiffel Tower')
|
||||
await expect(name).not.toHaveValue('', { timeout: 20_000 })
|
||||
// Which places OpenStreetMap answers with is never the same twice, so
|
||||
// the name is read off the form rather than assumed, and `cleanup`
|
||||
// deletes exactly what was created.
|
||||
found = await name.inputValue()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The whole dialog: the name is typed in its head band, outside the form.
|
||||
{ target: p => dialog(p) },
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, found)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, found),
|
||||
},
|
||||
'map-add-place': {
|
||||
guide: guide('map-add-place'),
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: map,
|
||||
// The map fills the frame, so the ring would sit nowhere in particular:
|
||||
// park the pointer on the spot the right-click is about to land on.
|
||||
hover: async p => {
|
||||
const box = await map(p).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
|
||||
},
|
||||
act: async p => {
|
||||
await map(p).click({ button: 'right', position: EMPTY })
|
||||
await expect(modal(p).getByText('Add Place/Activity')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// The address is a look-up of the coordinates and lands after the form.
|
||||
await expect(modal(p).getByPlaceholder('Latitude (e.g. 48.8566)')).not.toHaveValue('')
|
||||
await expect(modal(p).getByPlaceholder('Street, City, Country')).not.toHaveValue('', { timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByPlaceholder('Street, City, Country').locator('xpath=ancestor::div[1]'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Eiffel Tower'), NEW_PLACE)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => dialog(p),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, NEW_PLACE)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, NEW_PLACE),
|
||||
},
|
||||
'map-satellite': {
|
||||
guide: guide('map-satellite'),
|
||||
start: async p => {
|
||||
await openOnTokyo(p)
|
||||
pin = await reachable(pins(p))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Switch to satellite view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Switch to satellite view' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Switch to map view' })).toBeVisible()
|
||||
// The imagery is raster tiles from a third party, and the switch is
|
||||
// done long before they are on screen.
|
||||
await rasterTilesPainted(p)
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
},
|
||||
{ prepare: p => rasterTilesPainted(p), target: p => pins(p).nth(pin) },
|
||||
{
|
||||
prepare: p => rasterTilesPainted(p),
|
||||
target: p => p.getByRole('button', { name: 'Switch to map view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Switch to map view' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Switch to satellite view' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The layer is kept on the account, not on the trip: left on satellite it
|
||||
// would be the basemap of every picture taken after this guide.
|
||||
cleanup: async p => {
|
||||
await p.request.post('/api/settings/bulk', { data: { settings: { map_base_layer: 'default' } } })
|
||||
},
|
||||
},
|
||||
'map-whole-trip': {
|
||||
guide: guide('map-whole-trip'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByTestId('trip-overview-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('trip-overview-pill').click()
|
||||
// One router request per leg, paced, so the card takes its time.
|
||||
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The total carries an … until every leg has answered; a picture taken
|
||||
// before that shows a partial sum.
|
||||
prepare: async p => {
|
||||
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: overviewPanel,
|
||||
},
|
||||
{
|
||||
target: p => overviewPanel(p).getByRole('button').first(),
|
||||
act: async p => {
|
||||
await overviewPanel(p).getByRole('button').first().click()
|
||||
await settle(p)
|
||||
// The text's second half: the wheel over Tokyo. With the whole trip
|
||||
// shown, the camera stays on all of it after the pick (the fit that
|
||||
// follows takes in every day's line), and there a day of ten
|
||||
// kilometres is a few pixels under a bubble; four notches bring days
|
||||
// 1 and 2 out side by side. Tokyo is the easternmost bubble of the seed.
|
||||
const boxes = await clusters(p).evaluateAll(els => els.map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
|
||||
}))
|
||||
const tokyo = boxes.sort((a, b) => b.x - a.x)[0]
|
||||
if (!tokyo) throw new Error('no bubble on the map to zoom in on')
|
||||
await p.mouse.move(tokyo.x, tokyo.y)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await p.mouse.wheel(0, -100)
|
||||
await p.waitForTimeout(450)
|
||||
}
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// No act: the guide ends with the whole trip on the map, which is what
|
||||
// the result picture is of. `cleanup` presses the button.
|
||||
{ target: p => p.getByRole('button', { name: 'Hide whole trip' }) },
|
||||
],
|
||||
cleanup: async p => {
|
||||
const off = p.getByRole('button', { name: 'Hide whole trip' })
|
||||
if (await off.isVisible().catch(() => false)) await off.click()
|
||||
},
|
||||
},
|
||||
'map-booking-routes': {
|
||||
guide: guide('map-booking-routes'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{ target: p => connection(p, false).first() },
|
||||
{
|
||||
prepare: async p => {
|
||||
await connection(p, false).first().click()
|
||||
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(1)
|
||||
await settle(p)
|
||||
pin = await reachable(endpoints(p))
|
||||
},
|
||||
target: p => endpoints(p).nth(pin),
|
||||
act: async p => {
|
||||
await endpoints(p).nth(pin).click()
|
||||
await expect(bookingDetailClose(p)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole detail, head band to footer: the text walks through all of it.
|
||||
target: bookingDetail,
|
||||
act: async p => {
|
||||
await bookingDetailClose(p).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => allConnections(p, false),
|
||||
act: async p => {
|
||||
await allConnections(p, false).click()
|
||||
await expect.poll(() => endpoints(p).count(), { timeout: 30_000 }).toBeGreaterThan(2)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => allConnections(p, true),
|
||||
act: async p => {
|
||||
await allConnections(p, true).click()
|
||||
await expect(endpoints(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The per-trip choice lives in this browser, not on the server, so it would
|
||||
// otherwise carry into every guide that runs after it in the same context.
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
await p.evaluate(id => localStorage.removeItem(`trek:visible-connections:${id}`), tripId)
|
||||
},
|
||||
},
|
||||
'map-dawarich-trail': {
|
||||
guide: guide('map-dawarich-trail'),
|
||||
// Day 1 in Tokyo: at the whole-trip zoom the recording is one dashed line
|
||||
// along the Shinkansen and the walks through the cities vanish under the
|
||||
// pins, so the map is framed on a day before the button is pressed;
|
||||
// nothing fits the map to the trail.
|
||||
start: openOnTokyo,
|
||||
steps: [
|
||||
{
|
||||
target: trailPill,
|
||||
act: async p => {
|
||||
await trailPill(p).click()
|
||||
// One read of the trip's dates from the Dawarich, thinned to 600
|
||||
// points a day. The label is the reliable signal: anything but Hide
|
||||
// (nothing recorded, unreachable, offline) is not a picture worth
|
||||
// taking, so the wait fails on it rather than shooting an empty map.
|
||||
await expect(trailPill(p)).toHaveAttribute('aria-label', 'Hide recorded route', { timeout: 120_000 })
|
||||
await expect(trail(p).first()).toBeAttached({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
await p.waitForTimeout(600)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole map: the dashed day under the planned route, with the
|
||||
// pointer parked on an empty spot so no pin's card is in the picture.
|
||||
target: map,
|
||||
hover: async p => {
|
||||
const box = await map(p).boundingBox()
|
||||
if (!box) throw new Error('the map has no box')
|
||||
await p.mouse.move(box.x + EMPTY.x, box.y + EMPTY.y)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByTestId('trip-overview-pill'),
|
||||
act: async p => {
|
||||
await p.getByTestId('trip-overview-pill').click()
|
||||
// One router request per leg, paced; the total carries an … until
|
||||
// every leg has answered, and the result picture waits for that.
|
||||
await expect(overviewPanel(p)).toBeVisible({ timeout: 60_000 })
|
||||
await expect(overviewPanel(p)).not.toContainText('…', { timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const off = p.getByRole('button', { name: 'Hide whole trip' })
|
||||
if (await off.isVisible().catch(() => false)) await off.click()
|
||||
if ((await trailPill(p).getAttribute('aria-pressed').catch(() => null)) === 'true') await trailPill(p).click()
|
||||
// Per trip and per browser session; the test's context ends here, but
|
||||
// be explicit rather than trust that.
|
||||
const { tripId } = seededTrip()
|
||||
await p.evaluate(id => sessionStorage.removeItem(`trip-dawarich-${id}`), tripId)
|
||||
},
|
||||
},
|
||||
|
||||
'map-compass': {
|
||||
guide: guide('map-compass'),
|
||||
// The compass only exists on the GL renderers, so the guide switches the
|
||||
// account to MapLibre GL (no token, OpenFreeMap tiles) before opening the
|
||||
// trip, and `cleanup` puts Leaflet back: every other map guide goes by
|
||||
// `#trek-map`, which the GL map does not have.
|
||||
start: async p => {
|
||||
await setMapProvider(p, 'maplibre-gl')
|
||||
await openTrip(p)
|
||||
// MapViewAuto shows Leaflet while the GL chunk loads, then swaps; WebGL
|
||||
// runs on SwiftShader headless at 1920×1080×2, so the style, the vector
|
||||
// tiles from tiles.openfreemap.org and the day-1 framing take their time.
|
||||
await expect(glCanvas(p)).toBeVisible({ timeout: 60_000 })
|
||||
await expect(resetNorth(p)).toBeVisible({ timeout: 30_000 })
|
||||
await glMapIdle(p)
|
||||
await settle(p)
|
||||
// `settle` counts `<img>` loads; a WebGL canvas has none, so give the
|
||||
// tiles a moment to paint or the picture catches the blank canvas.
|
||||
await p.waitForTimeout(2000)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// The turn itself is the map's own camera move rather than a synthetic
|
||||
// right-button drag: a drag has to start on bare canvas, and the pins,
|
||||
// the two floating columns and the explore pill all sit over it. 35°
|
||||
// is enough for the streets to lie visibly askew and the arrow to lean.
|
||||
prepare: async p => {
|
||||
await p.evaluate(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
if (!m) throw new Error('no GL map on the page')
|
||||
m.easeTo({ bearing: 35, pitch: 0, duration: 0 })
|
||||
})
|
||||
await p.waitForFunction(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && Math.abs(m.getBearing() - 35) < 0.5 && !m.isMoving()
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
target: compassPill,
|
||||
},
|
||||
{
|
||||
target: compassPill,
|
||||
act: async p => {
|
||||
await resetNorth(p).click()
|
||||
// `easeTo` takes 300 ms; the compass is only upright once it has landed.
|
||||
await p.waitForFunction(() => {
|
||||
const m = (window as unknown as { __trek_map?: TrekGlMap }).__trek_map
|
||||
return !!m && Math.abs(m.getBearing()) < 0.5 && Math.abs(m.getPitch()) < 0.5 && !m.isMoving()
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The renderer is kept on the account, not on the trip: left on MapLibre it
|
||||
// would be the map of every picture taken after this guide, and every
|
||||
// `#trek-map` locator in the other guides would find nothing.
|
||||
cleanup: p => setMapProvider(p, 'leaflet'),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureMapFixtures(request)
|
||||
// The recorded route: the addon on, the signed-in user connected to the
|
||||
// Dawarich from media.env, the synthetic recording of the trip uploaded.
|
||||
await ensureDawarichConnection(request)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, not trip-scoped: while the addon is on, every trip map
|
||||
// carries the round Dawarich button in its bottom-right corner. This file's
|
||||
// pictures show it on purpose; the other trip screens' do not, so it goes
|
||||
// off again here, whatever happened in between. The renderer likewise: the
|
||||
// compass guide puts Leaflet back in its cleanup, but a guide that fails
|
||||
// never reaches its cleanup, and every `#trek-map` locator in the files that
|
||||
// run after this one would find a GL canvas instead.
|
||||
await disconnectDawarich(request)
|
||||
await request.post('/api/settings/bulk', { data: { settings: { map_provider: 'leaflet' } } })
|
||||
})
|
||||
|
||||
test('every registered map guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripMapContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-map', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripMapContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripMapContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, coverFixture, ensureTrack, ensureDaysFixtures, ensurePlaceFixtures, PLACE_WEBSITE, RATED_PLACE } from './fixtures'
|
||||
import { openTrip, selectDay, modal, dialog } from './trip-shared'
|
||||
import { long } from '../dates'
|
||||
import { tripPlaceContext, tripPlaceGuides } from '../../src/help/contexts/tripPlace'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the place card that floats over the map, keyed by the ids in
|
||||
* `src/help/contexts/tripPlace.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with the Kyoto walk imported, the day fixtures in place and this screen's own
|
||||
* fixtures on top; what a guide changes it changes back in `cleanup`, so the
|
||||
* next one starts from the same card.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripPlaceGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** An unplanned waypoint of the imported walk: the place that is on no day yet. */
|
||||
const SPARE = 'Ginkaku-ji'
|
||||
/** The walk itself: the only place of the trip that carries a path. */
|
||||
const TRACK = "Philosopher's Path"
|
||||
/** What the GPX import hands the trip's first track: TRACK_COLORS[0]. */
|
||||
const TRACK_IMPORT_COLOR = '#1d4ed8'
|
||||
/** The colour the guide picks instead: TRACK_COLORS[1]. */
|
||||
const TRACK_PICKED_COLOR = '#ea580c'
|
||||
/** The booking `ensureDaysFixtures` pins to the market stop on day 6. */
|
||||
const BOOKING = 'Lunch at Nishiki'
|
||||
/** The only list the seed creates. */
|
||||
const LIST = 'Kyoto shortlist'
|
||||
/** The list's row in the picker: its name, then the status chip once the place is in it. */
|
||||
const LIST_ROW = new RegExp(`^${LIST}(?: (?:Idea|Planned|Visited))?$`)
|
||||
const UPLOADED = 'cover-fixture.jpg'
|
||||
|
||||
/** The card itself: the scroll body's parent holds head, body and footer. */
|
||||
const card = (page: Page) => page.getByTestId('inspector-scroll').locator('xpath=..')
|
||||
const head = (page: Page) => card(page).locator('> header')
|
||||
const footer = (page: Page) => card(page).locator('> footer')
|
||||
/** A block of the card by its heading: every block is a <section> that opens with it. */
|
||||
const section = (page: Page, heading: string) =>
|
||||
card(page).getByText(heading, { exact: true }).locator('xpath=ancestor::section[1]')
|
||||
/** A row of the places column (the rows are options of a list). */
|
||||
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
/** A stop inside a day card. Day notes and to-dos share the class, so filter by the name. */
|
||||
const stop = (page: Page, name: string) => page.locator('.dp-row[role="button"]').filter({ hasText: name }).first()
|
||||
/** PlaceRating's root: the stars, the average and the voter faces on one line. */
|
||||
const ratingRow = (page: Page) => card(page).getByRole('radiogroup', { name: 'Your rating' }).locator('xpath=..')
|
||||
/** ParticipantsBox: the block under its heading. */
|
||||
const participants = (page: Page) => section(page, 'Participants')
|
||||
/** A member's chip, or, once the chip is gone, that member's line in the add list. */
|
||||
const member = (page: Page, name: string) => participants(page).getByRole('button', { name: new RegExp(name) })
|
||||
const addMember = (page: Page) => participants(page).getByRole('button', { name: 'Add', exact: true })
|
||||
/** "Files" while the place has none, "<n> files" once it has. */
|
||||
const filesToggle = (page: Page) => card(page).getByRole('button', { name: /^(Files|\d+ files)$/ })
|
||||
/** The booking strip opens the booking; it is a button named after it. */
|
||||
const bookingStrip = (page: Page) => card(page).getByRole('button', { name: `Open booking: ${BOOKING}` })
|
||||
/** The booking the strip opens, named by its title. Its form is one Edit further. */
|
||||
const bookingDetail = (page: Page) => page.getByRole('dialog', { name: BOOKING })
|
||||
/** The strip's lower row: Date, Time and Booking Code beside each other. */
|
||||
const bookingFields = (page: Page) => bookingStrip(page).locator('> div').nth(1)
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
// Exact: the picker's own button is named "Track color: #EA580C" once the colour is set.
|
||||
const swatch = (page: Page) => card(page).getByRole('button', { name: TRACK_PICKED_COLOR, exact: true })
|
||||
/** The seed's day 1: the trip starts nine days before the picture day (`start_date: day(-9)`). */
|
||||
const DAY_ONE = -9
|
||||
/** The weekday the English UI prints for a seeded day, `Saturday` on a run where day 1 is one. */
|
||||
const weekday = (offset: number) => long(offset).split(',')[0]
|
||||
/**
|
||||
* The Opening Hours row: the one button of the card with a clock in it. Found
|
||||
* by the icon rather than the label, because the label is the open day's line
|
||||
* ("Saturday: 06:00-17:00") and the day moves with the run.
|
||||
*/
|
||||
const hoursRow = (page: Page) => card(page).locator('button:has(svg.lucide-clock)')
|
||||
/** The hours block: the row and, once unfolded, the week in its place. */
|
||||
const hoursBox = (page: Page) => section(page, 'Opening Hours')
|
||||
|
||||
/** The card's own X, the one in its head; the day panel over the map has another. */
|
||||
async function closeCard(page: Page): Promise<void> {
|
||||
await head(page).locator('button:has(svg.lucide-x)').click()
|
||||
await expect(card(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the provider's answer for a place into the card's own cache before the
|
||||
* card asks for it. The card reads `sessionStorage` under this key first
|
||||
* (PlaceInspector's usePlaceDetails) and only then asks the server, with an
|
||||
* eight-second limit that Overpass exceeds as often as not; asked here over
|
||||
* the API there is no such limit, and a thin answer is asked again.
|
||||
*/
|
||||
async function primeDetails(page: Page, placeName: string): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { name: string; osm_id?: string | null }[] } | { name: string; osm_id?: string | null }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
const osmId = places.find(p => p.name === placeName)?.osm_id
|
||||
if (!osmId) throw new Error(`${placeName} carries no OpenStreetMap id; ensurePlaceFixtures did not run`)
|
||||
type Details = { opening_hours?: string[] | null } | null
|
||||
const hasWeek = (d: Details): boolean => Array.isArray(d?.opening_hours) && d.opening_hours.length === 7
|
||||
let details: Details = null
|
||||
for (let attempt = 1; attempt <= 3 && !hasWeek(details); attempt++) {
|
||||
const answer = await page.request.get(`/api/maps/details/${encodeURIComponent(osmId)}?lang=en`, { timeout: 45_000 })
|
||||
details = answer.ok() ? ((await answer.json()) as { place: Details }).place : null
|
||||
}
|
||||
if (!hasWeek(details)) throw new Error(`no opening hours for ${placeName} (${osmId}) from the details route`)
|
||||
await page.evaluate(([key, value]) => sessionStorage.setItem(key, value), [`gdetails_${osmId}_en`, JSON.stringify(details)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a place that carries an OpenStreetMap id and wait until the provider's
|
||||
* answer is on the card.
|
||||
*
|
||||
* The hours, the ring and the phone number arrive one request after the card:
|
||||
* the server asks Overpass and Nominatim for them, caches nothing on that path,
|
||||
* and the card swallows a lookup that times out. `settle` waits five seconds
|
||||
* for the network at most, which a slow mirror exceeds, and the card would then
|
||||
* be pictured without the feature the text is about. So this waits for the
|
||||
* hours row itself, and when it has not come, closes the card and opens the
|
||||
* place again, which fires the lookup afresh (closing clears the id the hook
|
||||
* watches). A third miss fails the run. With `day` the row's line is checked
|
||||
* against that day's weekday, which is what the step text promises; with
|
||||
* `phone` the tel link has to be there too.
|
||||
*/
|
||||
async function openWithHours(
|
||||
page: Page,
|
||||
open: (p: Page) => Promise<void>,
|
||||
opts: { day?: number; phone?: RegExp; place?: string } = {},
|
||||
): Promise<void> {
|
||||
await primeDetails(page, opts.place ?? 'Senso-ji Temple')
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
await open(page)
|
||||
const shown = await hoursRow(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false)
|
||||
if (shown) break
|
||||
if (attempt === 3) throw new Error(`no opening hours on the card after ${attempt} openings`)
|
||||
await closeCard(page)
|
||||
}
|
||||
if (opts.day !== undefined) await expect(hoursRow(page)).toHaveText(new RegExp(`^${weekday(opts.day)}: `))
|
||||
if (opts.phone) await expect(card(page).getByRole('link', { name: opts.phone })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openPlace(page: Page, name: string): Promise<void> {
|
||||
await row(page, name).click()
|
||||
await expect(card(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Opening the card from a stop is what gives it the stop's participants and booking. */
|
||||
async function openStop(page: Page, name: string): Promise<void> {
|
||||
await stop(page, name).click()
|
||||
await expect(card(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── The seed over the API, for the cleanups ──────────────────────────────────
|
||||
|
||||
type ApiPlace = { id: number; name: string; lat?: number | null; lng?: number | null }
|
||||
type ApiAssignment = { id: number; place_id: number }
|
||||
|
||||
async function tripPlaces(page: Page): Promise<ApiPlace[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: ApiPlace[] } | ApiPlace[]
|
||||
return Array.isArray(body) ? body : (body.places ?? [])
|
||||
}
|
||||
|
||||
const findPlace = async (page: Page, name: string): Promise<ApiPlace | undefined> =>
|
||||
(await tripPlaces(page)).find(p => p.name === name)
|
||||
|
||||
async function patchPlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const place = await findPlace(page, name)
|
||||
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
|
||||
}
|
||||
|
||||
async function assignmentsOf(page: Page, dayIndex: number): Promise<ApiAssignment[]> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
|
||||
const body = (await res.json()) as { assignments?: ApiAssignment[] }
|
||||
return body.assignments ?? []
|
||||
}
|
||||
|
||||
async function assignmentFor(page: Page, dayIndex: number, name: string): Promise<ApiAssignment | undefined> {
|
||||
const place = await findPlace(page, name)
|
||||
if (!place) return undefined
|
||||
return (await assignmentsOf(page, dayIndex)).find(a => a.place_id === place.id)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
/**
|
||||
* Take a place out of every collection it sits in.
|
||||
*
|
||||
* Run before the guide as well as after it: the Collections screen's own guides
|
||||
* run earlier in the suite and leave places saved, and a place that is already
|
||||
* in a list offers Saved where this guide's first step looks for Save to
|
||||
* Collection.
|
||||
*/
|
||||
async function unsaveEverywhere(page: Page, name: string): Promise<void> {
|
||||
const place = await findPlace(page, name)
|
||||
if (!place) return
|
||||
const query = new URLSearchParams({ name: place.name, lat: String(place.lat ?? ''), lng: String(place.lng ?? '') })
|
||||
const res = await page.request.get(`/api/addons/collections/membership?${query.toString()}`)
|
||||
if (!res.ok()) return
|
||||
const body = (await res.json()) as { lists?: { place_id: number }[] }
|
||||
for (const entry of body.lists ?? []) {
|
||||
await page.request.delete(`/api/addons/collections/places/${entry.place_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'read-place': {
|
||||
guide: guide('read-place'),
|
||||
// Day 1 explicitly: closing a day's detail panel clears the open day, and
|
||||
// the card is a different card without one.
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'Senso-ji Temple'),
|
||||
act: p => openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ }),
|
||||
},
|
||||
only(head),
|
||||
only(ratingRow),
|
||||
only(p => card(p).locator('.collab-note-md').first()),
|
||||
only(participants),
|
||||
{
|
||||
// Unfolded before the picture: the step is about the week, and a ring
|
||||
// around the collapsed row would show the reader one line of it.
|
||||
prepare: async p => {
|
||||
await hoursRow(p).click()
|
||||
await expect(hoursBox(p).getByRole('button', { name: 'Collapse' })).toBeVisible()
|
||||
await expect(hoursBox(p).getByText(/^Sunday: /)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: hoursBox,
|
||||
},
|
||||
only(footer),
|
||||
],
|
||||
},
|
||||
'rate-place': {
|
||||
guide: guide('rate-place'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openPlace(p, RATED_PLACE)
|
||||
},
|
||||
steps: [
|
||||
only(ratingRow),
|
||||
{
|
||||
target: p => card(p).getByRole('radio', { name: '4', exact: true }),
|
||||
act: async p => {
|
||||
await card(p).getByRole('radio', { name: '4', exact: true }).click()
|
||||
await expect(card(p).getByRole('radio', { name: '4', exact: true }))
|
||||
.toHaveAttribute('aria-checked', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// captureGuide hovers the target before it shoots, which is the gesture
|
||||
// the step describes; the tooltip is a portal, so it is asserted here.
|
||||
target: ratingRow,
|
||||
act: async p => { await expect(p.getByRole('tooltip')).toBeVisible() },
|
||||
},
|
||||
only(p => row(p, RATED_PLACE)),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const place = await findPlace(p, RATED_PLACE)
|
||||
// Only the admin's vote: the two members' votes are the fixture's.
|
||||
if (place) await p.request.delete(`/api/trips/${tripId}/places/${place.id}/rating`)
|
||||
},
|
||||
},
|
||||
'place-image': {
|
||||
guide: guide('place-image'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => row(p, 'Arashiyama Bamboo Grove'),
|
||||
act: p => openPlace(p, 'Arashiyama Bamboo Grove'),
|
||||
},
|
||||
{
|
||||
target: p => card(p).getByRole('button', { name: /^(Upload image|Change image)$/ }),
|
||||
act: async p => {
|
||||
await card(p).locator('input[accept*="image/jpeg"]').setInputFiles(await coverFixture())
|
||||
await expect(card(p).getByRole('button', { name: 'Remove image' })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(head),
|
||||
only(p => row(p, 'Arashiyama Bamboo Grove')),
|
||||
],
|
||||
cleanup: p => patchPlace(p, 'Arashiyama Bamboo Grove', { image_url: null }),
|
||||
},
|
||||
'place-day-assign': {
|
||||
guide: guide('place-day-assign'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => dayHeader(p, 6),
|
||||
act: p => selectDay(p, 6),
|
||||
},
|
||||
{
|
||||
target: p => row(p, SPARE),
|
||||
act: async p => {
|
||||
await openPlace(p, SPARE)
|
||||
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Add to Day' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Add to Day' }).click()
|
||||
await expect(footer(p).getByRole('button', { name: 'Remove from Day' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The gesture only: dragging it here would leave the day reordered for the
|
||||
// last step's picture, and the arrow already says what to do.
|
||||
{ target: p => stop(p, SPARE), dropTo: p => stop(p, 'Arashiyama Bamboo Grove') },
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Remove from Day' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Remove from Day' }).click()
|
||||
await expect(footer(p).getByRole('button', { name: 'Add to Day' })).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const assignment = await assignmentFor(p, 5, SPARE)
|
||||
if (assignment) await p.request.delete(`/api/trips/${tripId}/days/${dayIds[5]}/assignments/${assignment.id}`)
|
||||
},
|
||||
},
|
||||
'place-participants': {
|
||||
guide: guide('place-participants'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'teamLab Planets'),
|
||||
act: async p => {
|
||||
await openStop(p, 'teamLab Planets')
|
||||
await expect(participants(p)).toBeVisible()
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => member(p, 'jonas'),
|
||||
act: async p => {
|
||||
await member(p, 'jonas').click()
|
||||
await expect(member(p, 'jonas')).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await addMember(p).click()
|
||||
await expect(member(p, 'jonas')).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: participants,
|
||||
},
|
||||
{
|
||||
target: p => member(p, 'jonas'),
|
||||
act: async p => {
|
||||
await member(p, 'jonas').click()
|
||||
await expect(addMember(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const assignment = await assignmentFor(p, 0, 'teamLab Planets')
|
||||
// An empty list is what "everybody is going" is stored as.
|
||||
if (assignment) {
|
||||
await p.request.put(`/api/trips/${tripId}/assignments/${assignment.id}/participants`, { data: { user_ids: [] } })
|
||||
}
|
||||
},
|
||||
},
|
||||
'place-booking': {
|
||||
guide: guide('place-booking'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 6)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => stop(p, 'Nishiki Market'),
|
||||
act: async p => {
|
||||
await openStop(p, 'Nishiki Market')
|
||||
await expect(card(p).getByText(BOOKING)).toBeVisible()
|
||||
},
|
||||
},
|
||||
// The fields rather than the whole strip: step 3 rings the strip, and two
|
||||
// steps pointing at one element give the reader the same picture twice.
|
||||
only(bookingFields),
|
||||
{
|
||||
target: bookingStrip,
|
||||
act: async p => {
|
||||
await bookingStrip(p).click()
|
||||
await expect(bookingDetail(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await bookingDetail(p).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText('Link to day assignment', { exact: true }).locator('xpath=..'),
|
||||
act: closeModal,
|
||||
},
|
||||
{
|
||||
// The + is rendered only while its own stop is hovered, so the row is
|
||||
// put in view and hovered before the target is looked up, and hovered
|
||||
// again for the picture instead of the button itself.
|
||||
prepare: async p => {
|
||||
await stop(p, 'Nishiki Market').scrollIntoViewIfNeeded()
|
||||
await stop(p, 'Nishiki Market').hover()
|
||||
await expect(stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' })).toBeVisible()
|
||||
},
|
||||
target: p => stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }),
|
||||
hover: p => stop(p, 'Nishiki Market').hover(),
|
||||
act: async p => {
|
||||
await stop(p, 'Nishiki Market').getByRole('button', { name: 'Add booking' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'New Reservation' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The new booking is never saved, so there is nothing to delete.
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'place-files': {
|
||||
guide: guide('place-files'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openPlace(p, 'teamLab Planets')
|
||||
},
|
||||
steps: [
|
||||
only(filesToggle),
|
||||
{
|
||||
target: p => card(p).getByText('Upload', { exact: true }),
|
||||
act: async p => {
|
||||
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
|
||||
await expect(card(p).getByRole('button', { name: '1 files' })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The list opens itself once the upload lands.
|
||||
only(p => card(p).getByRole('button', { name: '1 files' })),
|
||||
// No click: openFile hands the browser a download.
|
||||
only(p => card(p).getByRole('button', { name: UPLOADED })),
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/files`)
|
||||
const body = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of (body.files ?? []).filter(f => f.original_name === UPLOADED)) {
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
await p.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
},
|
||||
},
|
||||
'place-navigation': {
|
||||
guide: guide('place-navigation'),
|
||||
start: async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await openWithHours(p, q => openPlace(q, PLACE_WEBSITE.place), { place: PLACE_WEBSITE.place })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Navigation' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Navigation' }).click()
|
||||
await expect(p.getByRole('menu')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByRole('menu')),
|
||||
{
|
||||
// Clicking one opens an external tab, so the menu is closed instead.
|
||||
target: p => p.getByRole('menuitem', { name: 'Google Maps' }),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(p.getByRole('menu')).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
only(p => footer(p).getByRole('button', { name: 'Open Website' })),
|
||||
],
|
||||
},
|
||||
'place-to-collection': {
|
||||
guide: guide('place-to-collection'),
|
||||
start: async p => {
|
||||
await unsaveEverywhere(p, 'Senso-ji Temple')
|
||||
await openTrip(p, { day: null })
|
||||
await openWithHours(p, q => openPlace(q, 'Senso-ji Temple'))
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => footer(p).getByRole('button', { name: 'Save to Collection' }),
|
||||
act: async p => {
|
||||
await footer(p).getByRole('button', { name: 'Save to Collection' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Save to list' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(dialog),
|
||||
{
|
||||
// Anchored, not exact: the Collections guides leave a list behind whose
|
||||
// name opens with this one's, and once the place is saved the row's
|
||||
// name grows the status chip (Idea, Planned, Visited).
|
||||
target: p => modal(p).getByRole('button', { name: LIST_ROW }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: LIST_ROW }).click()
|
||||
await expect(modal(p).getByRole('button', { name: LIST_ROW })).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Close' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Close' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(footer(p).getByRole('button', { name: 'Saved' })).toBeVisible({ timeout: 20_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => unsaveEverywhere(p, 'Senso-ji Temple'),
|
||||
},
|
||||
'place-track': {
|
||||
guide: guide('place-track'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => row(p, TRACK),
|
||||
act: async p => {
|
||||
await openPlace(p, TRACK)
|
||||
await expect(card(p).getByText('Track Stats', { exact: true })).toBeVisible()
|
||||
},
|
||||
},
|
||||
only(p => section(p, 'Track Stats')),
|
||||
{
|
||||
target: p => card(p).getByRole('button', { name: 'Track color' }),
|
||||
act: async p => {
|
||||
await card(p).getByRole('button', { name: 'Track color' }).click()
|
||||
await expect(swatch(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: swatch,
|
||||
act: async p => {
|
||||
await swatch(p).click()
|
||||
await expect(swatch(p)).toHaveAttribute('aria-pressed', 'true', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// No click: it would undo the result picture. The reset happens in cleanup.
|
||||
only(p => card(p).getByRole('button', { name: 'Automatic color' })),
|
||||
],
|
||||
cleanup: p => patchPlace(p, TRACK, { route_color: TRACK_IMPORT_COLOR }),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
|
||||
await ensureDaysFixtures(request)
|
||||
await ensurePlaceFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered place guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlaceContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-place', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripPlaceContext.id, async p => {
|
||||
await openTrip(p, { day: null })
|
||||
await selectDay(p, 1)
|
||||
await openWithHours(p, q => openStop(q, 'Senso-ji Temple'), { day: DAY_ONE, phone: /^\+81/ })
|
||||
})
|
||||
})
|
||||
|
||||
for (const id of tripPlaceContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, gpxFixture, ensureTrack } from './fixtures'
|
||||
import { openTrip, modal, dialog, confirmDialog } from './trip-shared'
|
||||
import { tripPlacesContext, tripPlacesGuides } from '../../src/help/contexts/tripPlaces'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the places column of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripPlaces.ts`. They run on the seeded "Autumn in Japan"
|
||||
* with one walk imported up front, so the Tracks filter exists; what a guide
|
||||
* creates it takes away again in `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripPlacesGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
const SPARE = { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' }
|
||||
/** What create-place looks up. See the note in its second step for why this one. */
|
||||
const SEARCHED = { query: 'Osaka Castle', match: /^Osaka Castle/ }
|
||||
|
||||
/** The search result create-place picks. */
|
||||
const suggestion = (page: Page) =>
|
||||
modal(page).locator('.shadow-dropdown button').filter({ hasText: SEARCHED.match }).first()
|
||||
/** A row of the places column by the place's name (the rows are options of a list). */
|
||||
const row = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
/** The context menu is a fixed popover of plain buttons; the item texts tell it apart. */
|
||||
const menu = (page: Page) => page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Delete' }) }).last()
|
||||
/** The add button: "Add Place/Activity" with no day open, "New place" with one. */
|
||||
const addButton = (page: Page) => page.getByRole('button', { name: /^(Add Place\/Activity|New place)$/ })
|
||||
/**
|
||||
* The head band of the column: the add row, the search with the select switch,
|
||||
* and the row of filters. Found from its import button, the one control only
|
||||
* this band has, so the plain "Search" of the field is read inside it.
|
||||
*/
|
||||
const head = (page: Page) =>
|
||||
page.getByRole('button', { name: 'Import Places' }).locator('xpath=ancestor::div[contains(@class,"flex-col")][1]')
|
||||
/** The import button's menu: Import file and the list import. */
|
||||
const importMenu = (page: Page) =>
|
||||
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Import file' }) }).last()
|
||||
/** The Show dropdown (All, Unplanned, Planned, Tracks) and the list it opens. */
|
||||
const showTrigger = (page: Page) => head(page).getByRole('button', { name: 'Show', exact: true })
|
||||
const showList = (page: Page) => page.getByTestId('places-filter').locator('xpath=..')
|
||||
/** The category filter: a tag button whose list sits right after it. */
|
||||
const categoryTrigger = (page: Page) => head(page).getByRole('button', { name: 'Categories', exact: true })
|
||||
const categoryList = (page: Page) => categoryTrigger(page).locator('xpath=following-sibling::div[1]')
|
||||
const searchBox = (page: Page) => head(page).getByPlaceholder('Search', { exact: true })
|
||||
/** The selection bar at the foot of the column, found by its delete action. */
|
||||
const selectionBar = (page: Page) => page.getByRole('button', { name: 'Delete selected' }).locator('xpath=..')
|
||||
/** FileImportModal, recognised by its hidden file input. */
|
||||
const importFileDialog = (page: Page) =>
|
||||
page.getByRole('dialog').filter({ has: page.locator('input[type="file"][accept*=".gpx"]') })
|
||||
/** ListImportModal, recognised by the link box. */
|
||||
const listImportDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder(/goo\.gl|naver/) })
|
||||
/** The drop box of the file dialog: the dashed button that opens the file picker. */
|
||||
const dropBox = (page: Page) => importFileDialog(page).locator('button.border-dashed').first()
|
||||
/** The column itself: the header block around the search box. */
|
||||
const column = (page: Page) => searchBox(page).locator('xpath=ancestor::div[contains(@class,"scroll") or @data-places-sidebar][1]')
|
||||
|
||||
async function createSpare(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const created = await page.request.post(`/api/trips/${tripId}/places`, { data: SPARE })
|
||||
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => names.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const sortTrigger = (page: Page) => head(page).getByRole('button', { name: 'Sort by', exact: true })
|
||||
const sortList = (page: Page) => sortTrigger(page).locator('xpath=following-sibling::div[1]')
|
||||
/** A name no index knows, so the search comes back empty and offers Add by hand. */
|
||||
const HANDMADE = { name: 'Kissaten Hoshizora Ura', phone: '+81 75 000 1234', email: 'hello@hoshizora.example' }
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'sort-places': {
|
||||
guide: guide('sort-places'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await sortTrigger(p).click()
|
||||
await expect(sortList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: sortList,
|
||||
},
|
||||
{
|
||||
target: p => sortList(p).getByRole('button', { name: 'Highest rated' }),
|
||||
act: async p => {
|
||||
await sortList(p).getByRole('button', { name: 'Highest rated' }).click()
|
||||
await expect(sortList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await sortTrigger(p).click()
|
||||
await sortList(p).getByRole('button', { name: 'Recently added' }).click()
|
||||
},
|
||||
},
|
||||
'place-by-hand': {
|
||||
guide: guide('place-by-hand'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await addButton(p).click()
|
||||
const box = modal(p).getByPlaceholder('Search places...')
|
||||
await expect(box).toBeVisible()
|
||||
await typeInto(p, box, HANDMADE.name)
|
||||
await box.press('Enter')
|
||||
await expect(modal(p).getByText(/^Nothing found for/)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Add by hand' }).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add by hand' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add by hand' }).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(HANDMADE.name)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByLabel('Phone', { exact: true }), HANDMADE.phone)
|
||||
await typeInto(p, modal(p).getByLabel('E-mail', { exact: true }), HANDMADE.email)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByLabel('E-mail', { exact: true }).locator('xpath=ancestor::div[.//label[normalize-space()="Phone"]][1]'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add opening hours' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Remove opening hours' })).toBeVisible()
|
||||
await modal(p).getByRole('button', { name: 'Remove opening hours' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Remove opening hours' }).locator('xpath=ancestor::div[.//input][1]'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, HANDMADE.name)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, HANDMADE.name),
|
||||
},
|
||||
'create-place': {
|
||||
guide: guide('create-place'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
// A running trip always has a day open, so the button reads New place.
|
||||
target: addButton,
|
||||
act: async p => {
|
||||
await addButton(p).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The results are pictured before the pick: once one is taken the
|
||||
// search box empties again, and a ring round an empty box shows the
|
||||
// reader nothing of what the step says.
|
||||
prepare: async p => {
|
||||
const box = modal(p).getByPlaceholder('Search places...')
|
||||
// Osaka Castle on purpose: its entry in the index carries a picture,
|
||||
// opening hours and a description, so Place details shows what the
|
||||
// text says it shows. Most results have none of that and the column
|
||||
// would be an empty state in the picture.
|
||||
await typeInto(p, box, SEARCHED.query)
|
||||
await expect(suggestion(p)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).locator('.shadow-dropdown').first(),
|
||||
hover: p => suggestion(p).hover(),
|
||||
act: async p => {
|
||||
await suggestion(p).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue(SEARCHED.match, { timeout: 20_000 })
|
||||
// The details column answers after the form; wait for it or the
|
||||
// picture catches the spinner.
|
||||
await expect(modal(p).getByText('Pick a picture', { exact: false }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The details column is an <aside> beside the form; both used to resolve to
|
||||
// the whole dialog, which gave two steps the same picture.
|
||||
only(p => modal(p).getByText('Place details', { exact: true }).locator('xpath=ancestor::aside[1]')),
|
||||
// The name and the category sit in the head band, above the form, so the
|
||||
// step about the fields shows the dialog rather than the form alone.
|
||||
only(p => dialog(p)),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(row(p, SEARCHED.query)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SEARCHED.query),
|
||||
},
|
||||
'place-to-open-day': {
|
||||
guide: guide('place-to-open-day'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await openTrip(p, { day: null })
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: /^1 .*Day 1 / }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: /^1 .*Day 1 / }).click()
|
||||
await expect(p.getByRole('button', { name: 'Add to the open day' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Add to the open day' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add to the open day' }).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places...')).toBeVisible()
|
||||
await beat(p, 400)
|
||||
await closeModal(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await row(p, SPARE.name).hover() },
|
||||
target: p => row(p, SPARE.name).getByRole('button', { name: '+ Day' }),
|
||||
act: async p => {
|
||||
await row(p, SPARE.name).getByRole('button', { name: '+ Day' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The other way round, and the one most readers try first: the row is
|
||||
// dragged onto a day card. The place is off the day again before the
|
||||
// drag so the picture shows it leaving the column rather than a stop
|
||||
// that is already there.
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(row(p, SPARE.name)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => row(p, SPARE.name),
|
||||
dropTo: p => p.getByRole('button', { name: /^2 .*Day 2 / }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await row(p, SPARE.name).dragTo(p.getByRole('button', { name: /^2 .*Day 2 / }))
|
||||
await expect(p.locator('.dp-row[role="button"]').filter({ hasText: SPARE.name }).first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'filter-places': {
|
||||
guide: guide('filter-places'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await openTrip(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await showTrigger(p).click()
|
||||
await expect(showList(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: showList,
|
||||
// The lists close on a second click of their trigger.
|
||||
act: async p => { await showTrigger(p).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
prepare: async p => { await typeInto(p, searchBox(p), 'temple'); await settle(p) },
|
||||
target: p => searchBox(p).locator('xpath=..'),
|
||||
act: async p => { await searchBox(p).fill(''); await settle(p) },
|
||||
},
|
||||
{
|
||||
// One category ticked, so the list shows the tick, the button its count
|
||||
// and the foot of the list its Clear filter.
|
||||
prepare: async p => {
|
||||
await categoryTrigger(p).click()
|
||||
await expect(categoryList(p)).toBeVisible()
|
||||
// No Category: the seed's places carry none, so it is the choice that
|
||||
// leaves rows in the list rather than an empty column.
|
||||
await categoryList(p).getByRole('button', { name: /No Category/ }).click()
|
||||
await expect(categoryList(p).getByRole('button', { name: 'Clear filter' })).toBeVisible()
|
||||
// The list scrolls inside itself; its foot is where the tick and Clear filter are.
|
||||
await categoryList(p).evaluate(el => { el.scrollTop = el.scrollHeight })
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: categoryList,
|
||||
act: async p => {
|
||||
await categoryList(p).getByRole('button', { name: 'Clear filter' }).click()
|
||||
await categoryTrigger(p).click()
|
||||
await expect(categoryList(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click(); await beat(p, 300) },
|
||||
target: p => p.getByRole('button', { name: 'Filter by rating' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Filter by rating' }).click() },
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'edit-place': {
|
||||
guide: guide('edit-place'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, 'Shibuya Crossing').click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Edit' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: menu,
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(modal(p).getByPlaceholder('e.g. Eiffel Tower')).toHaveValue('Shibuya Crossing')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(dialog),
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
||||
act: closeModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
'delete-place': {
|
||||
guide: guide('delete-place'),
|
||||
start: async p => {
|
||||
await createSpare(p)
|
||||
await openTrip(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await row(p, SPARE.name).click({ button: 'right' })
|
||||
await expect(menu(p).getByRole('button', { name: 'Delete' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => menu(p).getByRole('button', { name: 'Delete' }),
|
||||
act: async p => {
|
||||
await menu(p).getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(confirmDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: confirmDialog,
|
||||
act: async p => {
|
||||
await confirmDialog(p).getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
await expect(row(p, SPARE.name)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, SPARE.name),
|
||||
},
|
||||
'select-places': {
|
||||
guide: guide('select-places'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Select', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(p.getByRole('button', { name: 'Select all' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Two rows next to each other, so one picture shows both ticks.
|
||||
prepare: async p => {
|
||||
await row(p, 'Shibuya Crossing').click()
|
||||
await row(p, 'Meiji Jingu').click()
|
||||
await expect(p.getByRole('status', { name: '2 selected' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => row(p, 'Meiji Jingu'),
|
||||
},
|
||||
only(selectionBar),
|
||||
{
|
||||
target: p => selectionBar(p).getByRole('button', { name: 'Done' }),
|
||||
act: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Done' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Select all' })).toHaveCount(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'import-places-file': {
|
||||
guide: guide('import-places-file'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Import Places' }).click()
|
||||
await expect(importMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => importMenu(p).getByRole('button', { name: 'Import file' }),
|
||||
act: async p => {
|
||||
await importMenu(p).getByRole('button', { name: 'Import file' }).click()
|
||||
await expect(importFileDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await importFileDialog(p).locator('input[type="file"]').setInputFiles(gpxFixture('arashiyama-loop'))
|
||||
await expect(importFileDialog(p).getByText('Tracks (with path geometry)')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: importFileDialog,
|
||||
},
|
||||
only(dropBox),
|
||||
{
|
||||
target: p => importFileDialog(p).getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await importFileDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importFileDialog(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(row(p, 'Arashiyama loop')).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, 'Arashiyama loop', 'Togetsukyo Bridge', 'Okochi Sanso Villa'),
|
||||
},
|
||||
'import-places-list': {
|
||||
guide: guide('import-places-list'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.getByRole('button', { name: 'Import Places' }).click()
|
||||
await expect(importMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
// Google List while Google is the only list provider, List Import once Naver is on too.
|
||||
target: p => importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }),
|
||||
act: async p => {
|
||||
await importMenu(p).getByRole('button', { name: /^(Google List|List Import)$/ }).click()
|
||||
await expect(listImportDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, listImportDialog(p).getByPlaceholder(/goo\.gl|naver/), 'https://maps.app.goo.gl/kyoto-favourites')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: listImportDialog,
|
||||
},
|
||||
only(p => listImportDialog(p).getByRole('button', { name: 'Import', exact: true })),
|
||||
],
|
||||
// The dialog has no Escape route: Cancel is the way out.
|
||||
cleanup: async p => {
|
||||
if (await listImportDialog(p).isVisible().catch(() => false)) {
|
||||
await listImportDialog(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(listImportDialog(p)).toHaveCount(0)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureTrack(request, seededTrip().tripId, 'kyoto-walk')
|
||||
})
|
||||
|
||||
test('every registered places guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripPlacesContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-places', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripPlacesContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripPlacesContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,760 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, settle, PICTURE_DAY, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureRoadtripFixtures } from './fixtures'
|
||||
import { openTrip, modal, dialog } from './trip-shared'
|
||||
import { tripRoadtripContext, tripRoadtripGuides } from '../../src/help/contexts/tripRoadtrip'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for road trip mode, keyed by the ids in
|
||||
* `src/help/contexts/tripRoadtrip.ts`. They run on the seeded "Autumn in
|
||||
* Japan" with the Road trip addon switched on, one long drive out of Tokyo on
|
||||
* day 4 and one imported track on day 7 (`ensureRoadtripFixtures`). What a
|
||||
* guide writes it takes away again in `cleanup`, and `test.afterAll` switches
|
||||
* the addon off again: it is an instance-wide setting, and while it is on
|
||||
* every trip's left column grows a switch the other screens never show.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripRoadtripGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
// ── Where things are ──────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.roadtrip-rail-scroll')
|
||||
/** A day's card by the number in its heading: the rail leaves out days with nothing on them. */
|
||||
const dayCard = (page: Page, day: number) =>
|
||||
rail(page)
|
||||
.locator('> section')
|
||||
.filter({ has: page.getByRole('heading', { name: `Day ${day}`, exact: true }) })
|
||||
.first()
|
||||
/**
|
||||
* One stop's row and everything hanging off it. The kind disc sits inside the
|
||||
* row button while the stop is a destination and beside it once it is a stop on
|
||||
* the way, so the list item is the only handle that holds for both.
|
||||
*/
|
||||
const stopItem = (page: Page, name: string) => rail(page).getByRole('listitem').filter({ hasText: name }).first()
|
||||
const stopRow = (page: Page, name: string) => stopItem(page, name).getByRole('button').filter({ hasText: name }).first()
|
||||
const kindDialog = (page: Page) => page.getByRole('dialog', { name: 'Kind of stop' })
|
||||
/** The corridor panel: before a search its heading is the only stable thing in it. */
|
||||
const corridor = (page: Page) => page.getByRole('heading', { name: 'Along the route' }).locator('xpath=ancestor::div[2]')
|
||||
/**
|
||||
* The two dropdowns of the Looking for line. Both are listbox triggers, so they
|
||||
* are told apart by name: the kinds read "Looking for: Fuel", the day "Day 1".
|
||||
*/
|
||||
const kindPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Looking for/ })
|
||||
const dayPicker = (page: Page) => corridor(page).getByRole('button', { name: /^Day \d/ }).first()
|
||||
/** The list a dropdown opens; only one is open at a time. */
|
||||
const openList = (page: Page) => corridor(page).locator('div[role="listbox"]')
|
||||
const settingsCard = (page: Page) => page.getByRole('button', { name: /^Driving settings/ })
|
||||
/** The drive band of day 4's long leg, the one with more than one road to it. */
|
||||
const altBand = (page: Page) => dayCard(page, 4).getByRole('button', { name: /Other ways$/ }).first()
|
||||
/** The alternatives bar floats over the map, inside neither column. */
|
||||
const altBar = (page: Page) => page.getByText('Ways to drive this leg').locator('xpath=ancestor::div[2]')
|
||||
/** The refuel band belongs to no stop: it runs across the rail between two of them. */
|
||||
const dryBand = (page: Page) => page.getByText('Tank runs out here').first().locator('xpath=ancestor::div[3]')
|
||||
const addFuelStop = (page: Page) => dryBand(page).getByRole('button', { name: /as a fuel stop$/ }).first()
|
||||
/** A via handle is the one marker Leaflet drags itself; the corridor pins use HTML5 drag. */
|
||||
const viaHandle = (page: Page) => page.locator('.leaflet-marker-icon.leaflet-marker-draggable').first()
|
||||
|
||||
// ── Getting there ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function openRoadtrip(page: Page): Promise<void> {
|
||||
await openTrip(page, { day: null })
|
||||
await page.getByRole('tab', { name: 'Road trip' }).click()
|
||||
await waitForTheDrive(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every leg of every day is a router round trip, and the rail says nothing at
|
||||
* all until they land. The totals appear first and the partial line only while
|
||||
* something is outstanding, so neither of them on its own says the drive is
|
||||
* there; a drive band on a day that really has one does.
|
||||
*/
|
||||
async function waitForTheDrive(page: Page): Promise<void> {
|
||||
await expect(page.getByText('Driving time', { exact: true })).toBeVisible({ timeout: 120_000 })
|
||||
await expect(dayCard(page, 2).getByRole('button', { name: /Other ways$/ }).first()).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.getByText('Still working out the rest of the drive')).toHaveCount(0, { timeout: 120_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a leg where the pointer can reach it. The map opens fitted to the whole
|
||||
* trip, which leaves the drawn route under the two floating panels; clicking a
|
||||
* stop frames that stop instead, and the card it opens is closed again at once.
|
||||
*/
|
||||
async function frameOnStop(page: Page, name: string): Promise<void> {
|
||||
// On the name: the row's centre can land on its Stay badge, which opens its
|
||||
// own dialog instead of selecting the stop.
|
||||
await stopRow(page, name).click({ position: { x: 70, y: 12 } })
|
||||
// The place's card opens over the map; its Close sits in the card's head band.
|
||||
const head = placeCardHead(page, name)
|
||||
await expect(head).toBeVisible({ timeout: 15_000 })
|
||||
await finishMapPan(page)
|
||||
await head.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(head).toHaveCount(0)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the map's pan to a picked place land. Leaflet times its pan from
|
||||
* `+new Date()`, and the clock is pinned to the picture day (`PICTURE_DAY`), so
|
||||
* the pan starts and never gets anywhere: the map stays where it was. Moving the
|
||||
* pinned clock a few seconds on ends it on the next frame, as a second passing
|
||||
* would; nothing in these pictures reads the time to the second.
|
||||
*/
|
||||
async function finishMapPan(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(400)
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 5_000))
|
||||
await page.waitForTimeout(1200)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of the card a stop opens over the map (the place inspector):
|
||||
* the place's name as a heading, and Close beside it. The rail prints the same
|
||||
* name, but never as a heading.
|
||||
*/
|
||||
const placeCardHead = (page: Page, name: string) =>
|
||||
page.getByRole('heading', { name, exact: true, level: 2 }).locator('xpath=ancestor::header[1]')
|
||||
|
||||
type RoutePoint = { leg: number; x: number; y: number }
|
||||
|
||||
/**
|
||||
* Points that really lie on a drawn leg, with nothing over them. A bounding-box
|
||||
* centre is no use here: a route is a curve, its box is mostly empty space, and
|
||||
* what sits in the middle of it is usually somebody else's pin.
|
||||
*/
|
||||
async function routePoints(page: Page): Promise<RoutePoint[]> {
|
||||
return page.evaluate(() => {
|
||||
const paths = Array.from(
|
||||
document.querySelectorAll<SVGPathElement>('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]'),
|
||||
)
|
||||
const found: { leg: number; x: number; y: number }[] = []
|
||||
paths.forEach((path, leg) => {
|
||||
const length = path.getTotalLength()
|
||||
const ctm = path.getScreenCTM()
|
||||
if (!ctm || length < 24) return
|
||||
for (let f = 0.08; f <= 0.92; f += 0.02) {
|
||||
const at = path.getPointAtLength(length * f)
|
||||
const x = Math.round(ctm.a * at.x + ctm.c * at.y + ctm.e)
|
||||
const y = Math.round(ctm.b * at.x + ctm.d * at.y + ctm.f)
|
||||
// The two panels float over the map; a point under one of them is not clickable.
|
||||
if (x < 430 || x > 1550 || y < 220 || y > 950) continue
|
||||
if (document.elementFromPoint(x, y) === path) found.push({ leg, x, y })
|
||||
}
|
||||
})
|
||||
return found
|
||||
})
|
||||
}
|
||||
|
||||
/** Which leg the via guide aims at, and where; worked out once, used by three steps. */
|
||||
let aimedLeg = 0
|
||||
let aimedAt = { x: 0, y: 0 }
|
||||
/** The day the via landed on, read back between two steps of the same guide. */
|
||||
let viaDayId = 0
|
||||
/** Names of what a guide created, read back by its cleanup. */
|
||||
let addedStop = ''
|
||||
let refuelStop = ''
|
||||
|
||||
/** The leg with the most reachable points: the one worth clicking and worth a picture. */
|
||||
async function aimAtLeg(page: Page): Promise<void> {
|
||||
const points = await routePoints(page)
|
||||
if (points.length === 0) throw new Error('no drawn leg is reachable on the map')
|
||||
const counts = new Map<number, number>()
|
||||
for (const point of points) counts.set(point.leg, (counts.get(point.leg) ?? 0) + 1)
|
||||
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
aimedLeg = best ? best[0] : points[0].leg
|
||||
const own = points.filter(point => point.leg === aimedLeg)
|
||||
const middle = own[Math.floor(own.length / 2)] ?? points[0]
|
||||
aimedAt = { x: middle.x, y: middle.y }
|
||||
}
|
||||
|
||||
const aimedLine = (page: Page) => page.locator('.leaflet-overlay-pane svg path[stroke="#0a5cc2"]').nth(aimedLeg)
|
||||
/** The pointer goes on the line, not on the centre of the box around it. */
|
||||
const hoverAimedLine = async (page: Page): Promise<void> => {
|
||||
await page.mouse.move(aimedAt.x, aimedAt.y)
|
||||
}
|
||||
|
||||
// ── What the guides write, and how it is taken back ───────────────────────────
|
||||
|
||||
type ViaRow = { id: number; day_id: number; lat: number; lng: number }
|
||||
|
||||
async function listVias(page: Page): Promise<ViaRow[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
|
||||
const body = (await res.json()) as { vias?: ViaRow[] }
|
||||
return body.vias ?? []
|
||||
}
|
||||
|
||||
async function clearVias(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
for (const via of await listVias(page)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/roadtrip/days/${via.day_id}/vias/${via.id}`)
|
||||
}
|
||||
// A followed track is recorded per day even when the drive needed no via to
|
||||
// follow it, and deleting vias leaves that record standing: clear it too.
|
||||
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/vias`)
|
||||
const { tracks = [] } = (await res.json()) as { tracks?: { day_id: number }[] }
|
||||
for (const track of tracks) {
|
||||
await page.request.post(`/api/trips/${tripId}/roadtrip/days/${track.day_id}/vias/batch`, {
|
||||
data: { vias: [], track: null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDayBoundaries(page: Page): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/roadtrip/day-boundaries`)
|
||||
const body = (await res.json()) as { boundaries?: { day_number: number }[] }
|
||||
for (const boundary of body.boundaries ?? []) {
|
||||
await page.request.delete(`/api/trips/${tripId}/roadtrip/day-boundaries/${boundary.day_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function listPlaces(page: Page): Promise<{ id: number; name: string }[]> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
return Array.isArray(body) ? body : (body.places ?? [])
|
||||
}
|
||||
|
||||
async function deleteByName(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const wanted = names.filter(name => name.length > 0)
|
||||
if (wanted.length === 0) return
|
||||
for (const place of (await listPlaces(page)).filter(x => wanted.includes(x.name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put one place back the way the seed had it. */
|
||||
async function restorePlace(page: Page, name: string, data: Record<string, unknown>): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const place = (await listPlaces(page)).find(x => x.name === name)
|
||||
if (place) await page.request.put(`/api/trips/${tripId}/places/${place.id}`, { data })
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'roadtrip-mode': {
|
||||
guide: guide('roadtrip-mode'),
|
||||
start: p => openTrip(p, { day: null }),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('tablist', { name: 'Plan view' }),
|
||||
act: async p => {
|
||||
await p.getByRole('tab', { name: 'Road trip' }).click()
|
||||
await waitForTheDrive(p)
|
||||
},
|
||||
},
|
||||
only(p => p.getByText('Driving time', { exact: true }).locator('xpath=ancestor::header[1]')),
|
||||
only(p => dayCard(p, 2).locator('header')),
|
||||
only(p => dayCard(p, 2).getByRole('list').first()),
|
||||
{
|
||||
// Day 2, like the two steps above: the rail leaves out a day with
|
||||
// nothing to drive, and day 1 is one of them.
|
||||
target: p => dayCard(p, 2).locator('header'),
|
||||
act: async p => {
|
||||
await dayCard(p, 2).locator('header').click()
|
||||
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'false')
|
||||
await settle(p)
|
||||
// Folded is not the state to leave the trip in, and the result picture
|
||||
// is of the whole drive rather than of one card shut.
|
||||
await dayCard(p, 2).locator('header').click()
|
||||
await expect(dayCard(p, 2).locator('header')).toHaveAttribute('aria-expanded', 'true')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'roadtrip-stops': {
|
||||
guide: guide('roadtrip-stops'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
// The disc itself, by its own label: the row button around it takes the
|
||||
// disc's label into its accessible name, so the role query matches both.
|
||||
target: p => stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]'),
|
||||
act: async p => {
|
||||
await stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]').click()
|
||||
await expect(kindDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: kindDialog,
|
||||
act: async p => {
|
||||
await kindDialog(p).getByRole('button', { name: 'Rest area' }).click()
|
||||
await expect(kindDialog(p)).toHaveCount(0)
|
||||
await expect(
|
||||
stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }),
|
||||
).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => dayCard(p, 2).locator('header')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await stopItem(p, 'Meiji Jingu').getByRole('button', { name: 'Change what kind of stop this is' }).click()
|
||||
await expect(kindDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => kindDialog(p).getByRole('button', { name: 'Back to a destination' }),
|
||||
act: async p => {
|
||||
await kindDialog(p).getByRole('button', { name: 'Back to a destination' }).click()
|
||||
await expect(kindDialog(p)).toHaveCount(0)
|
||||
await expect(stopItem(p, 'Meiji Jingu').locator('[aria-label="Make it a stop on the way"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }),
|
||||
act: async p => {
|
||||
await stopItem(p, 'Shibuya Crossing').getByRole('button', { name: /^Time at this stop/ }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Time at this stop' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dialog,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: '1 h', exact: true }).click()
|
||||
await expect(modal(p).getByRole('slider', { name: 'Minutes' })).toHaveValue('60')
|
||||
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(stopItem(p, 'Shibuya Crossing')).toContainText('1 h', { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await restorePlace(p, 'Shibuya Crossing', { duration_minutes: 45 })
|
||||
await restorePlace(p, 'Meiji Jingu', { stop_type: null })
|
||||
},
|
||||
},
|
||||
'roadtrip-corridor': {
|
||||
guide: guide('roadtrip-corridor'),
|
||||
start: async p => {
|
||||
addedStop = ''
|
||||
await openRoadtrip(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
// The line reads "these kinds, along this day", so the kinds come first.
|
||||
prepare: async p => {
|
||||
await kindPicker(p).click()
|
||||
await expect(openList(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: openList,
|
||||
act: async p => {
|
||||
// Fuel is what the panel opens on for a petrol car; Food is ticked beside it.
|
||||
await openList(p).getByRole('option', { name: 'Food' }).click()
|
||||
await expect(openList(p).getByRole('option', { name: 'Food' })).toHaveAttribute('aria-selected', 'true')
|
||||
// The list stays open for a second kind; a second click on its trigger shuts it.
|
||||
await kindPicker(p).click()
|
||||
await expect(openList(p)).toHaveCount(0)
|
||||
await expect(kindPicker(p)).toHaveAccessibleName(/Food/)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dayPicker,
|
||||
act: async p => {
|
||||
await dayPicker(p).click()
|
||||
// The rows are options named by the day's number and its label, "2 Day 2".
|
||||
await openList(p).getByRole('option', { name: /\bDay 2$/ }).click()
|
||||
await expect(dayPicker(p)).toContainText('Day 2')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => corridor(p).getByRole('button', { name: '5 km', exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
// The step's sentence tells the reader to choose a width and then search,
|
||||
// so the width is chosen here even though 5 km is what the panel opens on.
|
||||
await corridor(p).getByRole('button', { name: '5 km', exact: true }).click()
|
||||
await expect(corridor(p).getByRole('button', { name: '5 km', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
await corridor(p).getByRole('button', { name: 'Search', exact: true }).click()
|
||||
await expect(corridor(p).getByText(/on the way$/).first()).toBeVisible({ timeout: 180_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => corridor(p).getByRole('listitem').first()),
|
||||
{
|
||||
prepare: async p => {
|
||||
const row = corridor(p).getByRole('listitem').first()
|
||||
addedStop = (await row.innerText()).split('\n')[0] ?? ''
|
||||
await row.hover()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await corridor(p).getByRole('listitem').first().getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible()
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect
|
||||
.poll(async () => (await listPlaces(p)).some(x => x.name === addedStop), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, addedStop),
|
||||
},
|
||||
'roadtrip-via': {
|
||||
guide: guide('roadtrip-via'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
// A stop of day 4, the drive out of Tokyo: the one leg long enough to be
|
||||
// worth bending, and once Hakone is in the middle of the map it runs
|
||||
// east across the open map rather than under the right column.
|
||||
target: p => stopItem(p, 'Hakone Shrine'),
|
||||
act: async p => {
|
||||
await frameOnStop(p, 'Hakone Shrine')
|
||||
await aimAtLeg(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: aimedLine,
|
||||
hover: hoverAimedLine,
|
||||
act: async p => {
|
||||
await p.mouse.click(aimedAt.x, aimedAt.y)
|
||||
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(1)
|
||||
await expect(viaHandle(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
viaDayId = (await listVias(p))[0]?.day_id ?? 0
|
||||
await settle(p)
|
||||
},
|
||||
// Which day the click landed on is the router's answer, not ours.
|
||||
target: p => {
|
||||
const day = seededTrip().dayIds.indexOf(viaDayId) + 1
|
||||
// Fall back to day 2, not day 1: the rail has no card for a day
|
||||
// with nothing to drive.
|
||||
return dayCard(p, day > 0 ? day : 2).locator('header')
|
||||
},
|
||||
},
|
||||
{
|
||||
target: viaHandle,
|
||||
act: async p => {
|
||||
const box = await viaHandle(p).boundingBox()
|
||||
if (!box) throw new Error('the via handle has no box')
|
||||
const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
const before = JSON.stringify((await listVias(p))[0] ?? {})
|
||||
// A Leaflet drag, not an HTML5 one: the handle listens for the raw
|
||||
// pointer, so `dragTo` would move nothing.
|
||||
await p.mouse.move(from.x, from.y)
|
||||
await p.mouse.down()
|
||||
await p.mouse.move(from.x + 45, from.y + 40, { steps: 10 })
|
||||
await p.mouse.move(from.x + 90, from.y + 80, { steps: 10 })
|
||||
await p.mouse.up()
|
||||
await expect
|
||||
.poll(async () => JSON.stringify((await listVias(p))[0] ?? {}), { timeout: 60_000 })
|
||||
.not.toBe(before)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: viaHandle,
|
||||
act: async p => {
|
||||
const box = await viaHandle(p).boundingBox()
|
||||
if (!box) throw new Error('the via handle has no box')
|
||||
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' })
|
||||
await expect.poll(async () => (await listVias(p)).length, { timeout: 60_000 }).toBe(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: clearVias,
|
||||
},
|
||||
'roadtrip-alternatives': {
|
||||
guide: guide('roadtrip-alternatives'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
// Day 4's drive out of Tokyo, not a city leg: for three kilometres across
|
||||
// Shibuya the router knows one sensible way, and the bar says only that.
|
||||
target: altBand,
|
||||
act: async p => {
|
||||
await altBand(p).click()
|
||||
await expect(p.getByText('Ways to drive this leg')).toBeVisible({ timeout: 60_000 })
|
||||
await expect(p.getByText('Asking the router…')).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(altBar(p).getByRole('button', { name: /km/ }).nth(1)).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(altBar),
|
||||
{
|
||||
prepare: async p => {
|
||||
await altBar(p).getByRole('button', { name: /km/ }).last().hover()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => altBar(p).getByRole('button', { name: /km/ }).last(),
|
||||
},
|
||||
{
|
||||
target: p => altBar(p).getByRole('button', { name: 'Close' }),
|
||||
act: async p => {
|
||||
await altBar(p).getByRole('button', { name: 'Close' }).click()
|
||||
await expect(p.getByText('Ways to drive this leg')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// Nothing is chosen here, but choosing an entry writes a via, so a misclick
|
||||
// must not follow the run into the next guide.
|
||||
cleanup: clearVias,
|
||||
},
|
||||
'roadtrip-limits': {
|
||||
guide: guide('roadtrip-limits'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
target: settingsCard,
|
||||
act: async p => {
|
||||
await settingsCard(p).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByTestId('limit-legMinutes').locator('xpath=ancestor::section[1]'),
|
||||
act: async p => {
|
||||
await modal(p).getByTestId('limit-legMinutes').fill('60')
|
||||
await p.keyboard.press('Tab')
|
||||
await expect(modal(p).getByTestId('limit-legMinutes')).toHaveValue('60')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('group', { name: 'What you drive' }),
|
||||
act: async p => {
|
||||
await modal(p).getByText('Electric', { exact: true }).click()
|
||||
await expect(modal(p).getByRole('radio', { name: 'Electric' })).toBeChecked()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByTestId('limit-range').locator('xpath=ancestor::label[1]'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Work it out from the car' }).click()
|
||||
await expect(modal(p).getByRole('spinbutton', { name: /^Battery, / })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// Not toggled on purpose: each of these switches sends every leg of the
|
||||
// trip back through the avoidance router, which is minutes of waiting for
|
||||
// one picture of a row of switches.
|
||||
// By its title, not by a switch: without an avoidance router the switches
|
||||
// are replaced by a dash and the section is all that is left of it.
|
||||
only(p => modal(p).getByText('Avoid where possible', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
{
|
||||
prepare: async p => {
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
target: settingsCard,
|
||||
act: async p => {
|
||||
// Both halves: the badge the fixture leaves reads "1 h 30 min", which
|
||||
// contains "1 h", so the shortened limit only shows in what is NOT there.
|
||||
await expect(settingsCard(p)).toContainText('1 h', { timeout: 30_000 })
|
||||
await expect(settingsCard(p)).not.toContainText('1 h 30 min')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The fixture writes the whole preference set, so re-running it is the undo.
|
||||
cleanup: p => ensureRoadtripFixtures(p.request),
|
||||
},
|
||||
'roadtrip-day-window': {
|
||||
guide: guide('roadtrip-day-window'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await settingsCard(p).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Driving settings' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByRole('group', { name: 'Daily travel times' }),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('textbox', { name: 'Day start time' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('textbox', { name: 'Day start time' }).fill('09:00')
|
||||
await expect(modal(p).getByRole('textbox', { name: 'Day start time' })).toHaveValue('09:00')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('textbox', { name: 'Day end time' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('textbox', { name: 'Day end time' }).fill('17:30')
|
||||
await p.keyboard.press('Tab')
|
||||
await expect(modal(p).getByRole('textbox', { name: 'Day end time' })).toHaveValue('17:30')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('group', { name: 'End the day' }),
|
||||
act: async p => {
|
||||
await modal(p).getByText('At the last place', { exact: true }).click()
|
||||
await expect(modal(p).getByRole('radio', { name: 'At the last place' })).toBeChecked()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
target: settingsCard,
|
||||
act: async p => {
|
||||
await expect(settingsCard(p)).toContainText('09:00', { timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await clearDayBoundaries(p)
|
||||
await ensureRoadtripFixtures(p.request)
|
||||
},
|
||||
},
|
||||
'roadtrip-refuel': {
|
||||
guide: guide('roadtrip-refuel'),
|
||||
start: async p => {
|
||||
refuelStop = ''
|
||||
await openRoadtrip(p)
|
||||
},
|
||||
steps: [
|
||||
only(dryBand),
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Find fuel' }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Find fuel' }).first().click()
|
||||
await expect(p.getByText('Looking along the route…')).toHaveCount(0, { timeout: 180_000 })
|
||||
await expect(addFuelStop(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => dryBand(p).getByRole('list').first()),
|
||||
{
|
||||
prepare: async p => {
|
||||
const label = (await addFuelStop(p).getAttribute('aria-label')) ?? ''
|
||||
refuelStop = label.replace(/^Add /, '').replace(/ as a fuel stop$/, '')
|
||||
await addFuelStop(p).hover()
|
||||
await settle(p)
|
||||
},
|
||||
target: addFuelStop,
|
||||
act: async p => {
|
||||
await addFuelStop(p).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Add as a stop' })).toBeVisible({ timeout: 30_000 })
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect
|
||||
.poll(async () => (await listPlaces(p)).some(x => x.name === refuelStop), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByName(p, refuelStop),
|
||||
},
|
||||
'roadtrip-track': {
|
||||
guide: guide('roadtrip-track'),
|
||||
start: openRoadtrip,
|
||||
steps: [
|
||||
{
|
||||
target: p => dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }),
|
||||
act: async p => {
|
||||
await dayCard(p, 7).getByRole('button', { name: 'Track', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Day 7 follows a track' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('list').first(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Arashiyama loop/ }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Follow this track' })).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Follow this track' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Follow this track' }).click()
|
||||
// One router round trip per refinement round, on a route that is
|
||||
// being pulled onto a track metre by metre.
|
||||
await expect(modal(p).getByText(/via points placed|already followed this track/)).toBeVisible({
|
||||
timeout: 240_000,
|
||||
})
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dialog,
|
||||
act: async p => {
|
||||
await closeModal(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await clearVias(p)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureRoadtripFixtures(request)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Instance-wide, not trip-scoped: left on, every trip's left column grows the
|
||||
// Days / Road trip switch and every other screen's pictures change. Here
|
||||
// rather than in the last guide's cleanup, so a failed run puts it back too.
|
||||
await request.put('/api/admin/addons/roadtrip', { data: { enabled: false } })
|
||||
})
|
||||
|
||||
test('every registered road trip guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripRoadtripContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-roadtrip', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripRoadtripContext.id, openRoadtrip)
|
||||
})
|
||||
|
||||
for (const id of tripRoadtripContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { dismissReleaseNotice, settle, type StepAction } from './guide'
|
||||
import { seededTrip } from './fixtures'
|
||||
|
||||
/**
|
||||
* What every trip screen's script needs: the way into the seeded trip, the
|
||||
* day selection and the dialogs. Not a `.guide.ts`, so importing it registers
|
||||
* no tests twice.
|
||||
*/
|
||||
|
||||
export const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
export const dialog = (page: Page) => modal(page).locator('> [role="presentation"], > div').first()
|
||||
/**
|
||||
* ConfirmDialog is its own portal and carries `trek-backdrop-enter` without the
|
||||
* `trek-modal-backdrop` of `Modal`, so the yes/no questions need their own
|
||||
* locator: `modal` would never see them.
|
||||
*/
|
||||
export const confirmBackdrop = (page: Page) => page.locator('.trek-backdrop-enter:not(.trek-modal-backdrop)').last()
|
||||
export const confirmDialog = (page: Page) => confirmBackdrop(page).locator('.trek-modal-enter').last()
|
||||
/**
|
||||
* The import dialogs of the places column are portals of their own with no
|
||||
* shared backdrop class at all, so they are found by what stands inside them.
|
||||
* Both the overlay and the card carry `role="presentation"`, and the card comes
|
||||
* second in document order, which is the one worth a picture.
|
||||
*/
|
||||
export const portalDialog = (page: Page, contains: Locator): Locator =>
|
||||
page.locator('div[role="presentation"]').filter({ has: contains }).last()
|
||||
|
||||
export async function openTrip(page: Page, opts: { tab?: string; day?: number | null } = {}): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const { tab, day = 1 } = opts
|
||||
await page.goto(`/trips/${tripId}${tab ? `?tab=${tab}` : ''}`)
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
// Exact: the Lists tab carries a Shared button, which the loose name also matches.
|
||||
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
await untilTranslated(page)
|
||||
if (!tab && day) {
|
||||
// The trip is running, so the plan opens on today, an empty day at the
|
||||
// bottom. Day 1 has the places; its details panel would cover the map.
|
||||
await selectDay(page, day)
|
||||
await closeDayDetails(page)
|
||||
}
|
||||
// The map tiles land after the page says it is idle.
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
/**
|
||||
* A text that is still its key: `dayplan.dayN` where "Day 1" belongs. The
|
||||
* locale bundles come from `shared/dist`, and while a watcher rebuilds it a
|
||||
* page can load with a part of them missing. Reloading after the rebuild has
|
||||
* landed brings the strings back; a page that stays untranslated fails here
|
||||
* rather than in a picture.
|
||||
*/
|
||||
const rawKey = (page: Page) => page.getByText(/^(common|dayplan|planner|places|reservations|trip|day|budget|files|collab|packing|todo)\.[A-Za-z0-9.]+$/)
|
||||
|
||||
async function untilTranslated(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 4 && (await rawKey(page).count()) > 0; attempt++) {
|
||||
await page.waitForTimeout(4000)
|
||||
await page.reload()
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(page.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await settle(page)
|
||||
}
|
||||
await expect(rawKey(page), 'the page shows i18n keys instead of text').toHaveCount(0)
|
||||
}
|
||||
|
||||
/** Click the day's header: selects it and opens its details panel. */
|
||||
export async function selectDay(page: Page, n: number): Promise<void> {
|
||||
const header = page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
// The header carries buttons of its own: the booked night in the middle, the
|
||||
// transit, note and fold actions on the right. A click on the centre lands on
|
||||
// the booked night as soon as a day has one, which selects nothing, so the
|
||||
// click goes to the day's number at the left edge.
|
||||
await header.click({ position: { x: 22, y: 20 } })
|
||||
// The details panel is the selection: without it the day's route tools, the
|
||||
// + on a place row and To day are all absent, and a guide fails far from here.
|
||||
await expect(dayDetails(page)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** The day details panel: the one fixed, floating card over the map. */
|
||||
export const dayDetails = (page: Page) => page.locator('div.fixed.z-50').first()
|
||||
|
||||
/**
|
||||
* The details panel's own Close, in its head band. Found inside the panel: the
|
||||
* last X of the page is not always this one once the day carries bookings.
|
||||
*/
|
||||
export async function closeDayDetails(page: Page): Promise<void> {
|
||||
const close = dayDetails(page).getByRole('button', { name: 'Close', exact: true })
|
||||
if (await close.isVisible().catch(() => false)) {
|
||||
await close.click()
|
||||
await settle(page)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the trip with day `n` selected and its details panel standing. The panel
|
||||
* IS the selection — closing it deselects the day — and the day's route tools
|
||||
* and the "+" at the end of a place row render for a selected day only, so a
|
||||
* guide about either of them starts here rather than at `openTrip`.
|
||||
*/
|
||||
export async function openTripOnDay(page: Page, n = 1): Promise<void> {
|
||||
await openTrip(page, { day: null })
|
||||
await selectDay(page, n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a `ContextMenu`. It closes on a document click and ignores Escape,
|
||||
* and a real click would land on the map or on the row underneath.
|
||||
*/
|
||||
export async function closeMenu(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.body.click())
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
// ── Import from file ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import from file, on the Bookings and the Transports tab alike. The dialog
|
||||
* is another bare portal, told apart by its heading; the parse it starts is
|
||||
* reported by the background widget, a fixed portal in the bottom-right
|
||||
* corner (the one at z-index 50000) with a card per run.
|
||||
*/
|
||||
export const importDialog = (page: Page) =>
|
||||
portalDialog(page, page.getByText('Import booking confirmations', { exact: true }))
|
||||
export const importWidget = (page: Page) => page.locator('body > div[style*="z-index: 50000"]')
|
||||
/** The widget's card for the run that parses `file`; the card is labelled with the file names. */
|
||||
export const importTask = (page: Page, file: string) =>
|
||||
importWidget(page).locator('.bg-surface-card').filter({ hasText: file }).last()
|
||||
|
||||
/**
|
||||
* The first three steps of an import guide, the same on either tab: the
|
||||
* toolbar button, the drop box with the file named on it, and Import.
|
||||
*/
|
||||
export function importSteps(fixture: () => string, file: string): StepAction[] {
|
||||
return [
|
||||
{
|
||||
// An icon in the toolbar, named like the dialog it opens.
|
||||
target: p => p.getByRole('button', { name: 'Import booking confirmations', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Import booking confirmations', exact: true }).click()
|
||||
await expect(importDialog(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Set the hidden input directly: the box opens the file picker of the
|
||||
// operating system, which Playwright cannot photograph.
|
||||
await importDialog(p).locator('input[type="file"]').setInputFiles(fixture())
|
||||
await expect(importDialog(p).getByText(file)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => importDialog(p).getByText(file).locator('xpath=ancestor::button[1]'),
|
||||
act: settle,
|
||||
},
|
||||
{
|
||||
target: p => importDialog(p).getByRole('button', { name: 'Import', exact: true }),
|
||||
act: async p => {
|
||||
await importDialog(p).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(importDialog(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the widget away after an import guide. The review takes the card with
|
||||
* it when it starts, so one still standing means the run stopped before
|
||||
* that, and it would sit in the corner of every later guide's pictures. Close
|
||||
* is only offered once the parse has stopped, and the wait is bounded so a
|
||||
* card nobody can close does not fail the guide from its cleanup.
|
||||
*/
|
||||
export async function dismissImportTask(page: Page, file: string): Promise<void> {
|
||||
const task = importTask(page, file)
|
||||
if (!(await task.isVisible().catch(() => false))) return
|
||||
const close = task.getByRole('button', { name: 'Close' })
|
||||
await close.waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {})
|
||||
if (await close.isVisible().catch(() => false)) await close.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a document a guide uploaded out of the trip for good. DELETE alone is
|
||||
* the trash, and the Files guides photograph the trash, so the permanent
|
||||
* delete follows.
|
||||
*/
|
||||
export async function deleteTripFiles(page: Page, ...names: string[]): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/files`)
|
||||
const { files = [] } = (await res.json()) as { files?: { id: number; original_name: string }[] }
|
||||
for (const file of files.filter(f => names.includes(f.original_name))) {
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||||
}
|
||||
}
|
||||
@@ -1,701 +0,0 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { seededTrip, ensureTransportFixtures, TRANSIT_JOURNEY, flightEmlFixture, FLIGHT_EML, FLIGHT_TO_IMPORT } from './fixtures'
|
||||
import { openTrip, openTripOnDay, selectDay, modal, dialog, portalDialog, importSteps, importTask, dismissImportTask, deleteTripFiles } from './trip-shared'
|
||||
import { ensureAirtrailConnection, disconnectAirtrail, AIRTRAIL_FLIGHTS, requireExtractor, allowEmlUploads } from './external'
|
||||
import { tripTransportsContext, tripTransportsGuides } from '../../src/help/contexts/tripTransports'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Transports tab of a trip, keyed by the ids in
|
||||
* `src/help/contexts/tripTransports.ts`. They run on the seeded "Autumn in
|
||||
* Japan" with the transports `ensureTransportFixtures` adds up front, so the
|
||||
* tab has a type chip per group and a planned connection to open; what a guide
|
||||
* creates it takes away again in `cleanup`, so the next one starts the same.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripTransportsGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** The flight the seed creates. Its row on day 1 is its title, then the Departure span label. */
|
||||
const FLIGHT = { title: 'LH716 FRA → HND', row: /^LH716 FRA → HND Departure/ }
|
||||
/**
|
||||
* The connection the fixtures store on day 6, so no guide has to search for one
|
||||
* first. Its card is named by its title, the two ends with an arrow between them.
|
||||
*/
|
||||
const JOURNEY = { title: TRANSIT_JOURNEY }
|
||||
/** What plan-transit plans on day 1; the title the panel builds from the two ends. */
|
||||
const PLANNED = 'Senso-ji Temple → teamLab Planets'
|
||||
/** What add-transport creates. Both ends are searched, so the map has something to draw. */
|
||||
const TAXI = {
|
||||
title: 'Taxi to teamLab Planets',
|
||||
from: { query: 'Senso-ji Temple Tokyo', match: /^Sens/ },
|
||||
to: { query: 'teamLab Planets', match: /^teamLab/ },
|
||||
}
|
||||
|
||||
/** A day card's head: number, weather, "Day n", date, its pills and the "+" with the chevron. */
|
||||
const dayHeader = (page: Page, n: number) => page.getByRole('button', { name: new RegExp(`^${n} .*Day ${n} `) })
|
||||
/** The "+" in a day's head; Add transport and Public transit are in the menu it opens. */
|
||||
const addToDay = (page: Page, n: number) => dayHeader(page, n).getByRole('button', { name: 'Add to day' })
|
||||
/** That menu: a popover of plain buttons, told apart from a tooltip by its role. */
|
||||
const dayMenu = (page: Page) => page.locator('.trek-popover-enter:not([role="tooltip"])').last()
|
||||
async function openDayMenu(page: Page, n: number): Promise<void> {
|
||||
await addToDay(page, n).click()
|
||||
await expect(dayMenu(page).getByRole('button', { name: 'Add transport' })).toBeVisible()
|
||||
await beat(page, 300)
|
||||
}
|
||||
/** A booking card in the tab: an <article> named by the booking's title. */
|
||||
const card = (page: Page, title: string) => page.getByRole('article', { name: title }).first()
|
||||
/** A card found by something it shows rather than its title, such as a booking code. */
|
||||
const cardShowing = (page: Page, text: string) => page.getByRole('article').filter({ hasText: text }).first()
|
||||
/** The two popovers of the tab's toolbar, each opened by its own button. */
|
||||
const toolbarButton = (page: Page, name: 'Filter' | 'View options') => page.getByRole('button', { name, exact: true })
|
||||
const toolbarMenu = (page: Page, name: 'Filter' | 'View options') => toolbarButton(page, name).locator('xpath=../..').getByRole('menu')
|
||||
/** A transport's row in the day plan; the whole row is the button that opens it. */
|
||||
const transportRow = (page: Page, name: RegExp) => page.getByRole('button', { name }).first()
|
||||
/** The booking a click on a row or a card opens, named by the booking's title. */
|
||||
const bookingDetail = (page: Page, title: string) => page.getByRole('dialog', { name: title })
|
||||
/** The Edit at the foot of that booking, which leads on to its form. */
|
||||
const detailEdit = (page: Page, title: string) => bookingDetail(page, title).getByRole('button', { name: 'Edit', exact: true })
|
||||
/** A block of the open dialog by its heading: every block is a <section> that opens with it. */
|
||||
const block = (page: Page, label: string) => modal(page).getByText(label, { exact: true }).first().locator('xpath=ancestor::section[1]')
|
||||
/** The head band of the open dialog, where a booking's title is typed and its pills sit. */
|
||||
const headBand = (page: Page) => modal(page).locator('header').first()
|
||||
/** The Booking Type pill in that band, and the list it opens (a portal named after it). */
|
||||
const typePill = (page: Page) => modal(page).getByRole('button', { name: /^Booking Type:/ })
|
||||
const typeMenu = (page: Page) => page.getByRole('group', { name: 'Booking Type' })
|
||||
/** The leg-mode popover, told apart from the connector's tooltip by what stands in it. */
|
||||
const legMenu = (page: Page) =>
|
||||
page.locator('.trek-popover-enter').filter({ has: page.getByRole('button', { name: 'Use day default' }) }).last()
|
||||
/** The travel-time connector between two stops of the open day. */
|
||||
const connector = (page: Page) => page.getByRole('button', { name: 'Change travel mode' }).first()
|
||||
/** The transit search's stop boxes, and the transport form's location boxes. */
|
||||
const stopBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search stop or station…').nth(n)
|
||||
const locationBox = (page: Page, n: number) => modal(page).getByPlaceholder('Search station, port, address…').nth(n)
|
||||
/**
|
||||
* Both pickers are an input in its own relative wrapper with the result list as
|
||||
* the wrapper's second child, so one expression finds either one's suggestions.
|
||||
*/
|
||||
const suggestions = (box: Locator) => box.locator('xpath=../..').locator('> div').nth(1)
|
||||
/** The transit stop picker's list is portaled onto the page, fixed under its input. */
|
||||
const stopSuggestions = (page: Page) => page.locator('xpath=/html/body/div[contains(@class,"--z-toast")]').last()
|
||||
/** A result of the transit search: the card's own toggle carries the times as its name. */
|
||||
const itinerary = (page: Page, n: number) =>
|
||||
modal(page).getByRole('button', { name: /^\d{1,2}:\d{2} – \d{1,2}:\d{2} / }).nth(n)
|
||||
|
||||
async function searchLocation(page: Page, box: Locator, query: string, match: RegExp): Promise<void> {
|
||||
await box.click()
|
||||
await typeInto(page, box, query)
|
||||
const options = suggestions(box).locator('button')
|
||||
await expect(options.first()).toBeVisible({ timeout: 25_000 })
|
||||
// The suggestion that answers the query, not whatever came back first: this
|
||||
// list is a live geocoder and its top hit for a landmark is as often a shop
|
||||
// down the road, which then travels into the picture as the wrong address.
|
||||
const wanted = options.filter({ hasText: match }).first()
|
||||
await expect(wanted, `no suggestion for "${query}" matching ${match}`).toBeVisible({ timeout: 25_000 })
|
||||
await wanted.click()
|
||||
await expect(box).toHaveValue(match, { timeout: 15_000 })
|
||||
}
|
||||
|
||||
/** The transit search offers the day's own stops as soon as the box has focus. */
|
||||
async function pickStop(page: Page, box: Locator, name: string): Promise<void> {
|
||||
await box.click()
|
||||
const option = stopSuggestions(page).getByRole('button', { name })
|
||||
await expect(option).toBeVisible({ timeout: 20_000 })
|
||||
await option.click()
|
||||
await expect(box).toHaveValue(name, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
type ReservationRow = { id: number; title: string; confirmation_number: string | null }
|
||||
|
||||
async function deleteWhere(page: Page, matches: (r: ReservationRow) => boolean): Promise<void> {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/reservations`)
|
||||
const body = (await res.json()) as { reservations?: ReservationRow[] } | ReservationRow[]
|
||||
const list = Array.isArray(body) ? body : (body.reservations ?? [])
|
||||
for (const r of list.filter(matches)) {
|
||||
await page.request.delete(`/api/trips/${tripId}/reservations/${r.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteByTitle = (page: Page, ...titles: string[]) => deleteWhere(page, r => titles.includes(r.title))
|
||||
/** The imported flight's title is the extractor's; its booking code is the fixture's. */
|
||||
const deleteByCode = (page: Page, code: string) => deleteWhere(page, r => r.confirmation_number === code)
|
||||
|
||||
/**
|
||||
* What import-transport-file reads out of its e-ticket. The title is the
|
||||
* mapper's (airline and number joined), so the form and the card are checked
|
||||
* by what the fixture owns: the code, and the two airports the built-in table
|
||||
* resolves from their IATA codes.
|
||||
*/
|
||||
const IMPORTED = { code: FLIGHT_TO_IMPORT.code, from: /\(HND\)$/, to: /\(FRA\)$/ }
|
||||
/** The two AirportSelect boxes of a flight's form, departure first. */
|
||||
const airportBoxes = (page: Page) => modal(page).getByPlaceholder('Airport code or city (e.g. FRA)')
|
||||
|
||||
/**
|
||||
* What the AirTrail account holds (external.ts): two flights home on the last
|
||||
* day that connect in Tokyo, which the picker offers to join, and the Lisbon
|
||||
* weekend in spring for its Other flights. The joined booking's title is the
|
||||
* mapper's route, the airport codes joined by arrows (airtrail.mapper.ts).
|
||||
*/
|
||||
const [OUT_LEG, HOME_LEG, , OTHER] = AIRTRAIL_FLIGHTS
|
||||
const JOINED = { title: `${OUT_LEG.from} → ${OUT_LEG.to} → ${HOME_LEG.to}`, layover: OUT_LEG.to }
|
||||
|
||||
/** The toolbar's AirTrail button: an icon named like the picker it opens. */
|
||||
const airtrailButton = (page: Page) => page.getByRole('button', { name: 'Import from AirTrail', exact: true })
|
||||
/** The flight picker is a bare portal like the booking import; its title tells it apart. */
|
||||
const airtrailPicker = (page: Page) => portalDialog(page, page.getByText('Import from AirTrail', { exact: true }))
|
||||
/** A flight's row in the picker: a button named by the airline and the flight number. */
|
||||
const flightRow = (page: Page, flightNumber: string) =>
|
||||
airtrailPicker(page).getByRole('button', { name: new RegExp(`\\b${flightNumber}\\b`) })
|
||||
/** The join tick under a connection's legs, and the framed group it belongs to. */
|
||||
const joinToggle = (page: Page) =>
|
||||
airtrailPicker(page).getByRole('button', { name: /^Import as one flight with a layover in / })
|
||||
const connectionGroup = (page: Page) => joinToggle(page).locator('xpath=..')
|
||||
const importCta = (page: Page) => airtrailPicker(page).getByRole('button', { name: /^Import \d+$/ })
|
||||
|
||||
/** Give a day's legs back to the day's own mode, whichever stop carries the one that was set. */
|
||||
async function resetLegModes(page: Page, dayIndex: number): Promise<void> {
|
||||
const { tripId, dayIds } = seededTrip()
|
||||
const res = await page.request.get(`/api/trips/${tripId}/days/${dayIds[dayIndex]}/assignments`)
|
||||
const body = (await res.json()) as { assignments?: { id: number }[] }
|
||||
for (const a of body.assignments ?? []) {
|
||||
await page.request.put(`/api/trips/${tripId}/assignments/${a.id}/transport`, { data: { transport_mode: null } })
|
||||
}
|
||||
}
|
||||
|
||||
/** The day route toggle stays on until it is clicked again; it is only there while a day is open. */
|
||||
async function switchRouteOff(page: Page): Promise<void> {
|
||||
const route = page.getByRole('button', { name: 'Route', exact: true }).first()
|
||||
if (await route.isVisible().catch(() => false)) {
|
||||
await route.click()
|
||||
await settle(page)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
if (await modal(page).isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'transports-list': {
|
||||
guide: guide('transports-list'),
|
||||
start: p => openTrip(p, { tab: 'transports' }),
|
||||
steps: [
|
||||
only(p => p.getByRole('button', { name: 'Transports', exact: true })),
|
||||
{
|
||||
// The menu with Train ticked, so the picture shows a filter in force.
|
||||
prepare: async p => {
|
||||
await toolbarButton(p, 'Filter').click()
|
||||
await expect(toolbarMenu(p, 'Filter')).toBeVisible()
|
||||
await toolbarMenu(p, 'Filter').getByRole('button', { name: /^Train\b/ }).click()
|
||||
await expect(card(p, FLIGHT.title)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => toolbarMenu(p, 'Filter'),
|
||||
act: async p => {
|
||||
await toolbarMenu(p, 'Filter').getByRole('button', { name: 'Reset filters' }).click()
|
||||
await expect(card(p, FLIGHT.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await toolbarButton(p, 'View options').click()
|
||||
await expect(toolbarMenu(p, 'View options')).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => toolbarMenu(p, 'View options'),
|
||||
act: async p => {
|
||||
await toolbarButton(p, 'View options').click()
|
||||
await expect(toolbarMenu(p, 'View options')).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => card(p, FLIGHT.title)),
|
||||
{
|
||||
prepare: async p => {
|
||||
await card(p, FLIGHT.title).getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(p.getByText('Delete booking?')).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => portalDialog(p, p.getByText('Delete booking?')),
|
||||
// Cancel, not Confirm: the flight has to survive for the next guide.
|
||||
act: async p => {
|
||||
await portalDialog(p, p.getByText('Delete booking?')).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(p.getByText('Delete booking?')).toHaveCount(0)
|
||||
await expect(card(p, FLIGHT.title)).toBeVisible()
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
|
||||
},
|
||||
},
|
||||
'add-transport': {
|
||||
guide: guide('add-transport'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: p => openDayMenu(p, 1),
|
||||
target: p => dayMenu(p).getByRole('button', { name: 'Add transport' }),
|
||||
act: async p => {
|
||||
await dayMenu(p).getByRole('button', { name: 'Add transport' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typePill(p).click()
|
||||
await expect(typeMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: typeMenu,
|
||||
act: async p => {
|
||||
await typeMenu(p).getByRole('button', { name: /^Taxi$/ }).first().click()
|
||||
await expect(typeMenu(p)).toHaveCount(0)
|
||||
await expect(modal(p).getByText('Start time', { exact: true })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...'), TAXI.title)
|
||||
await settle(p)
|
||||
},
|
||||
target: headBand,
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await searchLocation(p, locationBox(p, 0), TAXI.from.query, TAXI.from.match)
|
||||
await searchLocation(p, locationBox(p, 1), TAXI.to.query, TAXI.to.match)
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
// Between the two stops of day 1, which the fixtures give 09:30 and 13:00.
|
||||
await modal(p).getByPlaceholder('00:00').nth(0).fill('12:00')
|
||||
await modal(p).getByPlaceholder('00:00').nth(1).fill('12:30')
|
||||
await settle(p)
|
||||
},
|
||||
target: p => modal(p).getByText('Date', { exact: true }).locator('xpath=../..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(transportRow(p, new RegExp(TAXI.title))).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByTitle(p, TAXI.title),
|
||||
},
|
||||
'import-transport-file': {
|
||||
guide: guide('import-transport-file'),
|
||||
// See import-booking-file: the extractor and no addon, and the mail has to
|
||||
// be an allowed file type for the review to keep it under Files.
|
||||
start: async p => {
|
||||
await requireExtractor(p.request)
|
||||
await allowEmlUploads(p.request, true)
|
||||
await openTrip(p, { tab: 'transports' })
|
||||
},
|
||||
steps: [
|
||||
...importSteps(flightEmlFixture, FLIGHT_EML),
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true })).toBeVisible({ timeout: 90_000 })
|
||||
await settle(p)
|
||||
},
|
||||
target: p => importTask(p, FLIGHT_EML),
|
||||
act: async p => {
|
||||
await importTask(p, FLIGHT_EML).getByRole('button', { name: 'Import', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Add transport' })).toBeVisible({ timeout: 20_000 })
|
||||
await expect(modal(p).getByPlaceholder('e.g. Lufthansa LH123, Hotel Adlon, ...')).toHaveValue(/203/)
|
||||
// Both airports came out of the built-in table by their codes: the
|
||||
// airport boxes are the two with an airport code in brackets.
|
||||
await expect(airportBoxes(p).nth(0)).toHaveValue(IMPORTED.from)
|
||||
await expect(airportBoxes(p).nth(1)).toHaveValue(IMPORTED.to)
|
||||
await expect(modal(p).getByPlaceholder('e.g. ABC12345')).toHaveValue(IMPORTED.code)
|
||||
await expect(modal(p).getByText(FLIGHT_EML)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: dialog,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await expect(cardShowing(p, IMPORTED.code)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await closeModal(p)
|
||||
await dismissImportTask(p, FLIGHT_EML)
|
||||
await allowEmlUploads(p.request, false)
|
||||
await p.evaluate(() => {
|
||||
try { localStorage.removeItem('trek.bg-import-tasks') } catch { /* nothing was stored */ }
|
||||
})
|
||||
await deleteByCode(p, IMPORTED.code)
|
||||
await deleteTripFiles(p, FLIGHT_EML)
|
||||
},
|
||||
},
|
||||
'plan-transit': {
|
||||
guide: guide('plan-transit'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
prepare: p => openDayMenu(p, 1),
|
||||
target: p => dayMenu(p).getByRole('button', { name: 'Public transit' }),
|
||||
act: async p => {
|
||||
await dayMenu(p).getByRole('button', { name: 'Public transit' }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Public transit' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The quick picks open on focus, so the day's own stops are in the picture.
|
||||
prepare: async p => {
|
||||
await stopBox(p, 0).click()
|
||||
await expect(stopSuggestions(p).getByRole('button', { name: 'Senso-ji Temple' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByText('From', { exact: true }).locator('xpath=../..'),
|
||||
act: async p => {
|
||||
await pickStop(p, stopBox(p, 0), 'Senso-ji Temple')
|
||||
await pickStop(p, stopBox(p, 1), 'teamLab Planets')
|
||||
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeEnabled()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Leave when Senso-ji ends. The search opens on 09:00, and a connection
|
||||
// from there would land in the day before the stop it starts from.
|
||||
prepare: async p => {
|
||||
const time = modal(p).getByPlaceholder('00:00')
|
||||
await time.fill('11:00')
|
||||
await time.blur()
|
||||
await expect(time).toHaveValue('11:00')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Depart' }).locator('xpath=ancestor::div[3]'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Fewer transfers' }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Tram' }).locator('xpath=ancestor::div[2]'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Search' }).click()
|
||||
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await itinerary(p, 0).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => itinerary(p, 0).locator('xpath=..'),
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add to day' }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await expect(transportRow(p, /Senso-ji Temple.*teamLab Planets/)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: p => deleteByTitle(p, PLANNED),
|
||||
},
|
||||
'change-transit-route': {
|
||||
guide: guide('change-transit-route'),
|
||||
start: p => openTrip(p, { tab: 'transports' }),
|
||||
steps: [
|
||||
{
|
||||
target: p => card(p, JOURNEY.title),
|
||||
act: async p => {
|
||||
await card(p, JOURNEY.title).click()
|
||||
await expect(bookingDetail(p, JOURNEY.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => bookingDetail(p, JOURNEY.title).getByText('Itinerary', { exact: true }).locator('xpath=ancestor::section[1]')),
|
||||
{
|
||||
target: p => bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }),
|
||||
act: async p => {
|
||||
await bookingDetail(p, JOURNEY.title).getByRole('button', { name: 'Change route' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Search' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Search' }).click()
|
||||
await expect(modal(p).getByText('Routing data via')).toBeVisible({ timeout: 60_000 })
|
||||
await itinerary(p, 1).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Add to day' })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByRole('button', { name: 'Add to day' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add to day' }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
// The connection now carries another itinerary: put the canned one back.
|
||||
cleanup: async p => {
|
||||
await deleteByTitle(p, JOURNEY.title)
|
||||
await ensureTransportFixtures(p.request)
|
||||
},
|
||||
},
|
||||
'leg-travel-mode': {
|
||||
guide: guide('leg-travel-mode'),
|
||||
// The route tools only render for the day that is open, and closing its
|
||||
// details panel closes the day again, so day 1 stays open here.
|
||||
start: p => openTripOnDay(p, 1),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
|
||||
await expect(connector(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(connector),
|
||||
{
|
||||
prepare: async p => {
|
||||
await connector(p).click()
|
||||
await expect(legMenu(p)).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: legMenu,
|
||||
},
|
||||
{
|
||||
target: p => legMenu(p).getByRole('button', { name: 'Walking' }),
|
||||
act: async p => {
|
||||
await legMenu(p).getByRole('button', { name: 'Walking' }).click()
|
||||
// The menu closing is the change landing. Not the icon on the first
|
||||
// leg: the day re-routes when a mode changes, and which leg comes
|
||||
// first is not fixed while it does.
|
||||
await expect(legMenu(p)).toHaveCount(0, { timeout: 20_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await switchRouteOff(p)
|
||||
await resetLegModes(p, 0)
|
||||
},
|
||||
},
|
||||
'edit-transport': {
|
||||
guide: guide('edit-transport'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => transportRow(p, FLIGHT.row),
|
||||
act: async p => {
|
||||
await transportRow(p, FLIGHT.row).click()
|
||||
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => detailEdit(p, FLIGHT.title),
|
||||
act: async p => {
|
||||
await detailEdit(p, FLIGHT.title).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Edit transport' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => block(p, 'Route')),
|
||||
{
|
||||
// Escape rather than Update: nothing about the seeded flight may change.
|
||||
target: p => modal(p).getByRole('button', { name: 'Update', exact: true }),
|
||||
act: closeModal,
|
||||
},
|
||||
],
|
||||
},
|
||||
'transport-on-map': {
|
||||
guide: guide('transport-on-map'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }),
|
||||
act: async p => {
|
||||
await transportRow(p, FLIGHT.row).getByRole('button', { name: 'Show booking routes' }).click()
|
||||
await expect(transportRow(p, FLIGHT.row).getByRole('button', { name: 'Hide booking routes' })).toBeVisible()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
},
|
||||
// The map is on Tokyo, so the Frankfurt end is off screen and the Haneda
|
||||
// one, drawn second, is the marker the reader can actually see.
|
||||
only(p => p.locator('.trek-endpoint-marker').last()),
|
||||
{
|
||||
prepare: async p => {
|
||||
await p.locator('.trek-endpoint-marker').last().click()
|
||||
await expect(bookingDetail(p, FLIGHT.title)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => bookingDetail(p, FLIGHT.title),
|
||||
act: async p => {
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(bookingDetail(p, FLIGHT.title)).toHaveCount(0)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Show all booking routes' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Show all booking routes' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Hide all booking routes' })).toBeVisible()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Day 6 carries the stored connection, and it has the two located stops
|
||||
// the route tools need, so its Route toggle is there to switch on.
|
||||
prepare: async p => {
|
||||
await selectDay(p, 6)
|
||||
await expect(p.getByRole('button', { name: 'Route', exact: true }).first()).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Route', exact: true }).first(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Route', exact: true }).first().click()
|
||||
await settle(p)
|
||||
await p.waitForTimeout(1500)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
await switchRouteOff(p)
|
||||
const { tripId } = seededTrip()
|
||||
await p.evaluate(id => {
|
||||
try { localStorage.removeItem(`trek:visible-connections:${id}`) } catch { /* nothing was stored */ }
|
||||
}, tripId)
|
||||
},
|
||||
},
|
||||
'airtrail-import': {
|
||||
guide: guide('airtrail-import'),
|
||||
// The connection is made here and taken away in cleanup, so the hero and
|
||||
// the guides before this one keep a toolbar without the AirTrail button.
|
||||
start: async p => {
|
||||
await ensureAirtrailConnection(p.request)
|
||||
await openTrip(p, { tab: 'transports' })
|
||||
// The addon list and the connection probe are read once, when the app
|
||||
// loads. One more load now that the connection exists, and the button
|
||||
// is waited for, so a missing connection fails here and not at step 1.
|
||||
await p.reload()
|
||||
await expect(p.getByRole('button', { name: 'Share', exact: true })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(airtrailButton(p)).toBeVisible({ timeout: 30_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: airtrailButton,
|
||||
act: async p => {
|
||||
await airtrailButton(p).click()
|
||||
await expect(airtrailPicker(p)).toBeVisible()
|
||||
// The rows come from the AirTrail instance itself, over the LAN.
|
||||
await expect(flightRow(p, HOME_LEG.flightNumber)).toBeVisible({ timeout: 30_000 })
|
||||
await expect(flightRow(p, OTHER.flightNumber)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
// The whole picker: During this trip with its ticked rows, Other flights below.
|
||||
only(airtrailPicker),
|
||||
// A row under Other flights, unticked, as the text says those are. Not
|
||||
// clicked: a spring flight imported into an autumn trip would be clamped
|
||||
// onto day 1 and spoil the result picture.
|
||||
only(p => flightRow(p, OTHER.flightNumber)),
|
||||
{
|
||||
// Joined by default: the tick under the two legs is on before anyone touches it.
|
||||
prepare: async p => {
|
||||
await expect(joinToggle(p)).toContainText(`Import as one flight with a layover in ${JOINED.layover}`)
|
||||
await expect(joinToggle(p).locator('svg.lucide-check')).toBeVisible()
|
||||
},
|
||||
target: connectionGroup,
|
||||
},
|
||||
{
|
||||
// The two flights home and nothing else are ticked.
|
||||
prepare: async p => {
|
||||
await expect(importCta(p)).toHaveText('Import 2')
|
||||
},
|
||||
target: importCta,
|
||||
act: async p => {
|
||||
await importCta(p).click()
|
||||
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
|
||||
// The toast counts flight ids, so the joined pair reports two.
|
||||
const toast = p.getByText('2 flight(s) imported', { exact: true })
|
||||
await expect(toast).toBeVisible({ timeout: 15_000 })
|
||||
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
|
||||
// The toast fades after three seconds; the card's picture and the
|
||||
// result are taken without a half-faded message in the corner.
|
||||
await expect(toast).toHaveCount(0, { timeout: 10_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await expect(card(p, JOINED.title).getByText('AirTrail', { exact: true })).toBeVisible()
|
||||
},
|
||||
target: p => card(p, JOINED.title),
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// By every title the import can produce: the joined route, or one
|
||||
// flight number per card when the join was off.
|
||||
await deleteByTitle(p, JOINED.title, ...AIRTRAIL_FLIGHTS.map(f => f.flightNumber))
|
||||
await disconnectAirtrail(p.request)
|
||||
await p.evaluate(() => { try { sessionStorage.clear() } catch { /* a locked-down browser keeps its filters */ } })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await ensureTransportFixtures(request)
|
||||
})
|
||||
|
||||
test('every registered transports guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripTransportsContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip-transports', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripTransportsContext.id, p => openTrip(p, { tab: 'transports' }))
|
||||
})
|
||||
|
||||
for (const id of tripTransportsContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { ensureUsers, seededTrip, ADMIN_USERS } from './fixtures'
|
||||
import { openTrip, openTripOnDay, modal } from './trip-shared'
|
||||
import { tripContext, tripGuides } from '../../src/help/contexts/trip'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the trip frame, keyed by the ids in `src/help/contexts/trip.ts`:
|
||||
* the people of the trip (the Share dialog), the columns and the undo. They run
|
||||
* on the seeded "Autumn in Japan"; what a guide creates (a member, a guest, a
|
||||
* link) it leaves for the next one or takes away again in `cleanup`.
|
||||
*/
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = tripGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/** A section of the Share dialog by the words it opens with (its label, or the title of a link card). */
|
||||
const block = (page: Page, opening: RegExp) => modal(page).locator('section').filter({ hasText: opening }).first()
|
||||
const inviteBlock = (page: Page) => block(page, /^Invite User/)
|
||||
/**
|
||||
* The days column's fold tab: the one button straight inside the column's
|
||||
* frame, which sits 10px in from the left. Its name is Collapse while the
|
||||
* column is open and Plan once it is folded, and both names are taken
|
||||
* elsewhere (the day cards fold with Collapse, the first tab is Plan).
|
||||
*/
|
||||
const leftToggle = (page: Page) => page.locator('div[style*="left: 10px"][style*="top: 10px"] > button').first()
|
||||
|
||||
async function openShare(page: Page): Promise<void> {
|
||||
await openTrip(page)
|
||||
await page.getByRole('button', { name: 'Share', exact: true }).click()
|
||||
await expect(modal(page)).toBeVisible()
|
||||
await expect(modal(page).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const closeModal = async (page: Page): Promise<void> => {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
const only = (target: (p: Page) => Locator) => ({ target })
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'add-member': {
|
||||
guide: guide('add-member'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Share', exact: true }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Share', exact: true }).click()
|
||||
await expect(modal(p).getByRole('heading', { name: 'Share Trip' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Select user…' }).click()
|
||||
// The select's list is rendered outside the dialog.
|
||||
await p.getByRole('button', { name: 'mara', exact: true }).last().click()
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: inviteBlock,
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Invite', exact: true }).click()
|
||||
await expect(block(p, /^Access\s*\d/).getByText('mara', { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => block(p, /^Access\s*\d/)),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'trip-invite-link': {
|
||||
guide: guide('trip-invite-link'),
|
||||
start: openShare,
|
||||
steps: [
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create invite link' }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create invite link' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Regenerate' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
only(p => block(p, /^Trip invite link/)),
|
||||
only(p => modal(p).getByRole('button', { name: 'Regenerate' }).locator('xpath=..')),
|
||||
],
|
||||
cleanup: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Disable' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Create invite link' })).toBeVisible({ timeout: 15_000 })
|
||||
await closeModal(p)
|
||||
},
|
||||
},
|
||||
'add-guest': {
|
||||
guide: guide('add-guest'),
|
||||
start: openShare,
|
||||
steps: [
|
||||
only(p => block(p, /^Guests/)),
|
||||
{
|
||||
prepare: async p => {
|
||||
await typeInto(p, modal(p).getByPlaceholder('Guest name'), 'Aunt Lise')
|
||||
await beat(p, 300)
|
||||
},
|
||||
target: p => modal(p).getByPlaceholder('Guest name').locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add guest' }).click()
|
||||
await expect(modal(p).getByText('Aunt Lise')).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'public-link': {
|
||||
guide: guide('public-link'),
|
||||
start: openShare,
|
||||
steps: [
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Map & Plan' }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Bookings', exact: true }).click()
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Create link', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Create link', exact: true }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Delete link' })).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => block(p, /^Public Link/),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Delete link' }).click()
|
||||
await expect(modal(p).getByRole('button', { name: 'Create link', exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'transfer-ownership': {
|
||||
guide: guide('transfer-ownership'),
|
||||
start: openShare,
|
||||
steps: [
|
||||
only(p => modal(p).getByRole('button', { name: 'Make owner' }).first()),
|
||||
only(p => modal(p).getByRole('button', { name: 'Leave trip' })),
|
||||
],
|
||||
cleanup: closeModal,
|
||||
},
|
||||
'collapse-columns': {
|
||||
guide: guide('collapse-columns'),
|
||||
start: p => openTrip(p),
|
||||
steps: [
|
||||
{
|
||||
target: leftToggle,
|
||||
act: async p => {
|
||||
await leftToggle(p).click()
|
||||
await expect(leftToggle(p)).toHaveAttribute('aria-label', 'Plan')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: leftToggle,
|
||||
act: async p => {
|
||||
await leftToggle(p).click()
|
||||
await expect(p.getByRole('button', { name: 'Export' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The divider is invisible until hovered; the hover shows it.
|
||||
target: p => p.locator('div[style*="col-resize"]').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
'undo-change': {
|
||||
guide: guide('undo-change'),
|
||||
start: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const created = await p.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: 'Tsukiji Outer Market', lat: 35.6654, lng: 139.7707, address: '4 Chome Tsukiji, Chuo City, Tokyo' },
|
||||
})
|
||||
if (!created.ok()) throw new Error(`could not create the spare place: ${created.status()} ${await created.text()}`)
|
||||
// A change to undo: the spare place onto day 1, from the places column.
|
||||
// The day has to stay open for that: the + at the end of a row belongs to
|
||||
// the open day, and closing the day's details panel closes the day with it.
|
||||
await openTripOnDay(p, 1)
|
||||
const row = p.getByRole('option', { name: /^Tsukiji Outer Market/ })
|
||||
await row.hover()
|
||||
const add = row.getByRole('button', { name: '+ Day' })
|
||||
await expect(add, 'the + that puts a place on the open day').toBeVisible({ timeout: 15_000 })
|
||||
await add.click()
|
||||
await expect(p.getByRole('button', { name: 'Undo' })).toBeEnabled({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Undo' }),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(p.getByRole('button', { name: 'Undo' })).toBeDisabled({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
const { tripId } = seededTrip()
|
||||
const res = await p.request.get(`/api/trips/${tripId}/places`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
const places = Array.isArray(body) ? body : (body.places ?? [])
|
||||
for (const place of places.filter(x => x.name === 'Tsukiji Outer Market')) {
|
||||
await p.request.delete(`/api/trips/${tripId}/places/${place.id}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// The seed's members already have access; the invite needs someone who has not.
|
||||
await ensureUsers(request, ADMIN_USERS)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
})
|
||||
|
||||
test('every registered trip guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripContext.guides].sort())
|
||||
})
|
||||
|
||||
test('hero: trip', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, tripContext.id, p => openTrip(p))
|
||||
})
|
||||
|
||||
for (const id of tripContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
|
||||
import { E2E_BASE_URL } from '../../playwright.config'
|
||||
import { monthName, year } from '../dates'
|
||||
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
/**
|
||||
* Actions for the Vacay guides, keyed by the ids in `src/help/contexts/vacay.ts`.
|
||||
* They run in the order the context lists them and build on each other: the
|
||||
* year the calendar shows gets a few logged days, company holidays and a
|
||||
* public-holiday calendar as the run goes on, which is what a lived-in plan
|
||||
* looks like anyway.
|
||||
*/
|
||||
|
||||
const YEAR = year()
|
||||
/** The month after the picture day's: the first one the planner draws after the current. */
|
||||
const NEXT_MONTH = monthName(1)
|
||||
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
|
||||
const ENTITLEMENT = '28'
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = vacayGuides.find(x => x.id === id)
|
||||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* What clearNotices in `screenshots/shot.ts` clears, less its loop over every
|
||||
* button whose name contains "next". On this page that loop found the year
|
||||
* card's "Add next year" and clicked it six times on every visit, so the plan
|
||||
* grew by six years per guide and the Year card filled up with chips past
|
||||
* 2100. A tour's own button is called just "Next", so that one is still stepped
|
||||
* through.
|
||||
*/
|
||||
async function clearVacayNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: 'Next', exact: true })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled().catch(() => false))) break
|
||||
await next.click().catch(() => {})
|
||||
}
|
||||
for (const label of ['Dismiss', 'OK']) {
|
||||
const btn = page.getByRole('button', { name: label, exact: true })
|
||||
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
|
||||
await btn.click().catch(() => {})
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openVacay(page: Page): Promise<void> {
|
||||
await page.goto('/vacay')
|
||||
await dismissReleaseNotice(page)
|
||||
await clearVacayNotices(page)
|
||||
await expect(toolbar(page)).toBeVisible()
|
||||
await expect(monthCard(page, NEXT_MONTH)).toBeVisible()
|
||||
}
|
||||
|
||||
/** The floating mode toolbar under the grid. */
|
||||
const toolbar = (page: Page) => page.locator('.vg-card.rounded-full').first()
|
||||
/** A month card by its (English) heading. */
|
||||
const monthCard = (page: Page, month: string) => page.locator('.vg-card').filter({ hasText: month }).first()
|
||||
/** A day cell inside a month card. */
|
||||
const day = (page: Page, month: string, n: number) => monthCard(page, month).getByRole('button', { name: String(n), exact: true })
|
||||
/** A sidebar card by its label (the DOM text; CSS uppercases it). */
|
||||
const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter({ hasText: label }).first()
|
||||
/** The settings dialog. */
|
||||
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
|
||||
/** The invite and share forms: small dialogs portalled to the body. */
|
||||
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
|
||||
/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */
|
||||
const settingRow = (page: Page, label: string): Locator =>
|
||||
settings(page).getByText(label, { exact: true }).locator('xpath=../../..')
|
||||
/** The switch of a settings row, found through the row's label. */
|
||||
const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first()
|
||||
/** The form Add calendar opens: colour, label, country, region and its Add button. */
|
||||
const addCalendarForm = (page: Page): Locator =>
|
||||
settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..')
|
||||
/** The Days tile of the Entitlement card while it is being edited. */
|
||||
const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..')
|
||||
/** A year chip in the Year card. */
|
||||
const yearChip = (page: Page, y: number): Locator =>
|
||||
sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first()
|
||||
/** A row of a sidebar card (a person, an invite, a share) by the name in it. */
|
||||
const cardRow = (page: Page, card: string, name: string): Locator =>
|
||||
sideCard(page, card).locator('.group').filter({ hasText: name }).first()
|
||||
|
||||
/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */
|
||||
const parkPointer = async (page: Page): Promise<void> => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) }
|
||||
|
||||
/**
|
||||
* Back to the top of the page. A step target further down scrolls the page a
|
||||
* little, and a result picture taken there hides the top row of months under
|
||||
* the header, unlike the hero and the other results.
|
||||
*/
|
||||
async function toTop(page: Page): Promise<void> {
|
||||
// On a desktop the body is what scrolls (index.css hides the overflow of
|
||||
// html), so the window's own scroll position is already 0.
|
||||
await page.evaluate(() => {
|
||||
for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
})
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a settings switch on, and leave it alone when it is on already. Block
|
||||
* Weekends and Carry Over start out on, and a plain click switched them off,
|
||||
* so the next picture showed the opposite of what the step had just said. The
|
||||
* switch has no aria state; its filled track is the only sign it is on.
|
||||
*/
|
||||
async function switchOn(page: Page, label: string): Promise<void> {
|
||||
const sw = toggleRow(page, label)
|
||||
if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Settings' }).click()
|
||||
await expect(settings(page)).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function closeSettings(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(settings(page)).toBeHidden()
|
||||
// The dialog hands the focus back to the Settings button, and after a key
|
||||
// press that draws a thick focus outline, which in the next picture reads
|
||||
// like a step ring.
|
||||
await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Add calendar and fill the form the way the step describes, short of
|
||||
* adding it, so the picture shows the fields filled in.
|
||||
*/
|
||||
async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise<void> {
|
||||
await addButton.click()
|
||||
await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label)
|
||||
await pick(page, settings(page).getByRole('button', { name: 'Select country' }), 'Germany')
|
||||
// The region list loads after the country is picked; give it a moment to show up.
|
||||
const region = settings(page).getByRole('button', { name: 'Select region (required)' })
|
||||
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
|
||||
if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin')
|
||||
await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function submitCalendarForm(page: Page): Promise<void> {
|
||||
await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
// The form closes once the calendar is saved and turns into its row.
|
||||
await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Close Settings", the last step of the holiday guides. The ring goes round
|
||||
* the head band rather than the close button alone: the 960 px dialog is as
|
||||
* wide as the smallest frame, so a frame centred on the button leaves out the
|
||||
* band's title and the calendar the guide just added, and the band is still
|
||||
* what the step names. The pointer rests on the button, so its Close tooltip
|
||||
* marks where to click inside the ring.
|
||||
*/
|
||||
const settingsCloseStep: StepAction = {
|
||||
target: p => settings(p).locator('header').first(),
|
||||
hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() },
|
||||
act: closeSettings,
|
||||
}
|
||||
|
||||
// ── A calendar shared with the admin ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The seeded member whose calendar the share-calendar guide shows as shared
|
||||
* with the admin. Not jonas: the admin shares with jonas in the same guide,
|
||||
* and two rows named jonas would hide which one is incoming.
|
||||
*/
|
||||
const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' }
|
||||
|
||||
/**
|
||||
* A request context acting as the member. Its own context with a bearer
|
||||
* token: a login on the page's context would put the member's session cookie
|
||||
* on the admin's requests.
|
||||
*/
|
||||
async function asMember(member: typeof SHARER): Promise<APIRequestContext> {
|
||||
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined })
|
||||
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } })
|
||||
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined
|
||||
await anon.dispose()
|
||||
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`)
|
||||
return apiRequest.newContext({
|
||||
baseURL: E2E_BASE_URL,
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
/** The member shares her calendar with the admin, as she would from her own Vacay. */
|
||||
async function shareWithAdmin(page: Page): Promise<void> {
|
||||
const me = await page.request.get('/api/auth/me')
|
||||
const adminId = ((await me.json()) as { user: { id: number } }).user.id
|
||||
const member = await asMember(SHARER)
|
||||
try {
|
||||
const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } })
|
||||
// A retried guide finds the share from its first attempt still there.
|
||||
if (!res.ok() && !(await res.text()).includes('Already shared')) {
|
||||
throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`)
|
||||
}
|
||||
} finally {
|
||||
await member.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
|
||||
async function unshareWithAdmin(page: Page): Promise<void> {
|
||||
const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] }
|
||||
for (const s of shares.incoming.filter(x => x.username === SHARER.username)) {
|
||||
const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`)
|
||||
if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`)
|
||||
}
|
||||
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
|
||||
notifications: { id: number; title_key: string; sender_username: string | null }[]
|
||||
}
|
||||
for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) {
|
||||
await page.request.delete(`/api/notifications/in-app/${n.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an option in a CustomSelect: open it, filter, click the match inside
|
||||
* the open menu. Scoped to the menu on purpose: a calendar row already set to
|
||||
* "Germany" renders a trigger button of that very name, which a page-wide
|
||||
* lookup would hit first.
|
||||
*/
|
||||
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
|
||||
await trigger.click()
|
||||
const search = page.getByPlaceholder('...')
|
||||
await search.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
await typeInto(page, search, text)
|
||||
const menu = search.locator('xpath=../..')
|
||||
await menu.getByRole('button', { name: text, exact: true }).first().click()
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'log-day': {
|
||||
guide: guide('log-day'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{ target: p => toolbar(p).getByRole('button').first() },
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 14),
|
||||
act: async p => { await day(p, NEXT_MONTH, 14).click(); await beat(p, 600) },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 14),
|
||||
act: async p => {
|
||||
await day(p, NEXT_MONTH, 14).click()
|
||||
await beat(p, 600)
|
||||
// Leave the day logged for the after-picture and the guides that follow.
|
||||
await day(p, NEXT_MONTH, 14).click()
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'half-day': {
|
||||
guide: guide('half-day'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Half day' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 15),
|
||||
act: async p => { await day(p, NEXT_MONTH, 15).click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Half day' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'comp-day': {
|
||||
guide: guide('comp-day'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Comp / Flex' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => day(p, NEXT_MONTH, 16),
|
||||
act: async p => {
|
||||
await day(p, NEXT_MONTH, 16).click()
|
||||
await settle(p)
|
||||
await p.getByRole('button', { name: 'Comp / Flex' }).click()
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
entitlement: {
|
||||
guide: guide('entitlement'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first(),
|
||||
act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() },
|
||||
},
|
||||
{
|
||||
// The seed's entitlement is 30, so typing 30 changed nothing and the
|
||||
// result showed the Left of before. A different number is typed first,
|
||||
// so the picture shows it in the field and the result recalculates Left.
|
||||
prepare: async p => {
|
||||
const input = sideCard(p, 'Entitlement').locator('input')
|
||||
await input.fill('')
|
||||
await typeInto(p, input, ENTITLEMENT)
|
||||
},
|
||||
// The whole tile, so the ring and its number sit round the Days label
|
||||
// and the field rather than on the label.
|
||||
target: daysTile,
|
||||
act: async p => {
|
||||
await sideCard(p, 'Entitlement').locator('input').press('Enter')
|
||||
await expect(sideCard(p, 'Entitlement').locator('input')).toHaveCount(0)
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
years: {
|
||||
guide: guide('years'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByTitle('Add next year'),
|
||||
act: async p => {
|
||||
await p.getByTitle('Add next year').click()
|
||||
await expect(sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sideCard(p, 'Year').locator('.grid'),
|
||||
act: async p => {
|
||||
// Over to the new year and back. Staying put would leave the Entitlement
|
||||
// card on the numbers of the year just added, which Add next year loads
|
||||
// under the heading of the year still selected; switching reloads them.
|
||||
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().click()
|
||||
await settle(p)
|
||||
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The chip with its minus showing, rather than the minus alone: the step
|
||||
// says to hover the chip, and a ring round the tiny minus put its number
|
||||
// on top of the year it belongs to.
|
||||
prepare: async p => { await yearChip(p, YEAR + 1).hover() },
|
||||
target: p => yearChip(p, YEAR + 1),
|
||||
// With the ring on the chip, this is what still fails the guide when the minus is gone.
|
||||
act: async p => { await expect(yearChip(p, YEAR + 1).getByRole('button', { name: 'Remove year' })).toBeVisible() },
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// The extra year was for the pictures only; the guides that follow expect the seed's single year.
|
||||
const res = await p.request.delete(`/api/addons/vacay/years/${YEAR + 1}`)
|
||||
if (!res.ok()) throw new Error(`could not remove year ${YEAR + 1}: ${res.status()}`)
|
||||
},
|
||||
},
|
||||
|
||||
'company-holidays': {
|
||||
guide: guide('company-holidays'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
// On by default, so the step only shows where the switch lives. The ring
|
||||
// takes the whole row, name and switch, which is what "check that it is
|
||||
// on" reads off, and it pulls the frame over the dialog instead of the
|
||||
// page beside it.
|
||||
prepare: openSettings,
|
||||
target: p => settingRow(p, 'Company Holidays'),
|
||||
act: closeSettings,
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Company Holiday' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => day(p, 'December', 24),
|
||||
act: async p => {
|
||||
await day(p, 'December', 24).click()
|
||||
await beat(p, 400)
|
||||
await day(p, 'December', 31).click()
|
||||
await settle(p)
|
||||
// Back to logging vacation, so the next guide starts where a reader would.
|
||||
await toolbar(p).getByRole('button').first().click()
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'public-holidays': {
|
||||
guide: guide('public-holidays'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
// The whole row, as in company-holidays step 1: name and switch together.
|
||||
prepare: openSettings,
|
||||
target: p => settingRow(p, 'Public Holidays'),
|
||||
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
// The step is about the country and the region, so the picture shows
|
||||
// the form filled in rather than the button that opens it empty.
|
||||
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).first(), 'Berlin'),
|
||||
target: addCalendarForm,
|
||||
act: submitCalendarForm,
|
||||
},
|
||||
settingsCloseStep,
|
||||
],
|
||||
},
|
||||
|
||||
'school-holidays': {
|
||||
guide: guide('school-holidays'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
prepare: openSettings,
|
||||
target: p => settingRow(p, 'School Holidays'),
|
||||
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
// The last Add calendar: the public-holiday one above it is the first.
|
||||
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).last(), 'Schools Berlin'),
|
||||
target: addCalendarForm,
|
||||
act: submitCalendarForm,
|
||||
},
|
||||
settingsCloseStep,
|
||||
],
|
||||
},
|
||||
|
||||
weekends: {
|
||||
guide: guide('weekends'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Settings' }),
|
||||
act: openSettings,
|
||||
},
|
||||
{
|
||||
target: p => toggleRow(p, 'Block Weekends'),
|
||||
act: async p => { await switchOn(p, 'Block Weekends') },
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'),
|
||||
act: closeSettings,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'leave-year': {
|
||||
guide: guide('leave-year'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
prepare: openSettings,
|
||||
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'),
|
||||
},
|
||||
{
|
||||
// The step names all three choices, so the ring goes round the row that holds them.
|
||||
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first().locator('xpath=..'),
|
||||
act: closeSettings,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'carry-over': {
|
||||
guide: guide('carry-over'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Settings' }),
|
||||
act: openSettings,
|
||||
},
|
||||
{
|
||||
target: p => toggleRow(p, 'Carry Over'),
|
||||
act: async p => { await switchOn(p, 'Carry Over'); await closeSettings(p) },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
invite: {
|
||||
guide: guide('invite'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => sideCard(p, 'Persons').getByRole('button').first(),
|
||||
act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
// Picked before the picture, so it shows mira chosen and Send Invite
|
||||
// ready, which is the button the step ends on; the dialog closes on send,
|
||||
// so no later picture shows the choice.
|
||||
prepare: async p => {
|
||||
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)')
|
||||
await expect(dialog(p).getByRole('button', { name: 'Send Invite' })).toBeEnabled()
|
||||
},
|
||||
target: p => dialog(p).getByRole('button', { name: 'Send Invite' }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Send Invite' }).click()
|
||||
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole row, with the pending chip the step is about. The pointer
|
||||
// stays off it, or the row's Cancel button shows as well.
|
||||
target: p => cardRow(p, 'Persons', 'mira'),
|
||||
hover: parkPointer,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'share-calendar': {
|
||||
guide: guide('share-calendar'),
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
target: p => sideCard(p, 'Shared Calendars').getByTitle('Share calendar'),
|
||||
act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
// As in the invite guide: jonas picked and Share ready in the picture.
|
||||
prepare: async p => {
|
||||
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas')
|
||||
await expect(dialog(p).getByRole('button', { name: 'Share', exact: true })).toBeEnabled()
|
||||
},
|
||||
target: p => dialog(p).getByRole('button', { name: 'Share', exact: true }),
|
||||
act: async p => {
|
||||
await dialog(p).getByRole('button', { name: 'Share', exact: true }).click()
|
||||
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
// The step also names calendars shared with you, and the seed has none,
|
||||
// so mira shares hers with the admin first. The page learns of it over
|
||||
// the socket; a reload is the fallback should that message not come.
|
||||
prepare: async p => {
|
||||
await shareWithAdmin(p)
|
||||
const incoming = cardRow(p, 'Shared Calendars', SHARER.username)
|
||||
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
|
||||
await expect(incoming).toBeVisible()
|
||||
},
|
||||
target: p => sideCard(p, 'Shared Calendars'),
|
||||
// On the row the admin shares, so its Stop sharing button shows.
|
||||
hover: async p => { await cardRow(p, 'Shared Calendars', 'jonas').hover() },
|
||||
},
|
||||
],
|
||||
cleanup: unshareWithAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('every registered vacay guide has a script, and only those', async () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual(vacayContext.guides.slice().sort())
|
||||
})
|
||||
|
||||
test('hero: vacay', async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureHero(page, vacayContext.id, openVacay)
|
||||
})
|
||||
|
||||
for (const id of vacayContext.guides) {
|
||||
test(`pictures: ${id}`, async ({ page }) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await captureGuide(page, SCRIPTS[id])
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, clearNotices, expect, seed } from './shot'
|
||||
import { PICTURE_DAY } from '../help/guide'
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Collab surfaces, one capture each.
|
||||
@@ -14,10 +15,13 @@ import type { Page, Locator } from '@playwright/test'
|
||||
* So each capture targets its own card element rather than clicking a tab.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The panel card containing a given piece of seeded content: see cardClass in
|
||||
* CollabPanel.tsx.
|
||||
* The panel card containing a given piece of seeded content — see cardClass in
|
||||
* CollabPanel.tsx:20.
|
||||
*
|
||||
* Matching on content rather than the panel heading is deliberate: the headings
|
||||
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
|
||||
@@ -26,15 +30,12 @@ import type { Page, Locator } from '@playwright/test'
|
||||
*/
|
||||
function card(page: Page, contains: string): Locator {
|
||||
return page
|
||||
.locator('div.bg-surface-secondary.rounded-2xl.min-h-0')
|
||||
.locator('div.bg-surface-card.rounded-2xl')
|
||||
.filter({ hasText: contains })
|
||||
.last()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// What's Next lists only what is still ahead, and shots.setup.ts timed its stop
|
||||
// against the picture day's morning, the instant the help pictures are taken at.
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, clearNotices, expect, seed } from './shot'
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Detail pages and the surfaces that need a couple of clicks to reach.
|
||||
@@ -8,6 +10,9 @@ import { test, clearNotices, expect, seed } from './shot'
|
||||
* the run instead of producing a screenshot of the wrong screen.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test('collection detail', async ({ page, shot }) => {
|
||||
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, clearNotices, expect, seed } from './shot'
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Modals and dialogs.
|
||||
@@ -9,6 +11,9 @@ import { test, clearNotices, expect, seed } from './shot'
|
||||
* silently produce a screenshot of the page behind it.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, clearNotices, seed } from './shot'
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Top-level navigable surfaces. One capture per route; anything that needs a
|
||||
@@ -9,6 +11,9 @@ import { test, clearNotices, seed } from './shot'
|
||||
* for which wiki page consumes which file.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
@@ -23,14 +28,6 @@ test('dashboard', async ({ page, shot }) => {
|
||||
|
||||
test('trip planner', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
// The seeded trip is running, so the plan opens on today, which is empty; the
|
||||
// picture shows the first days, where the plan has places, a note and a flight.
|
||||
// On the day's number: the middle of the header is the booked night's pill.
|
||||
await page.locator('.dp-day-header').first().click({ position: { x: 22, y: 20 } })
|
||||
await page.waitForTimeout(1500)
|
||||
await page.locator('.dp-day-header').first().evaluate(el => el.closest('.overflow-y-auto')?.scrollTo({ top: 0 }))
|
||||
await page.waitForTimeout(600)
|
||||
await shot.page_('TripPlanner')
|
||||
})
|
||||
|
||||
@@ -65,9 +62,6 @@ test('in-app help', async ({ page, shot }) => {
|
||||
})
|
||||
|
||||
test('files', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: /^Files/ }).first().click()
|
||||
await page.waitForTimeout(1200)
|
||||
await page.goto(`/trips/${seed.tripId}/files`)
|
||||
await shot.page_('Files')
|
||||
})
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { test, clearNotices, expect, seed } from './shot'
|
||||
import { TOKYO_HOTEL } from '../help/fixtures'
|
||||
|
||||
/**
|
||||
* The Plan tab and the dialogs of the trip page, as the wiki pages for the day
|
||||
* plan, places, bookings, transports and costs show them. Every picture drives
|
||||
* the real UI by its roles and names, so a renamed control fails here instead
|
||||
* of quietly photographing the wrong thing.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.waitForTimeout(800)
|
||||
})
|
||||
|
||||
const dialog = (page: Page) => page.getByRole('dialog').last()
|
||||
const firstDayCard = (page: Page) => page.locator('.dp-day-header').first().locator('xpath=..')
|
||||
|
||||
async function openTab(page: Page, label: string) {
|
||||
// A tab can carry a longer name for screen readers than its label ("Book" for the bookings).
|
||||
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
// On the day's number at the left edge: the middle of the header is the booked
|
||||
// night's pill once the day has a stay, and a click there opens the hotel instead.
|
||||
async function selectDay(page: Page, index: number) {
|
||||
await page.locator('.dp-day-header').nth(index).click({ position: { x: 22, y: 20 } })
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
const selectFirstDay = (page: Page) => selectDay(page, 0)
|
||||
|
||||
/** Out of the way, so no tooltip of the button just clicked stays in the picture. */
|
||||
const parkPointer = (page: Page) => page.mouse.move(1, 1)
|
||||
|
||||
/** A row of the places column, by the place's name. */
|
||||
const placeOption = (page: Page, name: string) => page.getByRole('option', { name: new RegExp(`^${name}`) }).first()
|
||||
|
||||
test('plan: a day card', async ({ page, shot }) => {
|
||||
await shot.element('PlanDayCard', firstDayCard(page))
|
||||
})
|
||||
|
||||
test('plan: the day "+" menu', async ({ page, shot }) => {
|
||||
await selectFirstDay(page)
|
||||
await firstDayCard(page).locator('button[aria-label="Add to day"]').click()
|
||||
await expect(page.getByRole('button', { name: 'Add Note' })).toBeVisible()
|
||||
await shot.page_('PlanDayAddMenu')
|
||||
})
|
||||
|
||||
test('plan: the route bar', async ({ page, shot }) => {
|
||||
await selectFirstDay(page)
|
||||
const bar = page.locator('[data-dp="route-tools"]').first()
|
||||
await expect(bar).toBeVisible()
|
||||
await shot.element('PlanRouteBar', bar)
|
||||
})
|
||||
|
||||
test('plan: the places column', async ({ page, shot }) => {
|
||||
const column = page.getByPlaceholder('Search').first().locator('xpath=ancestor::div[contains(@class,"flex-col")][2]')
|
||||
await shot.element('PlacesColumn', column)
|
||||
})
|
||||
|
||||
test('plan: the place inspector', async ({ page, shot }) => {
|
||||
await placeOption(page, 'Senso-ji Temple').click()
|
||||
await page.waitForTimeout(1200)
|
||||
await shot.page_('PlaceInspector')
|
||||
})
|
||||
|
||||
test('plan: the save to list dialog', async ({ page, shot }) => {
|
||||
await placeOption(page, 'Senso-ji Temple').click()
|
||||
await page.waitForTimeout(1000)
|
||||
await page.getByRole('button', { name: /^Save to Collection/ }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await expect(dialog(page).getByText('Kyoto shortlist')).toBeVisible()
|
||||
await page.waitForTimeout(500)
|
||||
await shot.element('SaveToList', dialog(page))
|
||||
})
|
||||
|
||||
test('plan: the edit place dialog', async ({ page, shot }) => {
|
||||
await placeOption(page, 'Senso-ji Temple').click()
|
||||
await page.waitForTimeout(1000)
|
||||
await page.getByRole('button', { name: 'Edit', exact: true }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await page.waitForTimeout(2500)
|
||||
await shot.element('PlaceForm', dialog(page))
|
||||
})
|
||||
|
||||
test('plan: the day details', async ({ page, shot }) => {
|
||||
await selectFirstDay(page)
|
||||
await shot.page_('DayDetails')
|
||||
})
|
||||
|
||||
test('plan: the stay editor', async ({ page, shot }) => {
|
||||
// Day 9 has no stay yet, so its details offer Add accommodation.
|
||||
await selectDay(page, 8)
|
||||
await page.getByRole('button', { name: 'Add accommodation' }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
// A hotel picked, so the head band carries its name.
|
||||
await dialog(page).getByText('Hotel Granvia Kyoto', { exact: true }).click()
|
||||
await page.waitForTimeout(500)
|
||||
await shot.element('StayEditor', dialog(page))
|
||||
})
|
||||
|
||||
test('plan: the map hover card', async ({ page, shot }) => {
|
||||
// With no day open every stop is folded into a cluster; the open day's stops are
|
||||
// pins. Its details panel stays: closing it would close the day as well.
|
||||
await selectFirstDay(page)
|
||||
// A place pin, not a cluster bubble or an explore result (only those carry a
|
||||
// name). The columns float over the map's edges and pins stack, so the first
|
||||
// one in the DOM is often one the pointer cannot reach.
|
||||
const pins = page.locator('#trek-map .leaflet-marker-icon:not(:has(.marker-cluster-custom)):not([aria-label])')
|
||||
await expect(pins.first()).toBeAttached()
|
||||
const total = await pins.count()
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (await pins.nth(i).hover({ trial: true, timeout: 1500 }).then(() => true, () => false)) {
|
||||
await pins.nth(i).hover()
|
||||
break
|
||||
}
|
||||
}
|
||||
const card = page.getByTestId('tooltip')
|
||||
await expect(card).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
// The card and the pin it belongs to with some map around them, not the whole planner.
|
||||
const box = (await card.boundingBox())!
|
||||
const width = 760
|
||||
const height = 440
|
||||
const x = Math.max(0, Math.min(1440 - width, box.x + box.width / 2 - width / 2))
|
||||
const y = Math.max(0, Math.min(900 - height, box.y + box.height / 2 - height / 2))
|
||||
await shot.region('MapHoverCard', { x, y, width, height })
|
||||
})
|
||||
|
||||
test('plan: the export dialog', async ({ page, shot }) => {
|
||||
await page.getByRole('button', { name: 'Export' }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('ExportDialog', dialog(page))
|
||||
})
|
||||
|
||||
test('plan: the subscribe to calendar dialog', async ({ page, shot }) => {
|
||||
await page.getByRole('button', { name: 'Export' }).first().click()
|
||||
await dialog(page).getByRole('button', { name: /^Subscribe to calendar/ }).click()
|
||||
const subscribe = page.getByRole('dialog').filter({ hasText: 'Subscribe to calendar' }).last()
|
||||
await subscribe.getByRole('button', { name: 'Enable calendar subscription' }).click()
|
||||
// Enabled, the dialog shows the ways to subscribe and its Regenerate and Turn off.
|
||||
await expect(subscribe.getByRole('button', { name: 'Turn off' })).toBeVisible()
|
||||
await shot.element('IcsSubscribe', subscribe)
|
||||
await subscribe.getByRole('button', { name: 'Turn off' }).click()
|
||||
})
|
||||
|
||||
test('plan: the PDF preview', async ({ page, shot }) => {
|
||||
await page.getByRole('button', { name: 'Export' }).first().click()
|
||||
await dialog(page).getByRole('button', { name: /^PDF/ }).click()
|
||||
const preview = page.locator('#pdf-preview-overlay > div')
|
||||
await expect(preview).toBeVisible({ timeout: 30_000 })
|
||||
await parkPointer(page)
|
||||
// The document is a srcdoc frame; its photos and the route map load after it.
|
||||
await page.waitForTimeout(4000)
|
||||
await shot.element('PDFPreview', preview)
|
||||
await page.locator('#pdf-close-btn').click()
|
||||
})
|
||||
|
||||
// The Bookings tab holds a booking of every kind here (shots.setup.ts), so the
|
||||
// views are shown on it; each test puts the view back to the cards.
|
||||
async function bookingsView(page: Page, view: 'Cards' | 'List' | 'Timeline') {
|
||||
await openTab(page, 'Book')
|
||||
await page.getByRole('button', { name: view, exact: true }).first().click()
|
||||
await parkPointer(page)
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
test('bookings: list view', async ({ page, shot }) => {
|
||||
await bookingsView(page, 'List')
|
||||
await shot.page_('BookingsList')
|
||||
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
|
||||
})
|
||||
|
||||
test('bookings: timeline view', async ({ page, shot }) => {
|
||||
await bookingsView(page, 'Timeline')
|
||||
await shot.page_('BookingsTimeline')
|
||||
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
|
||||
})
|
||||
|
||||
test('bookings: the detail popup and the editor', async ({ page, shot }) => {
|
||||
await bookingsView(page, 'Cards')
|
||||
await page.locator('article').filter({ hasText: TOKYO_HOTEL }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
await shot.element('BookingDetail', dialog(page))
|
||||
await dialog(page).getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await page.waitForTimeout(800)
|
||||
await shot.element('BookingEditor', dialog(page))
|
||||
})
|
||||
|
||||
test('transports: timeline view', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await page.getByRole('button', { name: 'Timeline', exact: true }).first().click()
|
||||
await parkPointer(page)
|
||||
await page.waitForTimeout(800)
|
||||
await shot.page_('TransportsTimeline')
|
||||
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
|
||||
})
|
||||
|
||||
test('transports: the detail popup', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await page.getByRole('button', { name: 'Cards', exact: true }).first().click()
|
||||
await page.locator('article').filter({ hasText: 'LH716' }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
await shot.element('TransportDetail', dialog(page))
|
||||
})
|
||||
|
||||
test('transports: the add transport dialog', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await page.getByRole('button', { name: /^(Add transport|Transport)$/ }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('TransportEditor', dialog(page))
|
||||
})
|
||||
|
||||
test('costs: the add expense dialog', async ({ page, shot }) => {
|
||||
await openTab(page, 'Costs')
|
||||
await page.getByRole('button', { name: 'Add expense' }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('ExpenseDialog', dialog(page))
|
||||
})
|
||||
|
||||
test('costs: the table view', async ({ page, shot }) => {
|
||||
await openTab(page, 'Costs')
|
||||
await page.getByRole('button', { name: 'Table', exact: true }).click()
|
||||
await expect(page.getByRole('table')).toBeVisible()
|
||||
await parkPointer(page)
|
||||
await shot.page_('CostsTable')
|
||||
// Back to the list for every other picture of the tab.
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click()
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, clearNotices, seed } from './shot'
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Trip-planner tabs and dialogs.
|
||||
@@ -9,6 +11,9 @@ import { test, clearNotices, seed } from './shot'
|
||||
* current wiki ended up with screenshots the text contradicts.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
@@ -16,7 +21,7 @@ test.beforeEach(async ({ page }) => {
|
||||
})
|
||||
|
||||
async function openTab(page: import('@playwright/test').Page, label: string) {
|
||||
await page.getByRole('button', { name: new RegExp(`^${label}`) }).first().click()
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
@@ -30,16 +35,6 @@ test('lists — packing', async ({ page, shot }) => {
|
||||
await shot.page_('PackingList')
|
||||
})
|
||||
|
||||
test('lists: to-do with a task open', async ({ page, shot }) => {
|
||||
await openTab(page, 'Lists')
|
||||
await page.getByRole('button', { name: /^To-Do/ }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
await page.getByText('Activate JR Pass', { exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
await shot.page_('Todos')
|
||||
await page.getByRole('button', { name: /^Packing List/ }).first().click()
|
||||
})
|
||||
|
||||
test('transports', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await shot.page_('Transports')
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test as setup, expect } from '@playwright/test'
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { seedDemoData } from './seed'
|
||||
import { E2E_BASE_URL, E2E_SEED_FILE } from '../../playwright.config'
|
||||
|
||||
/**
|
||||
* Populates the throwaway E2E database with the demo trip before any screenshot
|
||||
@@ -18,7 +17,7 @@ setup('seed the demo trip', async ({ page, playwright }) => {
|
||||
// members — see the comment in seed.ts on why they must not share one.
|
||||
const result = await seedDemoData(page.request, token =>
|
||||
playwright.request.newContext({
|
||||
baseURL: E2E_BASE_URL,
|
||||
baseURL: 'http://localhost:5173',
|
||||
// MUST be explicit: newContext otherwise picks up the project's
|
||||
// storageState, i.e. the admin's trek_session cookie — and the server
|
||||
// reads the cookie BEFORE the Authorization header
|
||||
@@ -32,6 +31,7 @@ setup('seed the demo trip', async ({ page, playwright }) => {
|
||||
expect(result.tripId, 'trip was created').toBeTruthy()
|
||||
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
|
||||
|
||||
mkdirSync(path.join(process.cwd(), 'e2e', '.tmp'), { recursive: true })
|
||||
writeFileSync(path.join(process.cwd(), E2E_SEED_FILE), JSON.stringify(result, null, 2))
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from 'node:path'
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { at, day, ordinal, short } from '../dates'
|
||||
|
||||
/**
|
||||
* Demo data for the documentation screenshots.
|
||||
@@ -16,16 +15,15 @@ import { at, day, ordinal, short } from '../dates'
|
||||
* settle-up) — the reader would see nothing new.
|
||||
* - Two extra members exist so splits, avatars and sharing tiers render with
|
||||
* real names instead of a lonely single-user state.
|
||||
* - Dates are relative to the run day (e2e/dates.ts): the trip ends today, so
|
||||
* the running surfaces (boarding pass, What's Next, forecast, reminders) have
|
||||
* something to show whenever the seed runs.
|
||||
* - Dates sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
|
||||
* have something to show.
|
||||
*/
|
||||
|
||||
const TRIP = {
|
||||
title: 'Autumn in Japan',
|
||||
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
|
||||
start_date: day(-9),
|
||||
end_date: day(0),
|
||||
start_date: '2026-09-12',
|
||||
end_date: '2026-09-21',
|
||||
currency: 'JPY',
|
||||
reminder_days: 3,
|
||||
}
|
||||
@@ -66,15 +64,15 @@ const PLACES = [
|
||||
|
||||
const EXPENSES = [
|
||||
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
|
||||
expense_date: day(-9), note: 'Booked with miles, taxes only.' },
|
||||
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
|
||||
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
|
||||
expense_date: day(-6), note: '2 nights, kaiseki dinner included.' },
|
||||
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
|
||||
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
|
||||
expense_date: day(-9), note: 'Green car, activated on arrival.' },
|
||||
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
|
||||
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
|
||||
expense_date: day(-8) },
|
||||
expense_date: '2026-09-13' },
|
||||
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
|
||||
expense_date: day(-4) },
|
||||
expense_date: '2026-09-17' },
|
||||
]
|
||||
|
||||
const PACKING = [
|
||||
@@ -83,15 +81,10 @@ const PACKING = [
|
||||
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
|
||||
]
|
||||
|
||||
/** Chat lines the collab guides look for by their text. */
|
||||
export const SEED_CHAT = {
|
||||
teamlab: `Booked the teamLab slot for the ${ordinal(-8)}, 14:00. Tickets are in the Files tab.`,
|
||||
}
|
||||
|
||||
const TODOS = [
|
||||
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: day(-37), priority: 2 },
|
||||
{ name: 'Activate JR Pass', category: 'On arrival', due_date: day(-9), priority: 1 },
|
||||
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: day(-32) },
|
||||
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
|
||||
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
|
||||
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
|
||||
]
|
||||
|
||||
export interface SeedResult {
|
||||
@@ -219,8 +212,8 @@ export async function seedDemoData(
|
||||
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
|
||||
title: 'LH716 FRA → HND',
|
||||
type: 'flight',
|
||||
reservation_time: at(-9, '13:05'),
|
||||
reservation_end_time: at(-8, '08:25'),
|
||||
reservation_time: '2026-09-12T13:05:00',
|
||||
reservation_end_time: '2026-09-13T08:25:00',
|
||||
confirmation_number: 'X7K2QP',
|
||||
status: 'confirmed',
|
||||
location: 'Frankfurt Airport',
|
||||
@@ -229,10 +222,10 @@ export async function seedDemoData(
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
|
||||
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
|
||||
local_date: day(-9), local_time: '13:05' },
|
||||
local_date: '2026-09-12', local_time: '13:05' },
|
||||
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
|
||||
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
|
||||
local_date: day(-8), local_time: '08:25' },
|
||||
local_date: '2026-09-13', local_time: '08:25' },
|
||||
],
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -300,7 +293,7 @@ export async function seedDemoData(
|
||||
const pollRes = await api.post(`${collab}/polls`, {
|
||||
data: {
|
||||
question: 'Which day should we keep free for Nara?',
|
||||
options: [short(-5), short(-4), short(-2)],
|
||||
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
|
||||
multiple: false,
|
||||
},
|
||||
})
|
||||
@@ -315,13 +308,13 @@ export async function seedDemoData(
|
||||
}).catch(() => {})
|
||||
|
||||
const conversation: Array<[string, string]> = [
|
||||
['admin', `Flights are booked, we land at Haneda 08:25 on the ${ordinal(-8)}.`],
|
||||
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
|
||||
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
|
||||
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
|
||||
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
|
||||
['mira', SEED_CHAT.teamlab],
|
||||
['mira', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
|
||||
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
|
||||
['admin', "It's included: kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
|
||||
['admin', "It's included — kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
|
||||
]
|
||||
for (const [who, text] of conversation) {
|
||||
const ctx = who === 'admin' ? api : as(who)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { E2E_SEED_FILE } from '../../playwright.config'
|
||||
|
||||
/**
|
||||
* Shared plumbing for the documentation screenshot run (`npm run shots`).
|
||||
@@ -57,20 +56,6 @@ async function hideDevOnlyUi(page: Page): Promise<void> {
|
||||
|
||||
export { expect }
|
||||
|
||||
interface SeedIds { tripId: number; collectionId?: number | null; journeyId?: number | null }
|
||||
let seedCache: SeedIds | null = null
|
||||
/**
|
||||
* The ids the seed project wrote for this run's database. Read on first use, not
|
||||
* at import: Playwright loads every test file before the seed project has run,
|
||||
* and the file is named after the API port, so a run on its own ports reads its own.
|
||||
*/
|
||||
export const seed: SeedIds = new Proxy({} as SeedIds, {
|
||||
get: (_t, key: string) => {
|
||||
seedCache ??= JSON.parse(readFileSync(path.join(process.cwd(), E2E_SEED_FILE), 'utf8')) as SeedIds
|
||||
return seedCache[key as keyof SeedIds]
|
||||
},
|
||||
})
|
||||
|
||||
export class Shot {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -83,12 +68,6 @@ export class Shot {
|
||||
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
/** Capture a region of the page, for a detail with no element of its own (a card over the map). */
|
||||
async region(name: string, clip: { x: number; y: number; width: number; height: number }): Promise<void> {
|
||||
await this.settle()
|
||||
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`), clip })
|
||||
}
|
||||
|
||||
/** Capture one element — preferred for dialogs, panels and cards. */
|
||||
async element(name: string, target: Locator): Promise<void> {
|
||||
await this.settle()
|
||||
@@ -125,13 +104,6 @@ export class Shot {
|
||||
* but tolerant: on a seeded DB the notice may already be cleared.
|
||||
*/
|
||||
export async function clearNotices(page: Page): Promise<void> {
|
||||
// The "what's new" card of a release lays an overlay over the whole page that
|
||||
// takes every click; it may land a moment after the page, so give it that moment.
|
||||
const releaseClose = page.locator('.rn-close')
|
||||
if (await releaseClose.waitFor({ state: 'visible', timeout: 2_500 }).then(() => true, () => false)) {
|
||||
await releaseClose.click().catch(() => {})
|
||||
await page.locator('.rn-overlay').waitFor({ state: 'detached', timeout: 3_000 }).catch(() => {})
|
||||
}
|
||||
const next = page.getByRole('button', { name: /next/i })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled().catch(() => false))) break
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import {
|
||||
ensureBookingsFixtures, ensureCollabFixtures, ensureDayDetailFixtures, ensureFilesFixtures,
|
||||
ensureListsFixtures, ensureMapFixtures, ensureTransportFixtures,
|
||||
} from '../help/fixtures'
|
||||
|
||||
/**
|
||||
* The demo trip as the wiki pictures show it: the seed plus the fixtures the help
|
||||
* guides add, so the Bookings tab holds a booking of every kind, Files has
|
||||
* documents, Collab has links and What's Next something ahead, and the days carry
|
||||
* their stays and transports. Its own project between the seed and the capture:
|
||||
* the help-media run adds these per guide and has to start from the bare seed.
|
||||
*/
|
||||
setup('fill the demo trip for the wiki pictures', async ({ page }) => {
|
||||
await ensureDayDetailFixtures(page.request)
|
||||
await ensureBookingsFixtures(page.request)
|
||||
await ensureTransportFixtures(page.request)
|
||||
await ensureMapFixtures(page.request)
|
||||
await ensureFilesFixtures(page.request)
|
||||
await ensureListsFixtures(page.request)
|
||||
await ensureCollabFixtures(page.request)
|
||||
})
|
||||
@@ -12,10 +12,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
// The port is in the name, so two runs on two port pairs keep two databases
|
||||
// and can render different guide files at the same time.
|
||||
const apiPort = process.env.E2E_API_PORT || '3001'
|
||||
const dbFile = path.join(here, '.tmp', `e2e-${apiPort}.db`)
|
||||
const dbFile = path.join(here, '.tmp', 'e2e.db')
|
||||
const serverDir = path.join(here, '..', '..', 'server')
|
||||
|
||||
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||
@@ -23,18 +20,14 @@ for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||
}
|
||||
|
||||
// Build once (no watcher) — the resulting process is a single killable node.
|
||||
// Several runs on their own port pairs share one dist, so they build it once
|
||||
// beforehand and set E2E_SERVER_PREBUILT: a rebuild under a running server
|
||||
// swaps its files mid-flight.
|
||||
if (!process.env.E2E_SERVER_PREBUILT) execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
TREK_DB_FILE: dbFile,
|
||||
ADMIN_EMAIL: 'e2e@trek.local',
|
||||
ADMIN_PASSWORD: 'E2eTest12345!',
|
||||
// playwright.config.ts picks the port; 3001 is the historical default.
|
||||
PORT: process.env.E2E_API_PORT || '3001',
|
||||
PORT: '3001',
|
||||
NODE_ENV: 'development',
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
|
||||
before first paint AND complies with the prod CSP (script-src 'self'). -->
|
||||
<script src="/theme-boot.js"></script>
|
||||
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
|
||||
same reasons, and ahead of the entry, which it has to watch. -->
|
||||
<script src="/shell-guard.js"></script>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.3",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -23,87 +23,83 @@
|
||||
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
|
||||
"e2e": "playwright test",
|
||||
"shots": "playwright test --project=screenshots",
|
||||
"help:media": "node e2e/help/run.mjs",
|
||||
"help:media:promote": "node e2e/help/promote.mjs",
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.20.0",
|
||||
"dexie": "^4.4.6",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.5.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "^18.0.13",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.18.4",
|
||||
"react-router": "^7.18.2",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.6.5",
|
||||
"zustand": "^5.0.15"
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@testing-library/jest-dom": "^6.10.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^25.9.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.15.0",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.9.8",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"sharp": "^0.35.4",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,34 +9,6 @@ import { defineConfig, devices } from '@playwright/test'
|
||||
* /ws to the backend. Tests run serially against one worker so they share the
|
||||
* single seeded database deterministically.
|
||||
*/
|
||||
/**
|
||||
* Ports. The defaults are what `npm run e2e` has always used. `npm run
|
||||
* help:media` (e2e/help/run.mjs) sets E2E_WEB_PORT/E2E_API_PORT to a second
|
||||
* pair so it can record while `npm run dev` keeps serving 5173/3001 to whoever
|
||||
* is watching. Environment only, no argv sniffing: Playwright's workers load
|
||||
* this file again with different arguments, and every process must agree.
|
||||
* vite.config.js honours TREK_DEV_PORT and TREK_DEV_API for the same reason.
|
||||
*/
|
||||
const WEB_PORT = Number(process.env.E2E_WEB_PORT) || 5173
|
||||
const API_PORT = Number(process.env.E2E_API_PORT) || 3001
|
||||
export const E2E_BASE_URL = `http://localhost:${WEB_PORT}`
|
||||
/**
|
||||
* The session and the seed's ids belong to the database the run boots, which is
|
||||
* named after its API port (e2e/server-launch.mjs). Two runs on two port pairs
|
||||
* therefore share nothing, and the default port keeps the old file names.
|
||||
*/
|
||||
export const E2E_STATE_FILE = `e2e/.tmp/state-${API_PORT}.json`
|
||||
export const E2E_SEED_FILE = `e2e/.tmp/seed-${API_PORT}.json`
|
||||
|
||||
// The day the seed and the pictures are relative to (e2e/dates.ts). Fixed here
|
||||
// for every project and worker that loads this file, so a run that crosses
|
||||
// midnight still agrees with itself; `help:media` sets it before this runs.
|
||||
if (!process.env.E2E_PICTURE_DAY) {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
process.env.E2E_PICTURE_DAY = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
@@ -47,7 +19,7 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: E2E_BASE_URL,
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -60,7 +32,7 @@ export default defineConfig({
|
||||
name: 'app',
|
||||
testMatch: /\.spec\.ts/,
|
||||
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
|
||||
@@ -71,53 +43,18 @@ export default defineConfig({
|
||||
{
|
||||
name: 'seed',
|
||||
testMatch: /seed\.setup\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
// The wiki pictures show the demo trip filled in with the fixtures the help
|
||||
// guides use (a booking of every kind, documents, links); help-media adds
|
||||
// them guide by guide and so starts from the bare seed.
|
||||
{
|
||||
name: 'shot-fixtures',
|
||||
testMatch: /shots\.setup\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE },
|
||||
dependencies: ['seed'],
|
||||
},
|
||||
{
|
||||
name: 'screenshots',
|
||||
testMatch: /\.shot\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: E2E_STATE_FILE,
|
||||
storageState: 'e2e/.tmp/state.json',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
dependencies: ['shot-fixtures'],
|
||||
},
|
||||
// Help-center media (`npm run help:media`): pictures and walkthroughs for
|
||||
// the in-app guides, generated from the guide definitions in src/help/.
|
||||
// Same seed as the wiki screenshots; its own match so neither run pays
|
||||
// for the other. Serial and generous on time: a walkthrough is paced for
|
||||
// a human to follow.
|
||||
{
|
||||
name: 'help-media',
|
||||
testMatch: /\.guide\.ts/,
|
||||
// Four minutes. A guide is a whole task, and a few of them wait on the
|
||||
// network more than once: the nearby-places one runs two POI searches
|
||||
// against a public index and opens the place form on top of them, which
|
||||
// does not fit in the two and a half minutes this used to allow.
|
||||
timeout: 240_000,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
// The full Chrome for Testing build rather than the headless shell the
|
||||
// other projects run on. Only the full build carries the PDF viewer,
|
||||
// and the Files tab's preview embeds a PDF as <object>, which the
|
||||
// shell leaves as a "Download PDF" link. Still headless.
|
||||
channel: 'chromium',
|
||||
storageState: E2E_STATE_FILE,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
dependencies: ['seed'],
|
||||
},
|
||||
],
|
||||
@@ -126,8 +63,7 @@ export default defineConfig({
|
||||
// Always start our own backend (never reuse) so the isolated test DB is
|
||||
// reset + reseeded on every run, regardless of any stray dev server.
|
||||
command: 'node e2e/server-launch.mjs',
|
||||
port: API_PORT,
|
||||
env: { E2E_API_PORT: String(API_PORT) },
|
||||
port: 3001,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
@@ -135,8 +71,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: WEB_PORT,
|
||||
env: { TREK_DEV_PORT: String(WEB_PORT), TREK_DEV_API: `http://localhost:${API_PORT}` },
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-tower-control-icon lucide-tower-control">
|
||||
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
|
||||
<path d="M8 13v9" />
|
||||
<path d="M16 22v-9" />
|
||||
<path d="m9 6 1 7" />
|
||||
<path d="m15 6-1 7" />
|
||||
<path d="M12 6V2" />
|
||||
<path d="M13 2h-2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB |