git:20260929.2056c37 to git:20261014.2e7bbe8

12 added, 10 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
+ - `e2e/demo-next` (`e2e:demo-next-real`) — flagship Playwright consumer.
+ - `e2e/console` (`e2e:console-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
+ - `e2e/journey` — the product journey imports kit helpers as a
+ workspace dep; its testkit variant 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` uses
`handle.projectSecret` server-side only, to seed and to grant the dev user
access; the console itself signs in with that user's session cookie (#1300).
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).
+ - Dogfood: `moon run e2e:demo-next-real`, `moon run e2e:console-real`,
+ `moon run e2e:journey-local` (runs the testkit variant among others).
- 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.
+ The real-instance lanes are dependencies of `e2e:all`, which
+ `.github/workflows/ci.yml` runs in its own e2e step after the build-and-test
+ graph, so moon still orders them through the DAG. They are `runInCI: "skip"`
+ only to stay out of that graph, where they would compete with the unit suites;
+ `:test` alone stays unit tests. Run one locally with `moon run e2e:<lane>`, or
+ all of them with `moon run e2e:all`.
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.