git:20261019.7f697da to git:20261020.8d47d7a

90 added, 170 removed. Audit A to A.

- # Test Instructions — `apps/cli/tests`
-
- These instructions apply to `apps/cli/tests/**`. Defer to [`../AGENTS.md`](../AGENTS.md)
- for package rules and the root `AGENTS.md` for repository-wide ones.
-
- ## There are exactly two kinds of test
-
- Every file under `tests/` is a unit test or a spec. Nothing else. A test that is
- neither does not get written — it gets reclassified until it is one of them.
-
- ### Unit tests — `tests/unit/**`
-
- A unit test imports from `src/` (or reads a checked-in file) and asserts on what
- it imported. It mirrors the layout of `src/`, so `src/lib/idp/credentials.ts` is
- covered by `tests/unit/lib/idp/credentials.test.ts`. Where a directory is one
- cohesive unit, a single file may cover it — `tests/unit/commands/doctor/checks.test.ts`
- covers every check — but the mirror path must still be obvious from the name.
-
- Doubles are fine here. **Unit tests own all the detail**: wording of guidance and
- errors, key ordering, file classification, renderer output, validation messages,
- id prefixes, envelope fields. If a behaviour can be asserted by importing a
- module, that is where it belongs, and a spec must not assert it again.
-
- ### Specs — `tests/integration/**`
-
- A spec drives the built CLI through `runCliForTest` against
- `@zitadel/api-mock`'s platform handlers, and asserts only what a developer could
- observe afterwards.
+ # Agent Instructions — `apps/cli/tests`
- **One spec file per command, named after the command.** `setup.spec.ts`,
- `plan.spec.ts`, `auth-method-sso-enable.spec.ts`, `variables-set.spec.ts`. A
- command's spec is the only spec that drives that command as its subject. There
- are no cross-command spec files, no "contract" files and no "round-trip" files.
+ Package rules: [`../AGENTS.md`](../AGENTS.md).
- A property that must hold for *every* command belongs in the fixture, not in a
- suite of its own. The envelope contract works this way: `ScaffoldedApp` checks
- it on every `--json` invocation, so every spec gets it for free and a
- regression fails inside the spec of the command that broke. A dedicated
- contract suite only ever covers the commands somebody remembered to list, and
- reports the failure far from its cause.
+ ## Two kinds of test
- A topic's commands are separate commands: `variables set` is
- `variables-set.spec.ts`, not a suite inside a shared file, so coverage is
- discoverable by filename. The binary's own behaviour — an unknown command,
- which server it resolves — is `cli.spec.ts`.
+ Every file here is a unit test or a spec. A test that is neither gets
+ reclassified until it is one.
- The generated resource commands (`users:list`, `teams:get`, …) come from one
- factory in `src/lib/oclif/crud/`. They are one spec — `resources.spec.ts` —
- not one per generated command. The factory's behaviour is unit-tested; the
- spec proves it is wired up.
+ - **Unit tests** (`tests/unit/**/*.test.ts`) import from `src/` (or read a
+ checked-in file) and mirror its layout: `src/lib/idp/credentials.ts` →
+ `tests/unit/lib/idp/credentials.test.ts`. One file may cover a cohesive
+ directory if the mirror path stays obvious. Doubles are fine.
+ - Unit tests own all detail: wording, key order, classification, renderer
+ output, validation messages, id prefixes, envelope fields. A spec never
+ asserts what a unit test can, so a failure names the module that broke.
+ - **Specs** (`tests/integration/**/*.spec.ts`) drive the built CLI through
+ `runCliForTest` against `@zitadel/api-mock`'s platform handlers and assert
+ only what a developer could observe.
+ - One spec file per command, named after it (`variables-set.spec.ts`), the
+ only spec that drives that command. No cross-command, "contract" or
+ "round-trip" files.
+ - A property every command must have lives in the fixture: `ScaffoldedApp`
+ checks the envelope contract on every `--json` run.
+ - The binary's own behaviour (unknown command, server resolution) is
+ `cli.spec.ts`. The generated resource commands share one factory
+ (`src/lib/oclif/crud/`): unit-test the factory, and `resources.spec.ts`
+ proves it is wired.
## The shape of a spec
```ts
- describe("<command>", () => {
- it("<what the developer gets>", async () => {
- const app = await aNextApp();
- await app.setup(); // arrange with flags, non-interactively
-
- const result = await app.enableSso("google", CREDENTIALS);
-
- expect(result.exitCode).toBe(0);
- expect(snapshotPlatformStore().idpSlugs).toEqual(["google"]);
- });
- });
- ```
-
- Three rules make that shape hold:
-
- 1. **Arrange with flags, act on your own command.** A spec may run other
- commands to reach its starting state, but only non-interactively and without
- asserting on them. `setup.spec.ts` is the only place the setup wizard is the
- subject.
- 2. **Observe by running a command.** A spec's subject is how the CLI behaves,
- not how it stores things, so what a command did is checked by running
- another one: `idps list`, `schemas list`, `flow-definitions list`,
- `variables list --project-level`, `plan`. Never `.zitadel/state.json` and
- never the mock's own store — a corrupt file matters only insofar as a later
- command surfaces it, and that surfacing is the assertion.
-
- A committed file may be read where its content *is* the behaviour, through
- `app.committed`: chiefly that a credential is a `${{ VARIABLE }}` reference
- and never a literal, because a secret in version control cannot be scrubbed
- later. Nothing else — not prose copy, key ordering, generated file contents
- or marker files, all of which a unit test reaches faster.
- 3. **One journey per test.** A test that runs setup, then doctor, then plan, then
- apply, then reruns is not a spec; it is five specs sharing a temp directory.
- Split it and let each command's spec own its own leg.
-
- Use `it.each` for anything that varies by framework, preset or resource. A spec
- suite is a table plus a journey, not a sequence of hand-written near-duplicates.
+ const platform = usePlatformMock();
- A spec body contains a fixture call, a command, and matchers — nothing else:
+ describe("auth-method sso enable", () => {
+ describe("against an invalid server", () => {
+ describe("that is not a zitadel api", () => {
+ it("still writes the provider into the project", async () => {
+ const app = await aSetUpApp();
+ platform.isNotZitadel();
- - **No paths.** Edit through `app.editUserSchema`, `app.editLoginFlow`,
- `app.editPackageJson`, `app.addFlow`; arrange through `app.writeConfig` and
- `app.writeLocalSecret`. A `.zitadel/...` string in a spec is only ever a
- value the CLI reported back.
- - **No HTTP.** The platform is arranged in its own terms —
- `platform.rejectsSchemaUploads()`, `platform.recovers()`,
- `platform.capturesBrandingPublishes()`. msw stays in `helpers/platform.ts`.
- - **One assertion vocabulary**, from `tests/helpers/matchers.ts`: `toSucceed`,
- `toFail`, `toFailWith`, `toExitWith`, `toBeSkipped`, `toExplain`, `toHintAt`,
- `toSuggest`, `toPrint`, `toPrintNoJson`, `toSay`, `toReportNothingToDo`.
- Never a bare `exitCode` compare. Rendered output is asserted with `toPrint`,
- which reads stdout alone — `toSay` spans stdout and stderr, so it cannot tell
- a summary printed to the wrong channel from one printed to the right one.
- - **No comments.** A step that needs explaining needs a named helper with a
- line of TSDoc instead.
- - **One shape**: arrange, blank line, a single command under test, blank line,
- assertions. One claim per test — split rather than assert two things.
- - **One skeleton.** Every suite is two top-level groups, in this order:
+ const result = await app.enableSso("google", CREDENTIALS);
- ```ts
- describe("<command>", () => {
- describe("against an invalid server", () => {
- describe("that is not a zitadel api", () => { ... });
- describe("that is down", () => { ... });
+ expect(result).toSucceed();
+ });
});
- describe("against a valid server", () => {
- describe("--json", () => { ... });
- describe("rendered for a terminal", () => { ... });
- });
+ describe("that is down", () => { … });
});
- ```
- The first group says what the command does when the server it is pointed at
- cannot serve the request: one that answers but is not a Zitadel API, and one
- that is down. Most fail. Some carry on deliberately, for one of two reasons:
- they never needed the server — `resources` describes the command surface,
- `stop`, `reset` and `eject` act on the machine — or they reach it and
- tolerate the failure, as `plan` does for its old-state fetch,
- `auth-method sso enable` for publishing the credentials, and `doctor` and
- `status` for their probes. Either way it is worth stating rather than leaving
- to be discovered, and each spec's own first group is the record, not this
- sentence. The `--json` and rendered groups never interleave, and a command
- with no human-rendered output has no second one.
-
- Formatting is Biome's, per the repo's `biome.json`, so nothing is
- hand-wrapped.
-
- One exception, in `cli.spec.ts`: it scans `src/**` for the commands the CLI
- suggests, and checks each one against the command list the built CLI reports.
- Reading source from a spec is otherwise forbidden, but the assertion is the
- cross-check between the two sides — split it and each half proves nothing, so
- it stays here deliberately rather than by oversight.
+ describe("against a valid server", () => {
+ describe("--json", () => {
+ it("registers nothing with the platform until apply", async () => {
+ const app = await aSetUpApp();
- ## Ports
+ await app.enableSso("google", CREDENTIALS);
- A port a test hands the CLI, in a unit test as in a spec, comes from
- `nextPort()` in [`helpers/ports.ts`](helpers/ports.ts), never from a
- listen-on-zero probe. A port bound and closed again races the other vitest
- workers: one of their outbound connections can take it as its source port
- before the CLI binds it, and the CLI refuses it with `E_PORT_IN_USE`. Retrying
- on that hides the race rather than fixing it. This is the per-worker allocation
- [`e2e/journey/AGENTS.md`](../../../e2e/journey/AGENTS.md) requires of the
- journey runner. A listener a test holds until it is over may listen on zero:
- nobody can take a port that is held.
+ expect(await app.registeredIdps()).toEqual([]);
+ });
+ });
- ## Network
+ describe("rendered for a terminal", () => { … });
+ });
+ });
+ ```
- No test leaves the machine. [`helpers/offline.ts`](helpers/offline.ts) runs
- before every file and fails a test that connects anywhere but loopback, naming
- where it tried to go: refusing the request alone would not do, because the CLI
- carries on past a server it cannot reach. A unit test answers the platform with
- msw — `setupServer(...setupPlatformHandlers())` with
- `onUnhandledRequest: "error"`, plus a handler that refuses where the failure is
- what the test is about — and a spec with `usePlatformMock()`. A command run
- without `--server` talks to `DEFAULT_SERVER`, the real platform, so it needs
- the same arrangement as any other.
+ - **Two top-level groups, in that order.** The invalid-server group states
+ what the command does against a server that is not a Zitadel API and one
+ that is down, including when it carries on regardless. The `--json` and
+ rendered groups never interleave; drop the second if nothing is rendered.
+ - **Arrange with flags, act on your own command.** Reach the starting state
+ through other commands, non-interactively and unasserted. `setup.spec.ts`
+ alone has the wizard as its subject.
+ - **Observe by running a command** (`idps list`, `plan`, …, through helpers
+ like `app.registeredIdps()`), never by reading `.zitadel/state.json` or the
+ mock's store.
+ - Read a committed file (`app.committed`) only where its content is the
+ behaviour: a credential is a `${{ VARIABLE }}` reference, never a literal.
+ - **One journey and one claim per test**: arrange, blank line, one command,
+ blank line, assertions. Use `it.each` for what varies by framework, preset
+ or resource.
+ - **No paths**: edit and arrange through `app.edit*`, `app.addFlow`,
+ `app.writeConfig`, `app.writeLocalSecret`.
+ - **No HTTP**: arrange the platform in its own terms (`platform.recovers()`,
+ …); msw stays in `helpers/platform.ts`.
+ - **Only the matchers in `helpers/matchers.ts`**, never a bare `exitCode`
+ compare. Assert rendered output with `toPrint` (stdout alone), not `toSay`,
+ which spans both channels.
+ - **No comments** (one exception below): a step that needs one becomes a
+ named helper with TSDoc.
+ - `cli.spec.ts` alone may read `src/**`: it checks the commands the CLI
+ suggests against the command list the built CLI reports.
## Prohibited in specs
- - **No doubles of our own modules.** A spec that stubs something in `src/` has
- stopped being a spec. Fake an external binary on `PATH` if the journey needs
- one; never fake our own code.
- - **Never a real Zitadel.** The mock answers everything. If the mock cannot
- express the behaviour under test, teach the mock — do not reach for a server,
- and do not assert the behaviour at all until the mock is honest about it.
- - **No assertions the mock cannot justify.** The mock accepts some things the
- real engine refuses (`idp.field_immutable` on a changed issuer, say). Where a
- spec stands in for a server-side rule, say so in a comment naming the
- validator, so the gap is explicit rather than assumed covered.
+ - No doubles of our own modules. Fake an external binary on `PATH` if needed.
+ - Never a real Zitadel. If the mock cannot express a behaviour, teach the mock
+ first.
+ - Where the mock accepts what the real engine refuses, a comment names the
+ server-side validator the spec stands in for.
- ## Why it is split this way
+ ## Ports and network
- Detail asserted in a spec is asserted slowly, once, through the whole CLI, and
- it fails with a stack trace pointing at the journey rather than the defect. The
- same detail asserted in a unit test runs in milliseconds and names the module
- that broke. So the split is not about where a test *can* live — it is about
- making a failure point at its cause. The spec's job is only to prove the
- building blocks are wired together and the journey a developer actually runs
- works end to end.
+ - A port handed to the CLI comes from `nextPort()`
+ ([`helpers/ports.ts`](helpers/ports.ts)), never a bind-and-close probe:
+ another worker's outbound connection can take it first (`E_PORT_IN_USE`).
+ Never retry on that. A listener held for the whole test may bind port 0.
+ - No test leaves the machine: [`helpers/offline.ts`](helpers/offline.ts) fails
+ any connection off loopback.
+ - A unit test answers the platform with
+ `setupServer(...setupPlatformHandlers())` and
+ `onUnhandledRequest: "error"`; a spec with `usePlatformMock()`. A command
+ without `--server` talks to `DEFAULT_SERVER`, the real platform, so it needs
+ the same.