# IPZ E2E

Audience: AI coding agents first.

## Canonical rule

This file is the canonical `international-press-zone` E2E contract. Load it for every browser,
smoke, screenshot, visual, or diagnostic browser run. Update this file and
`.claude/commands/ipz-e2e.md` in the same change whenever the E2E topology, wrapper, stack,
fixtures, credentials, readiness, command, artifact path, or safety policy changes.

## Fail-closed always-remote invariant

- ALWAYS execute remotely. No workstation fallback. No local build/browser/server half.
- Treat every `/ipz-e2e` request as remote-only. Offload the complete workload to one authorized
  Debian buildbox: every dependency install, snapshot build, admin build, WordPress/MariaDB stack,
  readiness check, Playwright/Chromium/Firefox run, retry, focused test, full test, smoke, screenshot,
  visual capture, and diagnostic browser run.
- Invoke only `~/.claude/bin/e2e-remote`. It must resolve `~/.claude/buildbox-hosts.json` and the
  `e2e`/`build` spill order. The registered E2E hosts are exactly `debian1`/`debian2`/`debian3`
  (`debian1/debian2/debian3`); reject any other receiver. Let the wrapper select and spill across
  that registry order.
- Omit ad-hoc host selection for normal runs. A host may be pinned only for the documented
  per-host snapshot repair, through `e2e-remote` and a name emitted by the registry. Never bypass
  the wrapper with direct SSH, `scp`, `rsync`, a hardcoded host address, or a second transport.
- Treat the workstation as a caller and artifact receiver only. Do not install dependencies, build,
  start WordPress/Podman/a server, launch a browser, run Playwright, make a WordPress smoke request,
  capture a screenshot, or retry an E2E failure on the workstation.
- Fail closed if the wrapper, registry, required host order, remote prerequisite, or remote run is
  unavailable. Never fall back to the workstation, a local browser/server pair, or another host.
- Keep the server and browser on the same buildbox. Keep `dev1` deployment-only; NEVER test, smoke,
  screenshot, or diagnose `dev1.danzigeronline.com`, `dev3.press.zone`, or any production/client
  WordPress.

Reject these examples immediately:

- `npm ci`, `npm install`, `npm run build`, `pnpm`, `npx playwright`, `playwright install`, or a
  browser command run from the workstation.
- Local `remote-stack.sh`, `podman`, `docker compose`, `wp`, `php -S`, or a workstation `curl`
  smoke/diagnostic request.
- `ssh debian2 ...`, `scp ...`, `rsync ...`, a literal buildbox IP, or `--hosts debian2` on a normal
  run. Use the wrapper's registry-driven spill logic.
- Retrying locally after a remote failure, using a proxy/tunnel, or targeting `dev1`, `dev3`, or a
  public WordPress URL.

## Canonical wrappers and command

0. **Green means landed.** For a gate run whose purpose is to validate a branch for delivery, the
   ONLY entrypoint is `tests/e2e/gate-and-land.sh`: it runs the full remote gate and, on pass,
   lands HEAD on `origin/master` as the tail of the same transaction. It prints `green` only after
   the push succeeds, and its receipt (`IPZ_GATE_RECEIPT=<path>`) carries `landed_master_sha` — a
   receipt without that key is incomplete and MUST NOT be reported as green. Any land failure exits
   red with `NOT DONE — merge blocked: <reason>`; report that as a failed run, never as "tests
   passed". No agent may declare a delivery gate green from a run that skipped the land tail.
   Focused/diagnostic/smoke runs (steps 2–3 below) are not delivery gates and never claim "green".
1. Start from the plugin root.
2. Prefer `tests/e2e/run-remote.sh` for its complete remote stack and visual/capture sequence. It
   must remain a thin wrapper around the installed `e2e-remote`; do not copy its inner commands to
   the workstation.
3. Use `~/.claude/bin/e2e-remote` directly for focused, full, smoke, retry, or diagnostic runs.
4. Let `e2e-remote` choose the receiver from the registry. Do not add `--hosts` to a normal run.
5. Run Chromium and Firefox. NEVER skip either browser.

```bash
# plugin root; the whole server/client pair runs on one registry-selected buildbox
~/.claude/bin/e2e-remote \
  --server "tests/e2e/remote-stack.sh" \
  --wait-port 8080 \
  --env WP_BASE_URL=http://127.0.0.1:8080 \
  -- /home/user/.local/share/mise/installs/pnpm/11.5.2/pnpm \
    --dir tests/e2e \
    exec playwright test --config=playwright.config.js <focused-spec> \
    --project=chromium --project=firefox
```

Replace `<focused-spec>` with an existing spec path relative to `tests/e2e`. Use the exact absolute
pnpm path, `--dir tests/e2e`, and `--config=playwright.config.js`. Never use `sh -lc`, Bun, `cd`,
`npx`, `npm exec`, or `tests/e2e/playwright.config.js` in this client seam. Update the pnpm
path/version when the environment changes. The stack resolves absolute `npm` and `node` paths with
`command -v`, invokes resolved `npm` directly, requires `admin/package-lock.json`, and uses `npm ci`,
not `npm install`. Do not pin npm/node versions unless both this contract and the stack enforce the
same version.

