Skip to content

test(e2e): local-backend Playwright suite for workplace, with advisory CI - #476

Open
camreeves wants to merge 12 commits into
developfrom
e2e/ci-verify
Open

test(e2e): local-backend Playwright suite for workplace, with advisory CI#476
camreeves wants to merge 12 commits into
developfrom
e2e/ci-verify

Conversation

@camreeves

@camreeves camreeves commented Jul 30, 2026

Copy link
Copy Markdown

Adds an end-to-end test suite for workplace that runs only against a local backend, the
isolated stack to run it against, and an advisory CI job on a self-hosted runner.

Nothing here gates merges. See Not a gate.

What's in it

e2e/support/ The engine: headless PKCE mint → storageState, a real-login driver, worker-scoped auth fixtures, idempotent API-driven seeding, stack preflight
e2e/stack/ A self-contained 10-service PlaceOS deployment — own compose project, own volumes, ports 9443/9080 — so it coexists with your own local stack and cannot disturb it
apps/workplace/e2e/local/ 3 specs: boot + org data, real login (scope and sub surviving refresh), a non-admin booking a desk through the full UI
E2E_USER_STORIES.md The coverage contract — what's covered, what isn't, and why
.github/workflows/e2e-advisory.yml Advisory CI on a self-hosted macOS runner
config/proxy.conf.js Only change to shared code — makes the dev-server proxy target env-driven. Defaults unchanged.
e2e/stack/up.sh                                  # backend, seeded, from nothing
export E2E_BACKEND_URL=https://localhost:9443
bunx playwright test --config apps/workplace/playwright.config.ts

7 passing, ~3 min in CI, ~26s locally. Verified green from a genuine cold start
(up.sh --fresh, volumes destroyed) and on the runner.

Local backend only

assertLocalOnly() throws at config load if E2E_BACKEND_URL/E2E_APP_URL resolve to anything
but a loopback host — an allowlist, not a prod blocklist, because a suite that creates and deletes
real data should not be one typo from doing it to a deployment.

The only surface needing anything external is room/calendar events, which is marked out of scope
and must never enter a gate. Desks, lockers, parking and visitors all work against a placeholder
tenant with no outbound calls.

Not a gate

