@zitadel/testing 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZITADEL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # @zitadel/testing
2
+
3
+ Test-kit for **seeded ephemeral local Zitadel instances**: boot the real server
4
+ (binary runtime + embedded Postgres, no Docker) from test code, create a
5
+ project with the default login flow, and mint password users that can complete
6
+ the real login journey immediately.
7
+
8
+ > **Status: alpha.** Published to npm on the shared release train — the kit
9
+ > carries the same version as `@zitadel/cli` and the SDKs, the train publishes
10
+ > under the `alpha` dist-tag (install with `@alpha`), and APIs can still move
11
+ > between alphas. macOS/Linux (see [Known limitations](#known-limitations)).
12
+
13
+ ## Why
14
+
15
+ The repo previously had two extremes: the demo e2e suites run against
16
+ `@zitadel/api-mock` (fast, but fake — no user store, no real crypto), and
17
+ `cli-journey-e2e` runs the real packaged product but creates users by clicking
18
+ through the registration UI, serialized per suite. This kit fills the middle:
19
+ **real server, programmatic seeding, parallel-safe per-test users.**
20
+
21
+ ## Quick start (Playwright)
22
+
23
+ ```sh
24
+ npm i -D @zitadel/testing@alpha @playwright/test
25
+ ```
26
+
27
+ The kit drives the `zitadel` CLI, which resolves the published
28
+ `@zitadel/server` platform binary on its own — no binary paths, no env vars.
29
+
30
+ One instance per suite, seeded per test. `withZitadel()` generates the
31
+ `webServer` entries that boot the instance and run your app against it — no
32
+ wrapper scripts:
33
+
34
+ ```ts
35
+ // playwright.config.ts
36
+ import { defineConfig } from "@playwright/test";
37
+ import { nextAppEnv, withZitadel } from "@zitadel/testing/playwright";
38
+
39
+ export default defineConfig({
40
+ testDir: "./e2e",
41
+ ...withZitadel({
42
+ configDir: import.meta.dirname,
43
+ port: 8092, // fixed, so the readiness URL is known up front
44
+ appOrigin: "http://localhost:3002", // your app's origin (proxy origin check)
45
+ app: {
46
+ command: ["pnpm", "dev"], // your app's dev server
47
+ cwd: import.meta.dirname,
48
+ readyPath: "/login",
49
+ env: nextAppEnv, // or your own AppEnvTemplate
50
+ },
51
+ }),
52
+ });
53
+ ```
54
+
55
+ ```ts
56
+ // my-login.spec.ts
57
+ import { expect, test } from "@zitadel/testing/playwright";
58
+
59
+ test("user signs in with password", async ({ page, seed }) => {
60
+ const user = await seed.user(); // unique email + password, loginable now
61
+ await page.goto("/login");
62
+ await page.getByLabel(/email/i).fill(user.email);
63
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
64
+ await page.getByLabel(/password/i).fill(user.password);
65
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
66
+ await expect(page).toHaveURL(/\/admin/);
67
+ });
68
+ ```
69
+
70
+ `app.env` is an `AppEnvTemplate`: a serializable mapping from your app's env
71
+ var names to `InstanceHandle` fields. `nextAppEnv` covers `@zitadel/sdk-next`
72
+ apps; the console maps the same fields to `VITE_*`/`CONSOLE_*` names instead.
73
+ The fixtures find the instance through `ZITADEL_TESTING_HANDSHAKE`, which
74
+ `withZitadel()` points at its handshake file.
75
+
76
+ ### Start tests authenticated
77
+
78
+ Most app tests don't want to re-test login. `authenticatedPage` seeds a user,
79
+ drives the real login flow headlessly (the same Flow API the login UI renders),
80
+ and injects the resulting session cookie into a dedicated browser context:
81
+
82
+ ```ts
83
+ test("member sees the dashboard", async ({ authenticatedPage }) => {
84
+ const { page, user } = authenticatedPage;
85
+ await page.goto("/dashboard"); // already signed in as `user`
86
+ });
87
+ ```
88
+
89
+ Underneath sits `seed.session()`, whose `sessionToken` also drives session
90
+ APIs without any browser — backend tests call the instance directly with the
91
+ cookie header (`cookie: __nextgen_session=<sessionToken>`, the SDK
92
+ middleware's own headless pattern). Scope: password flows (the shipped
93
+ `password-first` presets). A flow step demanding anything beyond the user's
94
+ email and password — a challenge, another factor — fails with the step name;
95
+ log in through the UI for those. `seed.identity()` complements registration
96
+ specs: an unused email+password that creates nothing, so the flow under test
97
+ must create the user.
98
+
99
+ The two in-repo consumers are `apps/demo-next-e2e/playwright.real.config.mts`
100
+ and `apps/console-e2e/playwright.real.config.mts` — run them with
101
+ `moon run demo-next-e2e:e2e-real` / `moon run console-e2e:e2e-real`.
102
+
103
+ ### Composable pieces
104
+
105
+ `withZitadel()` is sugar over exported building blocks: a supervisor entry
106
+ that calls `startLocalZitadel()` + `writeHandshake()`, and an app-runner entry
107
+ that `waitForHandshake()`s and spawns the dev server with
108
+ `applyAppEnvTemplate(...)` applied. Suites with unusual topologies (or
109
+ non-Playwright runners) compose those functions directly —
110
+ `apps/console/scripts/dev-real.mts` does, seeding a dev environment rather
111
+ than a test suite.
112
+
113
+ ## API
114
+
115
+ ```ts
116
+ import {
117
+ startLocalZitadel, // boot + bootstrap an ephemeral instance
118
+ connectZitadel, // attach to an existing instance via its handle
119
+ writeHandshake, readHandshakeSync, waitForHandshake,
120
+ } from "@zitadel/testing";
121
+
122
+ const z = await startLocalZitadel({
123
+ port, // default: free port
124
+ dir, // default: temp dir, removed on stop (caller dirs are kept)
125
+ appOrigins, // registered as the project's previewOrigins
126
+ useCase, // "minimal" (default) | "consumer" | "business"
127
+ preset, // "password-first" (default)
128
+ serverBinary, // ZITADEL_SERVER_BINARY override (in-repo: dist/server/nextgen)
129
+ keep, // keep the temp dir for debugging
130
+ });
131
+
132
+ z.handle; // serializable: { baseUrl, projectId, projectSecret, schemaId, previewSecret? }
133
+ z.api; // authenticated @zitadel/api client (bearer = projectSecret)
134
+ z.appEnv; // { ZITADEL_URL, NEXT_PUBLIC_ZITADEL_PROJECT_ID, ZITADEL_PROJECT_SECRET }
135
+ await z.seedUser({ email?, password?, attributes? }); // → { id, email, password }
136
+ await z.seedUsers(8, { email?, password?, attributes? }); // per-index templates
137
+ z.identity(); // unused { email, password } — creates nothing
138
+ await z.seedSession({ user? }); // → { user, sessionToken, expiresAt, cookie }
139
+ await z.stop(); // stop server, reap embedded Postgres, remove owned temp dir
140
+ ```
141
+
142
+ `connectZitadel(handle)` returns the same surface minus lifecycle — this is
143
+ what the Playwright fixtures use, and what a future remote-instance mode would
144
+ build on.
145
+
146
+ From `@zitadel/testing/playwright`: the `test`/`expect` fixtures (`seed.user()`
147
+ per test, `zitadel` per worker), plus `withZitadel(options)` returning
148
+ `{ webServer }` for the config, and `nextAppEnv`/`applyAppEnvTemplate` for the
149
+ env-template mechanism described above.
150
+
151
+ ## How it works
152
+
153
+ - **Lifecycle** shells out to `zitadel start/stop --json` (the CLI owns port
154
+ preflight, the health wait, process-group stop, and embedded-Postgres
155
+ reaping). Swapping this for direct library calls later will not change the
156
+ public API.
157
+ - **Bootstrap** is the server-side half of `zitadel setup`, no files:
158
+ `POST /projects` (unauthenticated; returns the `projectSecret` used as
159
+ bearer for everything else) → `POST /schemas` (server assigns the schema id)
160
+ → `POST /flow_definitions` (default login flow pinned to that schema id).
161
+ Templates come from `@zitadel/config/defaults`.
162
+ - **Seeding** is `POST /users` (the body carries `$schema: <schema id>`) +
163
+ `PUT /users/{id}/password` with `isChangeRequired: false`.
164
+
165
+ ## Parallelism model
166
+
167
+ **One instance per suite, one fresh user per test.** Emails are unique per
168
+ project, so per-test `seed.user()` calls are isolation enough for login-flow
169
+ tests, and tests run fully parallel against the shared instance
170
+ (demonstrated by `demo-next-e2e:e2e-real`, 2 workers).
171
+
172
+ Measured on an arm64 macBook (dev build, July 2026):
173
+
174
+ | Operation | Time |
175
+ | --- | --- |
176
+ | Cold boot (fresh data dir: initdb + migrations + health) | ~20–27s |
177
+ | Warm restart (existing data dir) | ~15s |
178
+ | Stop incl. embedded-Postgres reap | ~12s |
179
+ | Bootstrap (project + schema + flow) | ~100ms |
180
+ | Seed one user (create + password) | ~50ms |
181
+ | Full `e2e-real` suite (boot + Next dev + 2 browser tests) | ~25s |
182
+
183
+ **Instance-per-worker is not worth it for browser e2e**: the app dev server
184
+ boots once with one project's env, and every extra worker would pay the full
185
+ cold boot. It becomes interesting for API-level (no-browser) suites that
186
+ mutate project-wide state; revisit with warm-dir reuse if that need appears.
187
+
188
+ ## Debugging
189
+
190
+ - `startLocalZitadel({ keep: true })` keeps the temp dir; the server log is at
191
+ `<dir>/.zitadel/local/server.log`. Boot failures always keep the dir and
192
+ print its path.
193
+ - A crashed run can orphan a server: `zitadel stop --all` sweeps every
194
+ CLI-managed runtime on the machine.
195
+
196
+ ## Developing in this repo
197
+
198
+ Customer installs get the published server binary through `@zitadel/server`'s
199
+ platform packages; the in-repo workspace carries no such binary, so the repo's
200
+ own suites run `moon run server:build` and point the kit at the result via
201
+ `ZITADEL_SERVER_BINARY` (the `withZitadel` option `zitadel.serverBinary` /
202
+ `serverBinaryHint` exists for this). The in-repo moon tasks set
203
+ `NEXTGEN_SERVER_LOGIN_ENABLED=false` / `NEXTGEN_SERVER_CONSOLE_ENABLED=false`
204
+ — the suites drive the app-embedded login, not the server-hosted `/ui/*`.
205
+ Customer installs need none of this.
206
+
207
+ ## Known limitations
208
+
209
+ - `@zitadel/api`'s client stores auth in module-global state; avoid
210
+ interleaving calls to *different* instances within one process. Separate
211
+ processes (Playwright workers) are unaffected.
212
+ - macOS/Linux only for now. Not because of the port preflight (it degrades
213
+ gracefully where `lsof` is missing) — the untested surface on Windows is the
214
+ process-group stop and embedded-Postgres lifecycle. Revisit once the local
215
+ runtime's SQLite default removes the Postgres component.
216
+
217
+ ## Roadmap
218
+
219
+ This package is deliberately the **local runtime core** — "Testcontainers for
220
+ Zitadel" when the app under test and Playwright share one machine. The layers
221
+ on top, in intended order:
222
+
223
+ 1. **Registration fixtures.** Landed: `zitadel.identity()` plus the
224
+ demo-next-e2e registration spec driving the real registration UI and
225
+ verifying the created user through the API. `cli-journey-e2e` keeps the
226
+ packaged-product registration coverage.
227
+ 2. **Email/OTP capture.** `zitadel.email.waitForCode(address)` for
228
+ verification flows. Blocked on a server-side story (dev SMTP sink or
229
+ API-exposed codes); password-only flows don't hit this.
230
+ 3. **Vitest surface.** Landed from this item: `withZitadel(config)`
231
+ orchestration, `AppEnvTemplate`/`nextAppEnv`, and the session-mint seed-op
232
+ (`seed.session()` driving the real flow headlessly, `authenticatedPage` on
233
+ top). Remaining: a dedicated `@zitadel/testing/vitest` entry once a second
234
+ backend consumer exists — `sessionToken` already serves backend tests
235
+ directly.
236
+ 4. **Remote mode: ephemeral project on a persistent instance.** For app
237
+ deployments that cannot reach a local process (preview environments).
238
+ `bootstrapProject({ baseUrl })` + `connectZitadel(handle)` already compose
239
+ into this today; formalizing it means cleanup semantics and docs.
240
+ 5. **Vercel Sandbox runtime.** A publicly reachable ephemeral instance per
241
+ preview deployment (e.g. `@zitadel/testing/vercel`), returning the same
242
+ `InstanceHandle` so fixtures don't change. Gated on a spike: embedded
243
+ Postgres on the Sandbox image, forwarded proto/host handling, secure
244
+ cookies + issuer + handoff verification through `sandbox.domain()`,
245
+ registering the preview URL as an allowed origin post-deploy
246
+ (`PATCH /projects`), and cleanup that survives failed runs.
247
+ 6. **Publishing.** Landed: the consumer journey installs the kit the way a
248
+ customer would in CI, and the kit ships on the release train (release
249
+ manifest + changeset `fixed` group), versioned in lockstep with
250
+ `@zitadel/cli`. Remaining: the Windows decision (deferred until the SQLite
251
+ local default removes the embedded-Postgres lifecycle) and the stability
252
+ commitment when the train leaves alpha.
253
+
254
+ Parked until server support exists: passkey seeding (pre-registered WebAuthn
255
+ credentials). Independent refactor: extract `apps/cli/src/lib/local-server`
256
+ into a shared package and swap the lifecycle shell-out for imports.
@@ -0,0 +1,122 @@
1
+ import { ZitadelClient } from "@zitadel/api/client";
2
+
3
+ //#region src/types.d.ts
4
+ /**
5
+ * Serializable description of a bootstrapped instance + project. This is the
6
+ * contract that crosses process boundaries (boot script -> Playwright workers).
7
+ */
8
+ interface InstanceHandle {
9
+ baseUrl: string;
10
+ projectId: string;
11
+ projectSecret: string;
12
+ /**
13
+ * First registered app origin. Flow submissions enforce the project's
14
+ * origin allowlist, so headless drivers (seedSession) send it as the
15
+ * Origin header the way a browser request through the app would.
16
+ */
17
+ appOrigin?: string;
18
+ /**
19
+ * Server-assigned id of the seeded user schema. User documents must
20
+ * reference it via their `$schema` field, so seeding needs it alongside the
21
+ * project credential.
22
+ */
23
+ schemaId: string;
24
+ previewSecret?: string;
25
+ }
26
+ interface SeedUserInput {
27
+ email?: string;
28
+ password?: string;
29
+ /** Extra schema-defined user properties merged into the create body. */
30
+ attributes?: Record<string, unknown>;
31
+ }
32
+ interface SeededUser {
33
+ id: string;
34
+ email: string;
35
+ password: string;
36
+ }
37
+ /** An unused identity: seeded nowhere, ready for registration-flow specs. */
38
+ interface Identity {
39
+ email: string;
40
+ password: string;
41
+ }
42
+ /** Injectable mirror of the server's session cookie (session.go). */
43
+ interface SessionCookie {
44
+ name: string;
45
+ value: string;
46
+ httpOnly: boolean;
47
+ secure: boolean;
48
+ sameSite: "Lax";
49
+ path: string;
50
+ }
51
+ interface MintedSession {
52
+ user: SeededUser;
53
+ /** Bearer for session management APIs (e.g. `GET /sessions/me`). */
54
+ sessionToken: string;
55
+ expiresAt: string;
56
+ cookie: SessionCookie;
57
+ }
58
+ interface SeedSessionInput extends SeedUserInput {
59
+ /** Mint for an existing user instead of seeding a fresh one. */
60
+ user?: SeededUser;
61
+ /** Specific flow definition; the project default when omitted. */
62
+ flowDefinitionName?: string;
63
+ /**
64
+ * Origin header for the flow calls (must be on the project's allowlist).
65
+ * Defaults to the handle's registered app origin; the Playwright fixtures
66
+ * pass the suite's baseURL.
67
+ */
68
+ origin?: string;
69
+ }
70
+ interface SeedUsersTemplate {
71
+ email?: (index: number) => string;
72
+ password?: (index: number) => string;
73
+ attributes?: (index: number) => Record<string, unknown>;
74
+ }
75
+ interface ConnectedZitadel {
76
+ handle: InstanceHandle;
77
+ /** Authenticated platform API client (bearer = project secret). */
78
+ api: ZitadelClient;
79
+ /** Env vars an SDK-based app needs to talk to this instance/project. */
80
+ appEnv: Record<string, string>;
81
+ seedUser(input?: SeedUserInput): Promise<SeededUser>;
82
+ /** Batch-seed users; the template makes fixture data deterministic. */
83
+ seedUsers(count: number, template?: SeedUsersTemplate): Promise<SeededUser[]>;
84
+ /** A unique unused email+password — nothing is created on the instance. */
85
+ identity(): Identity;
86
+ /**
87
+ * Seed (or take) a user and drive the real login flow headlessly to a
88
+ * session: tests inject `cookie` to start authenticated, backend tests use
89
+ * `sessionToken` directly. Password flows only.
90
+ */
91
+ seedSession(input?: SeedSessionInput): Promise<MintedSession>;
92
+ }
93
+ interface LocalZitadelRuntime {
94
+ port: number;
95
+ pid: number;
96
+ /** State directory holding `.zitadel/local/` (data dir, logs, runtime.json). */
97
+ dir: string;
98
+ logPath: string;
99
+ }
100
+ interface LocalZitadel extends ConnectedZitadel, AsyncDisposable {
101
+ runtime: LocalZitadelRuntime;
102
+ stop(): Promise<void>;
103
+ }
104
+ //#endregion
105
+ //#region src/app-env.d.ts
106
+ /**
107
+ * Declarative mapping from an app's env var names to InstanceHandle fields.
108
+ * A template (not a function) so it can cross process boundaries: the
109
+ * Playwright config serializes it into the app-runner's environment, where it
110
+ * is applied to the handle read from the handshake file.
111
+ */
112
+ type AppEnvTemplate = Record<string, keyof InstanceHandle>;
113
+ /**
114
+ * The env shape `@zitadel/sdk-next` apps read. Other frameworks pass their own
115
+ * template — the console maps the same handle fields to `VITE_*`/`CONSOLE_*`
116
+ * names, for example.
117
+ */
118
+ declare const nextAppEnv: AppEnvTemplate;
119
+ declare function applyAppEnvTemplate(template: AppEnvTemplate, handle: InstanceHandle): Record<string, string>;
120
+ //#endregion
121
+ export { Identity as a, LocalZitadelRuntime as c, SeedUserInput as d, SeedUsersTemplate as f, ConnectedZitadel as i, MintedSession as l, SessionCookie as m, applyAppEnvTemplate as n, InstanceHandle as o, SeededUser as p, nextAppEnv as r, LocalZitadel as s, AppEnvTemplate as t, SeedSessionInput as u };
122
+ //# sourceMappingURL=app-env-D3W0GYhA.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-env-D3W0GYhA.d.mts","names":[],"sources":["../src/types.ts","../src/app-env.ts"],"mappings":";;;;;AAMA;;UAAiB,cAAA;EACf,OAAA;EACA,SAAA;EACA,aAAA;EAAA;;;;;EAMA,SAAA;EAUe;;;;;EAJf,QAAA;EACA,aAAA;AAAA;AAAA,UAGe,aAAA;EACf,KAAA;EACA,QAAA;EAKyB;EAHzB,UAAA,GAAa,MAAA;AAAA;AAAA,UAGE,UAAA;EACf,EAAA;EACA,KAAA;EACA,QAAA;AAAA;AAIF;AAAA,UAAiB,QAAA;EACf,KAAA;EACA,QAAA;AAAA;AAIF;AAAA,UAAiB,aAAA;EACf,IAAA;EACA,KAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,IAAA;AAAA;AAAA,UAGe,aAAA;EACf,IAAA,EAAM,UAAA;EAJF;EAMJ,YAAA;EACA,SAAA;EACA,MAAA,EAAQ,aAAA;AAAA;AAAA,UAGO,gBAAA,SAAyB,aAAA;EAPlC;EASN,IAAA,GAAO,UAAA;EANP;EAQA,kBAAA;EAPQ;;;AAGV;;EAUE,MAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA,IAAS,KAAA;EACT,QAAA,IAAY,KAAA;EACZ,UAAA,IAAc,KAAA,aAAkB,MAAA;AAAA;AAAA,UAGjB,gBAAA;EACf,MAAA,EAAQ,cAAA;EAPO;EASf,GAAA,EAAK,aAAA;;EAEL,MAAA,EAAQ,MAAA;EACR,QAAA,CAAS,KAAA,GAAQ,aAAA,GAAgB,OAAA,CAAQ,UAAA;EAXhC;EAaT,SAAA,CAAU,KAAA,UAAe,QAAA,GAAW,iBAAA,GAAoB,OAAA,CAAQ,UAAA;EAZpD;EAcZ,QAAA,IAAY,QAAA;EAbE;;;;AAGhB;EAgBE,WAAA,CAAY,KAAA,GAAQ,gBAAA,GAAmB,OAAA,CAAQ,aAAA;AAAA;AAAA,UAGhC,mBAAA;EACf,IAAA;EACA,GAAA;EAfiB;EAiBjB,GAAA;EACA,OAAA;AAAA;AAAA,UAGe,YAAA,SAAqB,gBAAA,EAAkB,eAAA;EACtD,OAAA,EAAS,mBAAA;EACT,IAAA,IAAQ,OAAA;AAAA;;;;;AAxGV;;;;KCEY,cAAA,GAAiB,MAAA,eAAqB,cAAA;;;;;;cAOrC,UAAA,EAAY,cAAA;AAAA,iBAMT,mBAAA,CACd,QAAA,EAAU,cAAA,EACV,MAAA,EAAQ,cAAA,GACP,MAAA"}
@@ -0,0 +1,36 @@
1
+ const require_handshake = require("./handshake-CggSIoxF.cjs");
2
+ const require_orchestration = require("./orchestration-D7QBgQ0l.cjs");
3
+ let node_child_process = require("node:child_process");
4
+ //#region src/app-runner.ts
5
+ /**
6
+ * Playwright webServer entry generated by `withZitadel()`: waits for the
7
+ * supervisor's handshake, then runs the app's dev server with the instance
8
+ * env applied. Signals forward to the child so Playwright teardown stops the
9
+ * app cleanly. Configuration arrives as JSON in ZITADEL_TESTING_APP_RUNNER.
10
+ */
11
+ const LOG = "[zitadel-testing-app]";
12
+ async function main() {
13
+ const config = require_orchestration.parseAppRunnerConfig(process.env[require_orchestration.APP_RUNNER_CONFIG_ENV]);
14
+ const handle = await require_handshake.waitForHandshake(require_orchestration.requireHandshakePath(process.env), config.handshakeTimeoutMs ?? 18e4);
15
+ console.log(`${LOG} starting ${config.command.join(" ")} against ${handle.baseUrl}`);
16
+ const [bin, ...args] = config.command;
17
+ const child = (0, node_child_process.spawn)(bin, args, {
18
+ cwd: config.cwd,
19
+ stdio: "inherit",
20
+ env: {
21
+ ...process.env,
22
+ ...require_handshake.applyAppEnvTemplate(config.env ?? {}, handle)
23
+ }
24
+ });
25
+ child.on("exit", (code) => {
26
+ process.exit(code ?? 1);
27
+ });
28
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => child.kill(signal));
29
+ }
30
+ main().catch((error) => {
31
+ console.error(`${LOG} ${error instanceof Error ? error.message : String(error)}`);
32
+ process.exit(1);
33
+ });
34
+ //#endregion
35
+
36
+ //# sourceMappingURL=app-runner.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-runner.cjs","names":["parseAppRunnerConfig","APP_RUNNER_CONFIG_ENV","waitForHandshake","requireHandshakePath","applyAppEnvTemplate"],"sources":["../src/app-runner.ts"],"sourcesContent":["/**\n * Playwright webServer entry generated by `withZitadel()`: waits for the\n * supervisor's handshake, then runs the app's dev server with the instance\n * env applied. Signals forward to the child so Playwright teardown stops the\n * app cleanly. Configuration arrives as JSON in ZITADEL_TESTING_APP_RUNNER.\n */\nimport { spawn } from \"node:child_process\";\n\nimport { applyAppEnvTemplate } from \"./app-env\";\nimport { waitForHandshake } from \"./handshake\";\nimport { APP_RUNNER_CONFIG_ENV, parseAppRunnerConfig, requireHandshakePath } from \"./orchestration\";\n\nconst LOG = \"[zitadel-testing-app]\";\n\nasync function main(): Promise<void> {\n const config = parseAppRunnerConfig(process.env[APP_RUNNER_CONFIG_ENV]);\n const handshakePath = requireHandshakePath(process.env);\n\n const handle = await waitForHandshake(handshakePath, config.handshakeTimeoutMs ?? 180_000);\n console.log(`${LOG} starting ${config.command.join(\" \")} against ${handle.baseUrl}`);\n\n const [bin, ...args] = config.command;\n const child = spawn(bin as string, args, {\n cwd: config.cwd,\n stdio: \"inherit\",\n env: {\n ...process.env,\n ...applyAppEnvTemplate(config.env ?? {}, handle),\n },\n });\n child.on(\"exit\", (code) => {\n process.exit(code ?? 1);\n });\n for (const signal of [\"SIGTERM\", \"SIGINT\"] as const) {\n process.on(signal, () => child.kill(signal));\n }\n}\n\nmain().catch((error: unknown) => {\n console.error(`${LOG} ${error instanceof Error ? error.message : String(error)}`);\n process.exit(1);\n});\n"],"mappings":";;;;;;;;;;AAYA,MAAM,MAAM;AAEZ,eAAe,OAAsB;CACnC,MAAM,SAASA,sBAAAA,qBAAqB,QAAQ,IAAIC,sBAAAA,uBAAuB;CAGvE,MAAM,SAAS,MAAMC,kBAAAA,iBAFCC,sBAAAA,qBAAqB,QAAQ,IAEA,EAAE,OAAO,sBAAsB,KAAQ;AAC1F,SAAQ,IAAI,GAAG,IAAI,YAAY,OAAO,QAAQ,KAAK,IAAI,CAAC,WAAW,OAAO,UAAU;CAEpF,MAAM,CAAC,KAAK,GAAG,QAAQ,OAAO;CAC9B,MAAM,SAAA,GAAA,mBAAA,OAAc,KAAe,MAAM;EACvC,KAAK,OAAO;EACZ,OAAO;EACP,KAAK;GACH,GAAG,QAAQ;GACX,GAAGC,kBAAAA,oBAAoB,OAAO,OAAO,EAAE,EAAE,OAAO;GACjD;EACF,CAAC;AACF,OAAM,GAAG,SAAS,SAAS;AACzB,UAAQ,KAAK,QAAQ,EAAE;GACvB;AACF,MAAK,MAAM,UAAU,CAAC,WAAW,SAAS,CACxC,SAAQ,GAAG,cAAc,MAAM,KAAK,OAAO,CAAC;;AAIhD,MAAM,CAAC,OAAO,UAAmB;AAC/B,SAAQ,MAAM,GAAG,IAAI,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,GAAG;AACjF,SAAQ,KAAK,EAAE;EACf"}
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,37 @@
1
+ import { i as applyAppEnvTemplate, n as waitForHandshake } from "./handshake-BPtWruO8.mjs";
2
+ import { i as parseAppRunnerConfig, o as requireHandshakePath, t as APP_RUNNER_CONFIG_ENV } from "./orchestration-C640_1Lg.mjs";
3
+ import { spawn } from "node:child_process";
4
+ //#region src/app-runner.ts
5
+ /**
6
+ * Playwright webServer entry generated by `withZitadel()`: waits for the
7
+ * supervisor's handshake, then runs the app's dev server with the instance
8
+ * env applied. Signals forward to the child so Playwright teardown stops the
9
+ * app cleanly. Configuration arrives as JSON in ZITADEL_TESTING_APP_RUNNER.
10
+ */
11
+ const LOG = "[zitadel-testing-app]";
12
+ async function main() {
13
+ const config = parseAppRunnerConfig(process.env[APP_RUNNER_CONFIG_ENV]);
14
+ const handle = await waitForHandshake(requireHandshakePath(process.env), config.handshakeTimeoutMs ?? 18e4);
15
+ console.log(`${LOG} starting ${config.command.join(" ")} against ${handle.baseUrl}`);
16
+ const [bin, ...args] = config.command;
17
+ const child = spawn(bin, args, {
18
+ cwd: config.cwd,
19
+ stdio: "inherit",
20
+ env: {
21
+ ...process.env,
22
+ ...applyAppEnvTemplate(config.env ?? {}, handle)
23
+ }
24
+ });
25
+ child.on("exit", (code) => {
26
+ process.exit(code ?? 1);
27
+ });
28
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => child.kill(signal));
29
+ }
30
+ main().catch((error) => {
31
+ console.error(`${LOG} ${error instanceof Error ? error.message : String(error)}`);
32
+ process.exit(1);
33
+ });
34
+ //#endregion
35
+ export {};
36
+
37
+ //# sourceMappingURL=app-runner.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-runner.mjs","names":[],"sources":["../src/app-runner.ts"],"sourcesContent":["/**\n * Playwright webServer entry generated by `withZitadel()`: waits for the\n * supervisor's handshake, then runs the app's dev server with the instance\n * env applied. Signals forward to the child so Playwright teardown stops the\n * app cleanly. Configuration arrives as JSON in ZITADEL_TESTING_APP_RUNNER.\n */\nimport { spawn } from \"node:child_process\";\n\nimport { applyAppEnvTemplate } from \"./app-env\";\nimport { waitForHandshake } from \"./handshake\";\nimport { APP_RUNNER_CONFIG_ENV, parseAppRunnerConfig, requireHandshakePath } from \"./orchestration\";\n\nconst LOG = \"[zitadel-testing-app]\";\n\nasync function main(): Promise<void> {\n const config = parseAppRunnerConfig(process.env[APP_RUNNER_CONFIG_ENV]);\n const handshakePath = requireHandshakePath(process.env);\n\n const handle = await waitForHandshake(handshakePath, config.handshakeTimeoutMs ?? 180_000);\n console.log(`${LOG} starting ${config.command.join(\" \")} against ${handle.baseUrl}`);\n\n const [bin, ...args] = config.command;\n const child = spawn(bin as string, args, {\n cwd: config.cwd,\n stdio: \"inherit\",\n env: {\n ...process.env,\n ...applyAppEnvTemplate(config.env ?? {}, handle),\n },\n });\n child.on(\"exit\", (code) => {\n process.exit(code ?? 1);\n });\n for (const signal of [\"SIGTERM\", \"SIGINT\"] as const) {\n process.on(signal, () => child.kill(signal));\n }\n}\n\nmain().catch((error: unknown) => {\n console.error(`${LOG} ${error instanceof Error ? error.message : String(error)}`);\n process.exit(1);\n});\n"],"mappings":";;;;;;;;;;AAYA,MAAM,MAAM;AAEZ,eAAe,OAAsB;CACnC,MAAM,SAAS,qBAAqB,QAAQ,IAAI,uBAAuB;CAGvE,MAAM,SAAS,MAAM,iBAFC,qBAAqB,QAAQ,IAEA,EAAE,OAAO,sBAAsB,KAAQ;AAC1F,SAAQ,IAAI,GAAG,IAAI,YAAY,OAAO,QAAQ,KAAK,IAAI,CAAC,WAAW,OAAO,UAAU;CAEpF,MAAM,CAAC,KAAK,GAAG,QAAQ,OAAO;CAC9B,MAAM,QAAQ,MAAM,KAAe,MAAM;EACvC,KAAK,OAAO;EACZ,OAAO;EACP,KAAK;GACH,GAAG,QAAQ;GACX,GAAG,oBAAoB,OAAO,OAAO,EAAE,EAAE,OAAO;GACjD;EACF,CAAC;AACF,OAAM,GAAG,SAAS,SAAS;AACzB,UAAQ,KAAK,QAAQ,EAAE;GACvB;AACF,MAAK,MAAM,UAAU,CAAC,WAAW,SAAS,CACxC,SAAQ,GAAG,cAAc,MAAM,KAAK,OAAO,CAAC;;AAIhD,MAAM,CAAC,OAAO,UAAmB;AAC/B,SAAQ,MAAM,GAAG,IAAI,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,GAAG;AACjF,SAAQ,KAAK,EAAE;EACf"}
@@ -0,0 +1,69 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+ import { setTimeout } from "node:timers/promises";
5
+ //#region src/app-env.ts
6
+ /**
7
+ * The env shape `@zitadel/sdk-next` apps read. Other frameworks pass their own
8
+ * template — the console maps the same handle fields to `VITE_*`/`CONSOLE_*`
9
+ * names, for example.
10
+ */
11
+ const nextAppEnv = {
12
+ ZITADEL_URL: "baseUrl",
13
+ NEXT_PUBLIC_ZITADEL_PROJECT_ID: "projectId",
14
+ ZITADEL_PROJECT_SECRET: "projectSecret"
15
+ };
16
+ function applyAppEnvTemplate(template, handle) {
17
+ const env = {};
18
+ for (const [name, field] of Object.entries(template)) {
19
+ const value = handle[field];
20
+ if (typeof value !== "string" || value.length === 0) throw new Error(`app env template maps "${name}" to handle field "${field}", which the instance handle does not carry`);
21
+ env[name] = value;
22
+ }
23
+ return env;
24
+ }
25
+ //#endregion
26
+ //#region src/handshake.ts
27
+ /**
28
+ * The handshake file carries an InstanceHandle across process boundaries:
29
+ * written by the script that boots + bootstraps the instance, read by
30
+ * Playwright workers (fixtures) and the app dev-server wrapper.
31
+ */
32
+ async function writeHandshake(path, handle) {
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await writeFile(path, `${JSON.stringify(handle, null, 2)}\n`, { mode: 384 });
35
+ }
36
+ function readHandshakeSync(path) {
37
+ const contents = readFileSync(path, "utf8");
38
+ let value;
39
+ try {
40
+ value = JSON.parse(contents);
41
+ } catch (error) {
42
+ throw new Error(`handshake file ${path} contains invalid JSON: ${error.message}`, { cause: error });
43
+ }
44
+ return validateHandle(value, path);
45
+ }
46
+ async function waitForHandshake(path, timeoutMs = 6e4) {
47
+ const deadline = Date.now() + timeoutMs;
48
+ for (;;) try {
49
+ return validateHandle(JSON.parse(await readFile(path, "utf8")), path);
50
+ } catch (error) {
51
+ if (Date.now() >= deadline) throw new Error(`handshake file not readable within ${timeoutMs}ms: ${path} (${error.message})`, { cause: error });
52
+ await setTimeout(250);
53
+ }
54
+ }
55
+ function validateHandle(value, source) {
56
+ const handle = value;
57
+ for (const field of [
58
+ "baseUrl",
59
+ "projectId",
60
+ "projectSecret",
61
+ "schemaId"
62
+ ]) if (typeof handle?.[field] !== "string" || handle[field].length === 0) throw new Error(`handshake file ${source} is missing "${field}"`);
63
+ if (!URL.canParse(handle.baseUrl)) throw new Error(`handshake file ${source} has a malformed "baseUrl": ${handle.baseUrl}`);
64
+ return handle;
65
+ }
66
+ //#endregion
67
+ export { nextAppEnv as a, applyAppEnvTemplate as i, waitForHandshake as n, writeHandshake as r, readHandshakeSync as t };
68
+
69
+ //# sourceMappingURL=handshake-BPtWruO8.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handshake-BPtWruO8.mjs","names":["sleep"],"sources":["../src/app-env.ts","../src/handshake.ts"],"sourcesContent":["import type { InstanceHandle } from \"./types\";\n\n/**\n * Declarative mapping from an app's env var names to InstanceHandle fields.\n * A template (not a function) so it can cross process boundaries: the\n * Playwright config serializes it into the app-runner's environment, where it\n * is applied to the handle read from the handshake file.\n */\nexport type AppEnvTemplate = Record<string, keyof InstanceHandle>;\n\n/**\n * The env shape `@zitadel/sdk-next` apps read. Other frameworks pass their own\n * template — the console maps the same handle fields to `VITE_*`/`CONSOLE_*`\n * names, for example.\n */\nexport const nextAppEnv: AppEnvTemplate = {\n ZITADEL_URL: \"baseUrl\",\n NEXT_PUBLIC_ZITADEL_PROJECT_ID: \"projectId\",\n ZITADEL_PROJECT_SECRET: \"projectSecret\",\n};\n\nexport function applyAppEnvTemplate(\n template: AppEnvTemplate,\n handle: InstanceHandle,\n): Record<string, string> {\n const env: Record<string, string> = {};\n for (const [name, field] of Object.entries(template)) {\n const value = handle[field];\n if (typeof value !== \"string\" || value.length === 0) {\n // Fail instead of silently dropping the var: an app booted without one\n // of its env vars produces a much harder-to-read failure downstream.\n throw new Error(\n `app env template maps \"${name}\" to handle field \"${field}\", which the instance handle does not carry`,\n );\n }\n env[name] = value;\n }\n return env;\n}\n","import { readFileSync } from \"node:fs\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { setTimeout as sleep } from \"node:timers/promises\";\n\nimport type { InstanceHandle } from \"./types\";\n\n/**\n * The handshake file carries an InstanceHandle across process boundaries:\n * written by the script that boots + bootstraps the instance, read by\n * Playwright workers (fixtures) and the app dev-server wrapper.\n */\nexport async function writeHandshake(path: string, handle: InstanceHandle): Promise<void> {\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, `${JSON.stringify(handle, null, 2)}\\n`, { mode: 0o600 });\n}\n\nexport function readHandshakeSync(path: string): InstanceHandle {\n const contents = readFileSync(path, \"utf8\");\n let value: unknown;\n try {\n value = JSON.parse(contents);\n } catch (error) {\n throw new Error(`handshake file ${path} contains invalid JSON: ${(error as Error).message}`, {\n cause: error,\n });\n }\n return validateHandle(value, path);\n}\n\nexport async function waitForHandshake(path: string, timeoutMs = 60_000): Promise<InstanceHandle> {\n const deadline = Date.now() + timeoutMs;\n for (;;) {\n try {\n return validateHandle(JSON.parse(await readFile(path, \"utf8\")), path);\n } catch (error) {\n if (Date.now() >= deadline) {\n throw new Error(\n `handshake file not readable within ${timeoutMs}ms: ${path} (${(error as Error).message})`,\n { cause: error },\n );\n }\n await sleep(250);\n }\n }\n}\n\nfunction validateHandle(value: unknown, source: string): InstanceHandle {\n const handle = value as Partial<InstanceHandle> | null;\n for (const field of [\"baseUrl\", \"projectId\", \"projectSecret\", \"schemaId\"] as const) {\n if (typeof handle?.[field] !== \"string\" || handle[field].length === 0) {\n throw new Error(`handshake file ${source} is missing \"${field}\"`);\n }\n }\n if (!URL.canParse(handle.baseUrl as string)) {\n throw new Error(`handshake file ${source} has a malformed \"baseUrl\": ${handle.baseUrl}`);\n }\n return handle as InstanceHandle;\n}\n"],"mappings":";;;;;;;;;;AAeA,MAAa,aAA6B;CACxC,aAAa;CACb,gCAAgC;CAChC,wBAAwB;CACzB;AAED,SAAgB,oBACd,UACA,QACwB;CACxB,MAAM,MAA8B,EAAE;AACtC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,SAAS,EAAE;EACpD,MAAM,QAAQ,OAAO;AACrB,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,EAGhD,OAAM,IAAI,MACR,0BAA0B,KAAK,qBAAqB,MAAM,6CAC3D;AAEH,MAAI,QAAQ;;AAEd,QAAO;;;;;;;;;ACzBT,eAAsB,eAAe,MAAc,QAAuC;AACxF,OAAM,MAAM,QAAQ,KAAK,EAAE,EAAE,WAAW,MAAM,CAAC;AAC/C,OAAM,UAAU,MAAM,GAAG,KAAK,UAAU,QAAQ,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAO,CAAC;;AAGhF,SAAgB,kBAAkB,MAA8B;CAC9D,MAAM,WAAW,aAAa,MAAM,OAAO;CAC3C,IAAI;AACJ,KAAI;AACF,UAAQ,KAAK,MAAM,SAAS;UACrB,OAAO;AACd,QAAM,IAAI,MAAM,kBAAkB,KAAK,0BAA2B,MAAgB,WAAW,EAC3F,OAAO,OACR,CAAC;;AAEJ,QAAO,eAAe,OAAO,KAAK;;AAGpC,eAAsB,iBAAiB,MAAc,YAAY,KAAiC;CAChG,MAAM,WAAW,KAAK,KAAK,GAAG;AAC9B,SACE,KAAI;AACF,SAAO,eAAe,KAAK,MAAM,MAAM,SAAS,MAAM,OAAO,CAAC,EAAE,KAAK;UAC9D,OAAO;AACd,MAAI,KAAK,KAAK,IAAI,SAChB,OAAM,IAAI,MACR,sCAAsC,UAAU,MAAM,KAAK,IAAK,MAAgB,QAAQ,IACxF,EAAE,OAAO,OAAO,CACjB;AAEH,QAAMA,WAAM,IAAI;;;AAKtB,SAAS,eAAe,OAAgB,QAAgC;CACtE,MAAM,SAAS;AACf,MAAK,MAAM,SAAS;EAAC;EAAW;EAAa;EAAiB;EAAW,CACvE,KAAI,OAAO,SAAS,WAAW,YAAY,OAAO,OAAO,WAAW,EAClE,OAAM,IAAI,MAAM,kBAAkB,OAAO,eAAe,MAAM,GAAG;AAGrE,KAAI,CAAC,IAAI,SAAS,OAAO,QAAkB,CACzC,OAAM,IAAI,MAAM,kBAAkB,OAAO,8BAA8B,OAAO,UAAU;AAE1F,QAAO"}