## Yoast compatibility gate

`tests/e2e/compatibility/with-yoast.sh` is a first-class, expected gate. It installs a pinned
Yoast SEO release into the running remote stack and proves the `international-press-zone` /
Yoast integration does not fatal. It is mandatory for any change touching
`includes/Compatibility/` or any `wpseo_*`/third-party-SEO filter handler.

```bash
# plugin root; run once per browser, sequentially, never in parallel
~/.claude/bin/e2e-remote \
  --server "tests/e2e/remote-stack.sh" \
  --wait-port 8080 \
  --env WP_BASE_URL=http://127.0.0.1:8080 \
  --env IPZ_E2E_RUN_ID=<unique-run-id> \
  --key <dedupe-key> \
  -- tests/e2e/compatibility/with-yoast.sh <chromium|firefox>
```

- `with-yoast.sh` takes exactly one browser project per invocation. Satisfying the mandatory
  chromium-AND-firefox rule takes two invocations of the command above.
- Run those two invocations sequentially, never in parallel. Concurrent `e2e-remote` runs can land
  on the same buildbox and collide on port `8080`; a parallel second run fails with
  `e2e-remote: FATAL port 8080 already in use on debian1`. The fleet may instead spill the run to
  the next host (`fleet-seat: debian1 unavailable (exit 83); spilling to next host`) and still pass,
  so a green result does not prove the parallel invocation was safe. Give each invocation its own
  distinct `IPZ_E2E_RUN_ID`, since the run id names the podman containers, network, and ownership
  label.
- `IPZ_E2E_RUN_ID` is required for this client: `with-yoast.sh` hard-fails without it, because the
  client must target the same disposable stack the server started.
- Yoast version is pinned by `IPZ_COMPAT_YOAST_VERSION` (default `25.6`). The script installs it
  into the running stack via a WP-CLI container using `--volumes-from` the WordPress container.

**Lesson that would have prevented the last Yoast regression:** Yoast passes generator OBJECTS to
`wpseo_schema_graph_pieces` and associative ARRAYS to `wpseo_schema_graph`. Registering an
array-shaped callback on the pieces filter is a guaranteed site-wide fatal.

## Snapshot prerequisite

`remote-stack.sh` boots from a prebuilt base snapshot and refuses to start without a compatible one.
Snapshot identity is the fingerprint in `tests/e2e/e2e-snapshot-contract.sh` (contract version,
WordPress image, MariaDB image, PHP floor, ACF version, fixture version). It lives per buildbox user
at `${IPZ_E2E_SNAPSHOT_ROOT:-$HOME/.cache/presszone/e2e-snapshots}/<fingerprint>`.

Missing or incompatible snapshot stops the server before port `8080` and prints
`E2E snapshot is missing or incompatible`; `e2e-remote` reports exit `4`. Build it on the receiving
buildbox, then rerun the canonical command:

```bash
# plugin root; <box> must come from the registry e2e order, never from a hand-written address
~/.claude/bin/e2e-remote --hosts <box> -- tests/e2e/build-e2e-snapshot.sh
```

Repeat through `e2e-remote` for every host emitted by `~/.claude/lib/buildbox-registry.mjs hosts
--order e2e`. Never run `build-e2e-snapshot.sh` directly on the workstation. The snapshot is a
per-host cache; every registered E2E host must hold the current fingerprint before an unpinned run.
Snapshots for `debian1`, `debian2`, and `debian3` were built on 2026-08-14 at fingerprint
`4367747ddb71d2d6d883129f8496782e5b34c4fe6876e6e9f9de0c606ac560d2`. Any contract-fingerprint
change invalidates every snapshot; rebuild every host in registry order through the wrapper.

## Topology and safety

`tests/e2e/remote-stack.sh` runs isolated MariaDB, WordPress, official wordpress.org ACF,
deterministic fixture, and candidate plugin on the same authorized buildbox as Playwright. The stack
binds only `127.0.0.1:8080` on that buildbox.

- Use only `http://127.0.0.1:8080` as the browser target inside the remote workload. Playwright
  configuration and global setup must resolve that same target when `WP_BASE_URL` is omitted; a
  `localhost` fallback changes the cookie host while WordPress posts to `127.0.0.1` and makes a valid
  browser report that cookies are blocked.
- Never use a workstation proxy, local browser/dev-server pair, or production/client WordPress.
- Target safety permits loopback or exact dedicated-host allowlist entries only. Reject wildcards,
  suffixes, broad domains, empty hosts, and bypass settings.
- Fixture graph assertions must prove the exact repeater/flexible metadata consumed by IPZ. This
  contract does not claim ACF Pro input rendering.

## Lifecycle and readiness

`e2e-remote` owns the server lifecycle. It launches `tests/e2e/remote-stack.sh`, waits for port
`8080`, and then starts Playwright on the same buildbox. Keep the server in the foreground until the
client ends. Port `8080` stays withheld until every readiness gate passes:

- candidate `admin/package-lock.json`, exact `npm ci`, candidate `npm run build`, and valid
  `admin/dist/asset-manifest.json` with required existing `main` and `editor` assets;
- DB, WordPress core, `wp-config.php`, install, deterministic fixture, ACF activation, candidate
  plugin activation, and fixture graph assertions;
- WP-CLI `/%postname%/` provisioning with writable Apache `.htaccess`, rewrite flush, and REST JSON
  routing smoke.

Fresh stacks must provision `/%postname%/` before delayed port publication. `remote-stack.sh` must
use WP-CLI to create writable `/var/www/html/.htaccess`, set the postname structure, and flush
rewrite rules. Before publication, `/wp-json/` must return `application/json` and JSON through Apache
routing. HTML from `/wp-json/` is rewrite provisioning failure, not a product JSON bug. If a target
plugin route requires authentication, smoke the WordPress `/wp-json/` index plus rewrite routing;
do not weaken the gate with authenticated client state.

The stack owns an isolated Podman runtime. `8080` is a one-capacity slot per buildbox user: acquire
the nonblocking per-user `flock` before any Podman network, volume, or container mutation. Lock
failure must report conflict and exit before creating resources. Never claim concurrent runs can
execute in parallel.

Each run must generate a unique sanitized resource identity and apply the exact label
`io.presszone.ipz-e2e.run=<RUN_ID>` to every created container, network, and volume. After the fixed-
port lock and before every Podman mutation, inventory exact target names and the exact ownership-label
namespace. Any target-name collision, label collision, or inventory-query failure must fail closed
with zero deletion and no cleanup trap. This includes a caller-reused `IPZ_E2E_RUN_ID`; preserve
existing resources.

Cleanup must inventory resources by the exact current-invocation label before deletion, delete only
names returned by that inventory, then re-inventory the exact label namespace. Never remove
caller-derived names without inventory proof. Every inventory, removal, or surviving-resource failure
must make cleanup and final process exit nonzero. Do not inspect, delete, attach to, retain, or reuse
another run's resources.

The fixture is required. Never mark a missing fixture skipped, expected, or non-blocking. Fix the
fixture or stack before any test retry.

## Evidence gates

- Run the focused spec in Chromium and Firefox; require zero skipped tests in both.
- Record exact discovered and executed counts. Do not use approximate, aggregate-only, or omitted
  counts.
- Keep trace, screenshot, and video in `tests/e2e/test-results/`.
- Keep HTML in distinct `tests/e2e/playwright-report/`; it must not equal the raw-artifact directory.
- Keep JUnit at `tests/e2e/test-results/junit.xml`.
- Resolve runtime warnings, console errors, page errors, and server errors. Require clean runtime
  output.
- Confirm cleanup found zero containers, networks, and volumes with the current-run label.
- A real stack smoke passed on 2026-08-11 through `e2e-remote` on an authorized buildbox: ready after
  32s; login, ACF activation, and candidate activation passed. Do not claim a focused browser
  journey passed from that smoke.

## Failure ladder

Stop at the first failing rung. Preserve the remote topology. Do not proxy around it.

1. **Concurrent port conflict:** fail without resource mutation. Preserve the other run; wait for
   owner cleanup, verify no current-run resources exist, then rerun the canonical command.
2. **Missing/incompatible snapshot (exit 4):** build it on the receiving host through the documented
   `e2e-remote --hosts <box> -- tests/e2e/build-e2e-snapshot.sh` repair, then rerun. Never edit the
   fingerprint to match a stale snapshot.
3. **Server fails before port:** inspect remote output and fix stack prerequisites/readiness only:
   plugin root, resolved npm/node, `admin/package-lock.json`, successful `npm ci`/`npm run build`,
   required manifest assets, Podman, fixture, candidate entrypoint/activation, fixture graph,
   permalink, `.htaccess`, and `/wp-json/` JSON smoke. Never build locally.
4. **`No tests found`:** preserve the exact client seam and verify the spec exists under `tests/e2e`.
5. **Zero group disclosures with valid REST fixture:** first prove the remote candidate manifest,
   assets, browser-loaded bundle, and console output. Never treat fixture REST success as SPA-build
   proof or add a conditional pass.
6. **Missing/invalid admin manifest/assets:** fail before WordPress mount. Fix remote dependencies,
   lockfile, or build. Never locally build or commit generated `admin/dist`.
7. **Skip, fixture, graph, or REST routing failure:** fail. Restore/fix the deterministic fixture or
   routing provisioning. Never add a skip, conditional pass, alternate host, or proxy.
8. **Runtime failure:** collect remote `test-results`, `playwright-report`, trace, screenshot, video,
   JUnit, browser output, and server output; fix source or test contract; rerun both browsers through
   the canonical wrapper.

## Receipt

Record: exact command; `e2e-remote` use; registry-selected buildbox name; server script; target;
focused spec; Chromium result; Firefox result; exact discovered/executed/skipped counts; artifact
paths; runtime-warning status; unique run identity; port-conflict status; permalink/rewrite/REST
JSON smoke result; cleanup result; and commit SHA. State failures and unresolved blockers exactly.