Deliberately advisory until it has earned trust:

  • No pull_request trigger. This repo is public and the runner is self-hosted, so a fork PR
    could run arbitrary code on a machine on an internal network. Every trigger requires write access.
  • Not a required status check, and nothing in the workflow can make it one.
  • Cannot delay builds. build.yml runs on ubuntu-latest, a different runner pool, and
    Actions has no cross-workflow needs.
  • The job is named "advisory — does not block builds" so a red X beside the build jobs can't be
    misread.
  • Triggers are staged. Stage 1 (this PR): nightly + manual + e2e/** — nobody sees a new check
    on their commits. Stage 2, once the nightly has a record: add push: develop. Three-line change.

Note the nightly only starts once this is on developschedule fires from the default branch
only. That's what breaks the chicken-and-egg of proving it before merging it.

Two real bugs found, both filed

  • PPT-2642 — one burst of concurrent
    POST /bookings permanently poisons staff-api's connection pool; every later booking-create
    returns 500 until the service restarts. Reproducer kept at
    e2e/support/repro/reg09-concurrent-bookings.ts.
  • PPT-2643 — the booking form discards
    typed input while still initialising (title / All Day / Require locker revert).

Neither is fixed here: PPT-2642's likely fix is in pg-orm, shared by every service in a
transactional path; PPT-2643's fix embeds a UX decision in a service used by every booking flow.

⚠️ bookDeskViaUI works around PPT-2643, so this suite no longer detects it. When it's fixed,
remove the workaround and replace it with a spec asserting input survives init. Flagged in the
contract.

Review notes

Worth a look at config/proxy.conf.js (shared dev config) and the workflow. The rest is new files.

Things learned the hard way, all documented in code comments rather than lost:

  • GET /bookings is caller-scoped — an admin sees none of another user's bookings, so a leak
    check run as admin proves nothing.
  • The list query param is type, not booking_type.
  • Desks are Zone metadata, not systems.
  • A fresh authority gets a relative login_url, which ts-client resolves without the port —
    dead-ends the login redirect on any non-443 deployment. seed.ts patches it.
  • ES 7.17.6 crashes under cgroup v2; pinned to 7.17.28. Do not downgrade.
  • frontend-loader is required even though the dev server serves the SPA — it supplies /login.

🤖 Generated with Claude Code

camreeves and others added 11 commits July 30, 2026 11:46
Adds an end-to-end suite that runs only against a local PlaceOS backend, plus an
isolated stack to run it against and an advisory CI workflow.

- e2e/support: headless PKCE mint + storageState, real-login driver, worker-scoped
  auth fixtures, idempotent API-driven seeding, stack preflight
- e2e/stack: self-contained 10-service PlaceOS deployment on its own compose
  project and ports, so it coexists with a developer's own stack
- apps/workplace/e2e/local: boot, real login (incl. scope/sub surviving refresh),
  and a non-admin desk booking driven through the full UI
- E2E_USER_STORIES.md: the coverage contract
- config/proxy.conf.js: make the dev-server proxy target env-driven (defaults
  unchanged)

The suite refuses any non-loopback backend via an allowlist that throws before a
browser launches. CI is ADVISORY and must not gate merges until the suite has a
track record — see the CI section of E2E_USER_STORIES.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first GitHub Actions run failed at bring-up with a single line —
"container placeos-e2e-elastic-1 is unhealthy" — and no way to see why.

- Elasticsearch had `bootstrap.memory_lock: true`, which requires an unlimited
  memlock rlimit or ES refuses to boot and the container exits. Docker Desktop
  grants that by default, so it only failed in CI. Turned off (production tuning,
  worthless to a throwaway test stack) and the ulimits set either way.
- up.sh now dumps `compose ps` and logs for every service when bring-up fails, so
  the step output explains itself instead of needing an artifact. `set -E` is
  required for that ERR trap to be inherited by shell functions — verified by
  breaking a service on purpose.
- The workflow's log collection enumerated a hand-picked service list that did not
  include elastic, so the one log that mattered was the one not captured. It now
  collects every service plus `compose ps`.
- Bounded the health wait (`--wait-timeout 300`) so a stuck container fails
  clearly rather than running to the job timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.17.6 (what PlaceOS/local pins) bundles a JDK with the cgroup v2 NPE bug. Its
launcher dies in JvmOptionsParser -> DefaultSystemMemoryInfo with "Cannot invoke
CgroupInfo.getMountPoint() because anyController is null" before the JVM starts,
so no ES_JAVA_OPTS workaround is possible. GitHub runners use cgroup v2; Docker
Desktop's VM does not, which is why this only failed in CI.

Staying on the 7.x line keeps client compatibility with rest-api and search-ingest.
Verified locally on 7.17.28: search-ingest builds its indices, suite 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first green CI run was green but reported both desk specs as flaky — failing
on attempt 1 with "the confirm dialog did not open", passing on retry.

The booking form is rebuilt when its async initialisation finishes, and the
rebuild restores defaults: title -> "Booking", All Day -> off, Require locker ->
on. It is a RACE, not a step. On a warm run it lands before we touch anything; on
a cold one it lands mid-flow and silently discards our input. Locally that
surfaced as a booking under the wrong title; in CI as an unopenable confirm
dialog, because a reverted All Day leaves the default slot, which on a slow run
has already passed and makes the form invalid with no visible error.

Probing showed the values sometimes survive desk attachment and sometimes do not,
so re-ordering cannot fix it. Re-applying inside a retrying block converges
whenever the rebuild fires, without depending on an internal ready signal.

Note this mitigates a race in the app, it does not fix one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Log all five CI runs honestly, including that run 1's diagnosis was wrong and
  that run 3 was "green" while reporting two flaky specs.
- REG-09: note that DB::ConnectionLost has NOT been seen in CI, but CI runs 2
  workers vs 4 locally, so the quiet record may just be lower concurrency rather
  than the problem being absent.
- REG-10 (new): the booking form discards input while still initialising. A real
  user can hit this. bookDeskViaUI now works around it, which means the suite no
  longer detects it — so it needs a row of its own rather than being buried in a
  test helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- runs-on: [self-hosted, placeos-e2e]
- Drop the `pull_request` trigger. This repo is PUBLIC and the runner is a machine
  on an internal network, so a fork PR could run arbitrary code on it. Remaining
  triggers (schedule, dispatch, push to e2e/**) all require write access. Costs
  nothing today since the check is advisory and not a PR gate.
- Add a reclaim step: a self-hosted machine is not a fresh VM, and a previous
  aborted run can leave the stack up or port 4214 held.
- Drop the vm.max_map_count bump — required on GitHub-hosted Linux, meaningless on
  macOS where the value lives inside Docker Desktop's VM.
- Move the nightly to 01:10 UTC and note that `schedule` only fires from the
  default branch, so the track record does not start until this is on develop.
- Add e2e/stack/SELF_HOSTED_RUNNER.md: the runbook, led by the fact that no inbound
  access is needed, with a pre-flight connectivity check and the macOS gotchas
  (Docker Desktop needs a GUI session, sleep kills nightlies, service PATH).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Nobody needs to reach the runner: committers push to GitHub, the runner collects
  work over its own outbound connection. The machine can sit on a network most of
  the team cannot reach and CI still works for everyone.
- Setting up over SSH alone cannot complete two steps: Docker Desktop is a GUI app
  that will not start without an active GUI session, and svc.sh installs a launchd
  agent that belongs to one. Do those at the machine once, then SSH is fine.
- Troubleshooting: being unable to ping/SSH the box is expected (macOS Remote Login
  off by default, ICMP dropped, possible wifi client isolation) and unrelated to CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first self-hosted run spent 9.1 min uploading node_modules to GitHub's cache
and 3.1 min saving the bun cache, out of 19.2 min total — while the suite itself
took 1.0 min. actions/cache, setup-node and setup-bun are the right answer on a
hosted runner that starts from a bare VM; on a persistent machine the workspace and
toolchain are already present, so shipping them to a remote cache over a slow
uplink costs more than everything else combined.

Toolchain is now provisioned once on the machine (brew: colima, docker,
docker-compose, bun, node@24), all resolvable from the runner service PATH.

Also:
- Fix a real concurrency bug: a scheduled run and a develop push share
  github.ref, so with a ref-only group plus cancel-in-progress a push would have
  CANCELLED an in-flight nightly, destroying the run the track record depends on.
  Keyed on github.event_name as well.
- Name the job "advisory — does not block builds": the job name is what appears in
  the Checks list beside the build jobs, so a red X cannot be misread.
- Stage the trigger rollout. `develop` is deliberately NOT enabled yet — it is the
  high-value trigger but it also puts a new check on everyone's commits, which
  should be earned once the nightly has a record. Three-line change when ready.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both findings investigated to root cause and filed against PPT, assigned to Cam.

PPT-2642 (REG-09) — worse than the flake suggested. One burst of concurrent
POST /bookings permanently poisons staff-api's connection pool; every later
booking-create returns 500 with "There is an existing transaction in this
connection" until the service restarts. Verified: serial requests are always clean
(including 409/422 raised inside the transaction), a concurrent burst looks healthy
itself, and everything after it fails. Reproducer kept at
e2e/support/repro/reg09-concurrent-bookings.ts rather than only in the ticket.

PPT-2643 (REG-10) — booking-form.service.ts newForm() defers itself until the
current user loads, then resets the form, discarding anything typed in the meantime.
One race, two symptoms: locally it ate the title, in CI it ate All Day (leaving the
default 5-minute slot, already past on a slow run, silently invalidating the form).

Neither is a PR: REG-09's likely fix is in pg-orm, shared by every PlaceOS service
in a transactional path; REG-10's fix embeds a UX decision (preserve dirty input vs
gate the form until ready) in a service used by every booking flow.

Noted in the contract that the suite no longer detects REG-10 — bookDeskViaUI works
around it — so the workaround must be removed when it is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runbook described the setup I planned, not the one that exists. Rewritten from
what actually works, with the corrections that cost time:

- Colima, not Docker Desktop (no GUI session, startable over SSH)
- brew's docker-compose needs cliPluginsExtraDirs or `docker compose` never resolves
- osx-x64 (the machine is Intel), /usr/local not /opt/homebrew
- svc.sh is generated by config.sh, not shipped in the tarball
- a slow link looks exactly like a blocked host — retry before blaming the network
- records the measured 2.8 min job time and why remote caching was removed
- documents the reboot limitation honestly rather than implying it is handled

Also fix the REG-09 reproducer's import, broken when it moved into
e2e/support/repro/ — verified it runs from the repo root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught reviewing my own diff — the README still attributed vm.max_map_count to
Docker Desktop's VM after the switch to Colima.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/placeos?upgradeToPro=build-rate-limit

@camreeves
camreeves requested review from MrYuion and stakach July 30, 2026 05:13
A nightly result nobody sees is not a result. Adds a failure notification using
the same chat integration PR Flow already uses, so there is nothing new to
configure — repoint CHAT_URL to change the recipient.

Only fires on failure. A green run stays silent, or the signal gets tuned out
within a fortnight. continue-on-error so a broken webhook can never turn a green
run red.

Flaky runs pass by conclusion, so nothing notifies. The step summary now says so
explicitly instead — that is the number that tells us whether the suite can be
trusted yet, and the easiest one to quietly stop looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
frontend-templates Ignored Ignored Preview Jul 31, 2026 3:49am

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant