AGENTS.md@packages/testing · diff
git:20260813.87827b7 to git:20260819.401655b
45 added, 0 removed. Audit A to A.
# testing Agent Notes
Scoped instructions for `packages/testing/`. Read together with the
[root `AGENTS.md`](../../AGENTS.md).
## What's in here
`@zitadel/testing` is the customer-facing test kit — "Testcontainers for
Zitadel": it boots a real local instance (`startLocalZitadel` /
`connectZitadel`), orchestrates instance + app for Playwright
(`withZitadel`), seeds users and sessions (`seed.*`, `authenticatedPage`),
completes passkey ceremonies headlessly (`enableVirtualPasskey`), and
drives the `<zitadel-login>` widget through whole auth ceremonies
(`loginWithPassword`, `registerWithPasskey`, …, with `flowAction` /
`flowField` as escape hatches).
In-repo consumers, which double as the dogfood proof:
- `apps/demo-next-e2e` (`e2e-real`) — flagship Playwright consumer.
- `apps/console-e2e` (`e2e-real`) and `console:dev-real` — kit as suite
bootstrapper and as dev-loop seeder.
- `apps/cli-journey-e2e` — the product journey imports kit helpers as a
workspace dep; the `e2e-testkit` lane installs the kit **from the local
registry** exactly as a customer would. Breaking the customer install
path shows up there, not in unit tests.
## It is published — act like it
On the release train since #692: listed in `PUBLIC_RELEASE_PACKAGES`
([`scripts/release-manifest.mjs`](../../scripts/release-manifest.mjs)) and in
the changesets `fixed` group — the two lists must match **in order**
(enforced by `apps/cli/tests/unit/scripts/release-manifest.test.ts`).
Consequences:
- **Every observable change needs a changeset** (minor for new API surface),
and PR titles follow the outcome-based decision table in
[`.changeset/README.md`](../../.changeset/README.md). The kit-specific
trap: don't reach for `test:` because the package is *about* testing —
it is shipped product surface, so a new capability is `feat` and a
repair is `fix`.
- **The `alpha` dist-tag is intentional.** The train publishes in changesets
pre mode, so releases land on `npm i @zitadel/testing@alpha`; `latest`
deliberately sits at the `0.0.0` bootstrap placeholder until pre mode
ends. Do not "fix" the tag, and do not flag it in review.
- `build-release` (in `moon.yml`) precleans via `release-clean.mjs` and
rebuilds; it is registered in `tools/release/moon.yml`'s
`build-public-packages` aggregate and `release:check-graph` enforces
that registration. It needs no mutex: nothing in the release graph reads
this package's dist, task deps alone order it.
## Build shape: dual ESM+CJS, and why the CJS bundles workspace deps
Playwright's Babel `require()` hook transpiles workspace-realpath ESM to
CJS while Node still evaluates the real `.mjs` as ESM — the require chain
must stay CJS end-to-end. So `tsdown.config.ts` builds twice: ESM with all
workspace deps external, CJS with `@zitadel/api` + `@zitadel/config`
**bundled in** (`deps.alwaysBundle`; the deprecated `noExternal` silently
loses to auto-external). `@zitadel/cli` stays external in both — it is a
regular published dependency resolved at install time. If you add a
workspace runtime dep, decide its side of that line explicitly.
`@playwright/test` is an **optional peer** (`>=1.60`): only the
`./playwright` entry may import it. Never let it leak into `./` — the
vitest/backend surface must work without Playwright installed.
## Behavior contracts locked by unit tests
These encode externally-proven behavior; the unit suites pin them so a
refactor cannot drift silently. Change the test only when the external
fact changes:
- **Passkey CDP profile** (`src/passkey.ts`): the exact
`WebAuthn.addVirtualAuthenticator` options are the journey-CI-proven
profile (ctap2, internal, resident keys, user verification, automatic
presence). Changing any flag changes which ceremonies complete. The
authenticator is **page-bound** — registration and the later login must
run on the same page; sign out via `context().clearCookies()`, never a
fresh context. RP-ID rule: serve the app on HTTPS or `http://localhost`;
raw IP origins are invalid.
- **Flow ceremonies** (`src/flows.ts`): the locator ladders are the
widget's documented automation-hook contract
(`packages/components/README.md`), locked by descriptor-equality unit
tests. Ceremonies branch only on **widget-observable state** (which
fields/actions render), never on app state — customer flow configs
legitimately vary. Broad fallbacks (accessible names, labels, bare
`data-action`) stay scoped to the `<zitadel-login>` host: `.or()` unions
resolve in page-wide DOM order, so an unscoped candidate could match app
chrome; the host also exists for custom templates that emit no hooks.
Free-form action names are escaped per the CSSOM string-serialization
rules before attribute-selector interpolation (#715), so an exotic name
cannot break the union's parsing.
- **Session-mint driver** (`src/session.ts`): the flow is stateless via
the sealed `_zflow` cookie (raw `fetch` + one-cookie jar — the typed
client hides response headers), submits must send an `Origin` on the
project allowlist, and `/sessions/me` authenticates via the session
cookie, not bearer.
- The flow-step shape itself is owned by
[`packages/config/defaults/default-login.json`](../config/defaults/default-login.json).
If a kit helper needs a shape the definition does not declare, the
definition changes first (same rule as `packages/api-mock`).
+ ## Credential contract (root ADR 053 §9)
+
+ Predictable test/dev credentials are a property of **this kit**, never of the
+ server or the production seed contract. The kit boots the process and applies
+ seeds, so it captures credentials from provisioning output (`POST /projects`
+ returns `project_secret` exactly once) and exposes them on `InstanceHandle`:
+ `projectSecret` today; the `platform` slot (`PlatformCredentials` — reserved
+ project id + publishable key, the pair the ADR itself guarantees) once the
+ platform-project provisioner lands. Root
+ [ADR 053 §9](../../docs/adrs/053-cross-project-principals.md) (Proposed)
+ sets the rule: test infrastructure gets its credentials from the testkit's
+ boot contract, not from seed defaults — paraphrased here on purpose; §9's
+ exact sentence lives in the ADR, so a reword there doesn't strand this file.
+ [Console ADR 0004 §2](../../apps/console/docs/adrs/0004-console-deployment-modes.md)
+ names the testkit as the bootstrap transport test and dev infrastructure
+ use. While the ADR remains Proposed, treat the slot as tracking it — the
+ slot is inert, so a change there moves this shape, not shipped behavior.
+
+ Consequences for changes here:
+
+ - Never add — or lean on — a server `--test-mode` flag, a seed-default
+ credential, or any other server-side door that mints deterministic
+ credentials. If the kit cannot capture a credential at provisioning time,
+ extend the provisioning contract the boot path drives (Console ADR 0004 §2
+ makes the testkit a transport of that seed input) — not the server
+ surface, and not direct database writes: the kit has no storage boundary
+ (it shells out to the CLI and provisions over HTTP), raw writes would
+ bypass the full provisioner, and seed ops must stay meaningful for
+ `connectZitadel` targets (see the Don't list).
+ - New credentials extend `InstanceHandle` — the `platform` slot is the
+ contract point; don't invent a parallel channel. The handle must stay
+ serializable: it crosses process boundaries via the handshake file, and
+ `AppEnvTemplate` deliberately reaches only its flat string fields.
+ - Declare a credential field only when its design is settled: additions are
+ churn-free minors, removals are not, so deferred credentials (the platform
+ automation principal, a boot-minted operator session) join as optional
+ fields when their ADRs land — never pre-frozen, and never with an invented
+ wire prefix in code or fixtures. Serialize only non-derivable state: a
+ session cookie is rebuilt from its token (`SESSION_COOKIE_NAME` +
+ constants), so a future serialized session is `{ sessionToken, expiresAt }`,
+ not a stored cookie object.
+ - Dev loops feed from boot output: `console:dev-real` threads
+ `handle.projectSecret` into the Vite proxy env itself (and prints it under
+ `--seed-only`). Don't reintroduce developer-remembered credential env vars.
+
## Moon layering
The layer is `tool`, not `library` — deliberately between the e2e suites
(`automation`, which may not depend on `automation`) and the CLI (`tool`,
which `library` may not depend on). Consumers add `dependsOn: testing` and
a `testing:build` task dep.
## Validation lanes
- `moon run testing:test testing:lint testing:typecheck` — unit gate.
- `moon run testing:test-integration` — boots real instances
(binary + SQLite local default).
- Dogfood: `moon run demo-next-e2e:e2e-real`, `moon run console-e2e:e2e-real`,
`env -u CI moon run cli-journey-e2e:e2e-testkit` (customer install path).
The real-instance lanes carry `runInCI: false`, but that only keeps them
out of moon's automatic CI selection — `full-pr` runs all of them through
explicit `env -u CI` workflow steps (`.github/workflows/ci.yml`). Locally,
use the same `env -u CI` incantation.
Serialize moon invocations — two concurrent graphs produce spurious
failures. Orphaned runtimes from aborted e2e runs squat fixed ports;
`moon run workspace:cli -- stop --all` sweeps them.
## Don't
- Don't make seed ops instance-specific. Every seed op must stay
meaningful for `connectZitadel` targets (remote dev instances), not just
locally-booted ones — the README positions the local runtime as the
shipped core and remote mode as roadmap, and seed ops must not foreclose
that direction.
- Don't publish-adjacent by hand: no version edits, no dist-tag changes,
no manual `npm publish`. The train owns all of it (the one-time name
bootstrap already happened).
- Don't remove the `LICENSE` file or `publishConfig.access` — published
sibling parity, checked in review not by tooling. The package is listed
in the root [`LICENSING.md`](../../LICENSING.md) MIT exceptions; keep
the `license` field, the `LICENSE` file, and that list in agreement.
- Don't reach for `page.evaluate`-style shortcuts in helpers that model
customer usage; helpers must only do what a customer's test could do
with the public product surface.