@zitadel/testing 0.0.0 → 0.1.0-alpha.18

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zitadel/testing
2
2
 
3
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
4
+ (binary runtime + SQLite by default, no Docker) from test code, create a
5
5
  project with the default login flow, and mint password users that can complete
6
6
  the real login journey immediately.
7
7
 
@@ -54,15 +54,12 @@ export default defineConfig({
54
54
 
55
55
  ```ts
56
56
  // my-login.spec.ts
57
- import { expect, test } from "@zitadel/testing/playwright";
57
+ import { expect, loginWithPassword, test } from "@zitadel/testing/playwright";
58
58
 
59
59
  test("user signs in with password", async ({ page, seed }) => {
60
60
  const user = await seed.user(); // unique email + password, loginable now
61
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();
62
+ await loginWithPassword(page, user); // drives the identifier → password steps
66
63
  await expect(page).toHaveURL(/\/admin/);
67
64
  });
68
65
  ```
@@ -96,6 +93,65 @@ log in through the UI for those. `seed.identity()` complements registration
96
93
  specs: an unused email+password that creates nothing, so the flow under test
97
94
  must create the user.
98
95
 
96
+ ### Drive the login flow
97
+
98
+ Four ceremony helpers complete whole auth journeys against the
99
+ `<zitadel-login>` widget. They are built on the widget's documented
100
+ automation hooks (`zitadel-action-*`, `zitadel-field-*` / `zitadel-input-*`),
101
+ not on translated button texts, so they survive locale and copy changes:
102
+
103
+ ```ts
104
+ import {
105
+ loginWithPassword, // identifier → password (handles combined steps too)
106
+ loginWithPasskey, // identifier-first; or one-tap without an email
107
+ registerWithPassword, // unknown identifier → registration → password path
108
+ registerWithPasskey, // unknown identifier → registration → passkey ceremony
109
+ } from "@zitadel/testing/playwright";
110
+
111
+ await page.goto("/login");
112
+ await registerWithPassword(page, { email, password });
113
+ // assert your app's signed-in surface — the helpers never assert app state
114
+ ```
115
+
116
+ They assume the default flow vocabulary (`submit` / `passkey` /
117
+ `passkey_register` actions, `email` and password fields) and branch only on
118
+ what the flow renders: flows that require extra registration fields get them
119
+ via `profile: [{ field, value }]`, filled when present — a boolean value
120
+ drives a checkbox, a string matches a select option or fills a text-like
121
+ input. For custom flows or single steps, `flowAction(page, name)` /
122
+ `flowField(page, name)` return plain locators for the same hooks, with
123
+ `clickFlowAction` / `fillFlowField` as one-line wrappers. Broad fallbacks
124
+ (accessible names via `{ name }` / `{ label }`, the generic `data-action`
125
+ attribute) are scoped to the `<zitadel-login>` host element — so they never
126
+ match same-named controls in your app's own chrome, and they keep working
127
+ for custom templates that render no automation hooks.
128
+
129
+ ### Test passkey flows
130
+
131
+ `enableVirtualPasskey(page)` — also available as the on-demand `passkey`
132
+ fixture — attaches a CDP virtual authenticator to the page (a platform
133
+ authenticator with discoverable credentials and automatic user presence), so
134
+ the real registration and login ceremonies complete without an OS dialog:
135
+
136
+ ```ts
137
+ test("registers and signs in with a passkey", async ({ page, seed, passkey }) => {
138
+ const who = seed.identity();
139
+ await page.goto("/login");
140
+ await registerWithPasskey(page, { email: who.email });
141
+ await expect.poll(() => passkey.credentialCount()).toBe(1);
142
+ });
143
+ ```
144
+
145
+ Constraints, all inherent to WebAuthn/CDP: Chromium projects only (the CDP
146
+ WebAuthn domain exists nowhere else); the authenticator is bound to the page,
147
+ so register and sign back in on the same page (sign out by clearing cookies
148
+ instead of opening a fresh context); and serve the app on an origin WebAuthn
149
+ accepts as a relying-party ID — HTTPS on a real domain, or `http://localhost`
150
+ for local tests; raw IP origins like `127.0.0.1` are invalid. The default
151
+ `password-first` flow offers passkey registration at the registration-choice
152
+ step; boot `preset: "passkey-first"` for one-tap discoverable-credential
153
+ login flows.
154
+
99
155
  The two in-repo consumers are `apps/demo-next-e2e/playwright.real.config.mts`
100
156
  and `apps/console-e2e/playwright.real.config.mts` — run them with
101
157
  `moon run demo-next-e2e:e2e-real` / `moon run console-e2e:e2e-real`.
@@ -122,7 +178,7 @@ import {
122
178
  const z = await startLocalZitadel({
123
179
  port, // default: free port
124
180
  dir, // default: temp dir, removed on stop (caller dirs are kept)
125
- appOrigins, // registered as the project's previewOrigins
181
+ appOrigins, // registered as the project's preview_origins
126
182
  useCase, // "minimal" (default) | "consumer" | "business"
127
183
  preset, // "password-first" (default)
128
184
  serverBinary, // ZITADEL_SERVER_BINARY override (in-repo: dist/server/nextgen)
@@ -136,7 +192,7 @@ await z.seedUser({ email?, password?, attributes? }); // → { id, email, passwo
136
192
  await z.seedUsers(8, { email?, password?, attributes? }); // per-index templates
137
193
  z.identity(); // unused { email, password } — creates nothing
138
194
  await z.seedSession({ user? }); // → { user, sessionToken, expiresAt, cookie }
139
- await z.stop(); // stop server, reap embedded Postgres, remove owned temp dir
195
+ await z.stop(); // stop server and remove owned temp dir
140
196
  ```
141
197
 
142
198
  `connectZitadel(handle)` returns the same surface minus lifecycle — this is
@@ -144,23 +200,27 @@ what the Playwright fixtures use, and what a future remote-instance mode would
144
200
  build on.
145
201
 
146
202
  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.
203
+ per test, `zitadel` per worker, `passkey` on demand), the flow ceremonies
204
+ (`loginWithPassword`, `loginWithPasskey`, `registerWithPassword`,
205
+ `registerWithPasskey`) with their locator-level escape hatches
206
+ (`flowAction`/`flowField`, `clickFlowAction`/`fillFlowField`), plus
207
+ `withZitadel(options)` returning `{ webServer }` for the config,
208
+ `enableVirtualPasskey(page)` for non-fixture pages, and
209
+ `nextAppEnv`/`applyAppEnvTemplate` for the env-template mechanism described
210
+ above.
150
211
 
151
212
  ## How it works
152
213
 
153
214
  - **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.
215
+ preflight, the health wait, and process-group stop). Swapping this for direct
216
+ library calls later will not change the public API.
157
217
  - **Bootstrap** is the server-side half of `zitadel setup`, no files:
158
- `POST /projects` (unauthenticated; returns the `projectSecret` used as
218
+ `POST /projects` (unauthenticated; returns the `project_secret` used as
159
219
  bearer for everything else) → `POST /schemas` (server assigns the schema id)
160
220
  → `POST /flow_definitions` (default login flow pinned to that schema id).
161
221
  Templates come from `@zitadel/config/defaults`.
162
222
  - **Seeding** is `POST /users` (the body carries `$schema: <schema id>`) +
163
- `PUT /users/{id}/password` with `isChangeRequired: false`.
223
+ `PUT /users/{id}/password` with `is_change_required: false`.
164
224
 
165
225
  ## Parallelism model
166
226
 
@@ -169,13 +229,13 @@ project, so per-test `seed.user()` calls are isolation enough for login-flow
169
229
  tests, and tests run fully parallel against the shared instance
170
230
  (demonstrated by `demo-next-e2e:e2e-real`, 2 workers).
171
231
 
172
- Measured on an arm64 macBook (dev build, July 2026):
232
+ Typical timings with the SQLite local default (dev build, July 2026 — estimates, not a fresh remeasure):
173
233
 
174
234
  | Operation | Time |
175
235
  | --- | --- |
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 |
236
+ | Cold boot (fresh data dir: SQLite migrate + health) | typically under a few seconds |
237
+ | Warm restart (existing data dir) | typically under a second |
238
+ | Stop | typically under a second |
179
239
  | Bootstrap (project + schema + flow) | ~100ms |
180
240
  | Seed one user (create + password) | ~50ms |
181
241
  | Full `e2e-real` suite (boot + Next dev + 2 browser tests) | ~25s |
@@ -211,8 +271,8 @@ Customer installs need none of this.
211
271
  processes (Playwright workers) are unaffected.
212
272
  - macOS/Linux only for now. Not because of the port preflight (it degrades
213
273
  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.
274
+ process-group stop for the local binary runtime. Revisit when Windows local
275
+ runtime support is a goal.
216
276
 
217
277
  ## Roadmap
218
278
 
@@ -238,19 +298,20 @@ on top, in intended order:
238
298
  `bootstrapProject({ baseUrl })` + `connectZitadel(handle)` already compose
239
299
  into this today; formalizing it means cleanup semantics and docs.
240
300
  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.
301
+ preview deployment (e.g. `@zitadel/testing/vercel`), returning the same
302
+ `InstanceHandle` so fixtures don't change. Gated on a spike: SQLite (or
303
+ another Sandbox-friendly store) on the Sandbox image, forwarded proto/host
304
+ handling, secure cookies + issuer + handoff verification through
305
+ `sandbox.domain()`, registering the preview URL as an allowed origin
306
+ post-deploy (`PATCH /projects`), and cleanup that survives failed runs.
247
307
  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.
308
+ customer would in CI, and the kit ships on the release train (release
309
+ manifest + changeset `fixed` group), versioned in lockstep with
310
+ `@zitadel/cli`. Remaining: the Windows decision for the local binary
311
+ runtime and the stability commitment when the train leaves alpha.
312
+
313
+ Parked until server support exists: passkey *seeding* (pre-registered WebAuthn
314
+ credentials) UI-driven passkey ceremonies are covered today by
315
+ `enableVirtualPasskey`. Independent refactor: extract
316
+ `apps/cli/src/lib/local-server` into a shared package and swap the lifecycle
317
+ shell-out for imports.
@@ -1,4 +1,4 @@
1
- const require_handshake = require("./handshake-CggSIoxF.cjs");
1
+ const require_handshake = require("./handshake-CRKcgkfN.cjs");
2
2
  const require_orchestration = require("./orchestration-D7QBgQ0l.cjs");
3
3
  let node_child_process = require("node:child_process");
4
4
  //#region src/app-runner.ts
@@ -1,4 +1,4 @@
1
- import { i as applyAppEnvTemplate, n as waitForHandshake } from "./handshake-BPtWruO8.mjs";
1
+ import { i as applyAppEnvTemplate, n as waitForHandshake } from "./handshake-ClzWvG8z.mjs";
2
2
  import { i as parseAppRunnerConfig, o as requireHandshakePath, t as APP_RUNNER_CONFIG_ENV } from "./orchestration-C640_1Lg.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region src/app-runner.ts
@@ -53,13 +53,14 @@ async function waitForHandshake(path, timeoutMs = 6e4) {
53
53
  }
54
54
  }
55
55
  function validateHandle(value, source) {
56
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`handshake file ${source} is not an object`);
56
57
  const handle = value;
57
58
  for (const field of [
58
59
  "baseUrl",
59
60
  "projectId",
60
61
  "projectSecret",
61
62
  "schemaId"
62
- ]) if (typeof handle?.[field] !== "string" || handle[field].length === 0) throw new Error(`handshake file ${source} is missing "${field}"`);
63
+ ]) if (typeof handle[field] !== "string" || handle[field].length === 0) throw new Error(`handshake file ${source} is missing "${field}"`);
63
64
  if (!URL.canParse(handle.baseUrl)) throw new Error(`handshake file ${source} has a malformed "baseUrl": ${handle.baseUrl}`);
64
65
  return handle;
65
66
  }
@@ -95,4 +96,4 @@ Object.defineProperty(exports, "writeHandshake", {
95
96
  }
96
97
  });
97
98
 
98
- //# sourceMappingURL=handshake-CggSIoxF.cjs.map
99
+ //# sourceMappingURL=handshake-CRKcgkfN.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"handshake-CggSIoxF.cjs","names":[],"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,QAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,KAAK,EAAE,EAAE,WAAW,MAAM,CAAC;AAC/C,QAAA,GAAA,iBAAA,WAAgB,MAAM,GAAG,KAAK,UAAU,QAAQ,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAO,CAAC;;AAGhF,SAAgB,kBAAkB,MAA8B;CAC9D,MAAM,YAAA,GAAA,QAAA,cAAwB,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,OAAA,GAAA,iBAAA,UAAe,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,SAAA,GAAA,qBAAA,YAAY,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"}
1
+ {"version":3,"file":"handshake-CRKcgkfN.cjs","names":[],"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 if (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n throw new Error(`handshake file ${source} is not an object`);\n }\n const handle = value as Partial<InstanceHandle>;\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,QAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,KAAK,EAAE,EAAE,WAAW,MAAM,CAAC;AAC/C,QAAA,GAAA,iBAAA,WAAgB,MAAM,GAAG,KAAK,UAAU,QAAQ,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAO,CAAC;;AAGhF,SAAgB,kBAAkB,MAA8B;CAC9D,MAAM,YAAA,GAAA,QAAA,cAAwB,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,OAAA,GAAA,iBAAA,UAAe,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,SAAA,GAAA,qBAAA,YAAY,IAAI;;;AAKtB,SAAS,eAAe,OAAgB,QAAgC;AACtE,KAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,MAAM,CACrE,OAAM,IAAI,MAAM,kBAAkB,OAAO,mBAAmB;CAE9D,MAAM,SAAS;AACf,MAAK,MAAM,SAAS;EAAC;EAAW;EAAa;EAAiB;EAAW,CACvE,KAAI,OAAO,OAAO,WAAW,YAAY,OAAO,OAAO,WAAW,EAChE,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"}
@@ -53,17 +53,18 @@ async function waitForHandshake(path, timeoutMs = 6e4) {
53
53
  }
54
54
  }
55
55
  function validateHandle(value, source) {
56
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`handshake file ${source} is not an object`);
56
57
  const handle = value;
57
58
  for (const field of [
58
59
  "baseUrl",
59
60
  "projectId",
60
61
  "projectSecret",
61
62
  "schemaId"
62
- ]) if (typeof handle?.[field] !== "string" || handle[field].length === 0) throw new Error(`handshake file ${source} is missing "${field}"`);
63
+ ]) if (typeof handle[field] !== "string" || handle[field].length === 0) throw new Error(`handshake file ${source} is missing "${field}"`);
63
64
  if (!URL.canParse(handle.baseUrl)) throw new Error(`handshake file ${source} has a malformed "baseUrl": ${handle.baseUrl}`);
64
65
  return handle;
65
66
  }
66
67
  //#endregion
67
68
  export { nextAppEnv as a, applyAppEnvTemplate as i, waitForHandshake as n, writeHandshake as r, readHandshakeSync as t };
68
69
 
69
- //# sourceMappingURL=handshake-BPtWruO8.mjs.map
70
+ //# sourceMappingURL=handshake-ClzWvG8z.mjs.map
@@ -1 +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"}
1
+ {"version":3,"file":"handshake-ClzWvG8z.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 if (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n throw new Error(`handshake file ${source} is not an object`);\n }\n const handle = value as Partial<InstanceHandle>;\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;AACtE,KAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,MAAM,CACrE,OAAM,IAAI,MAAM,kBAAkB,OAAO,mBAAmB;CAE9D,MAAM,SAAS;AACf,MAAK,MAAM,SAAS;EAAC;EAAW;EAAa;EAAiB;EAAW,CACvE,KAAI,OAAO,OAAO,WAAW,YAAY,OAAO,OAAO,WAAW,EAChE,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"}
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_src = require("./src-BIL0PdSd.cjs");
3
- const require_handshake = require("./handshake-CggSIoxF.cjs");
2
+ const require_src = require("./src-DirKLqi4.cjs");
3
+ const require_handshake = require("./handshake-CRKcgkfN.cjs");
4
4
  exports.SESSION_COOKIE_NAME = require_src.SESSION_COOKIE_NAME;
5
5
  exports.applyAppEnvTemplate = require_handshake.applyAppEnvTemplate;
6
6
  exports.bootLocalServer = require_src.bootLocalServer;
package/dist/index.d.mts CHANGED
@@ -54,9 +54,8 @@ interface BootedServer {
54
54
  /**
55
55
  * Boot an ephemeral local server by shelling out to `zitadel start` and parse
56
56
  * its JSON envelope. The CLI owns the subtle parts (port preflight, health
57
- * wait, process-group stop, embedded-Postgres reaping), so this module stays a
58
- * thin adapter; swapping it for direct library calls later must not change the
59
- * shape returned here.
57
+ * wait, process-group stop), so this module stays a thin adapter; swapping it
58
+ * for direct library calls later must not change the shape returned here.
60
59
  */
61
60
  declare function bootLocalServer(options?: BootServerOptions): Promise<BootedServer>;
62
61
  //#endregion
@@ -83,7 +82,7 @@ type StartLocalZitadelOptions = BootServerOptions & Omit<BootstrapProjectOptions
83
82
  */
84
83
  declare function connectZitadel(handle: InstanceHandle): ConnectedZitadel;
85
84
  /**
86
- * Boot an ephemeral local instance (binary runtime + embedded Postgres, no
85
+ * Boot an ephemeral local instance (binary runtime + SQLite by default, no
87
86
  * Docker) and bootstrap a project + default schema + login flow on it. The
88
87
  * result can seed loginable password users immediately.
89
88
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/bootstrap.ts","../src/lifecycle.ts","../src/handshake.ts","../src/session.ts","../src/index.ts"],"mappings":";;;;;UASiB,uBAAA;EACf,OAAA;EACA,WAAA;;AAFF;;;EAOE,UAAA;EACA,MAAA,GAAS,WAAA;EACT,OAAA,GAAU,YAAA;AAAA;AAAA,UAGK,mBAAA;EACf,SAAA;EACA,aAAA;EACA,aAAA;EACA,QAAA;EACA,MAAA;AAAA;;;;;;;iBAWoB,gBAAA,CACpB,OAAA,EAAS,uBAAA,GACR,OAAA,CAAQ,mBAAA;;;UCzBM,iBAAA;;EAEf,IAAA;;ADPF;;;ECYE,GAAA;EDXA;ECaA,YAAA;EDPA;ECSA,IAAA;EDRS;ECUT,MAAA;EACA,SAAA;AAAA;AAAA,UAGe,YAAA;EACf,OAAA;EACA,OAAA,EAAS,mBAAA;EACT,IAAA,IAAQ,OAAA;AAAA;;;;;;;;iBAUY,eAAA,CAAgB,OAAA,GAAS,iBAAA,GAAyB,OAAA,CAAQ,YAAA;;;;;;;ADnChF;iBEGsB,cAAA,CAAe,IAAA,UAAc,MAAA,EAAQ,cAAA,GAAiB,OAAA;AAAA,iBAK5D,iBAAA,CAAkB,IAAA,WAAe,cAAA;AAAA,iBAa3B,gBAAA,CAAiB,IAAA,UAAc,SAAA,YAAqB,OAAA,CAAQ,cAAA;;;;cCvBrE,mBAAA;;;KCED,wBAAA,GAA2B,iBAAA,GACrC,IAAA,CAAK,uBAAA;;AJDP;;;;iBIQgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,gBAAA;;;;;;iBA6BlC,iBAAA,CACpB,OAAA,GAAS,wBAAA,GACR,OAAA,CAAQ,YAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/bootstrap.ts","../src/lifecycle.ts","../src/handshake.ts","../src/session.ts","../src/index.ts"],"mappings":";;;;;UASiB,uBAAA;EACf,OAAA;EACA,WAAA;;AAFF;;;EAOE,UAAA;EACA,MAAA,GAAS,WAAA;EACT,OAAA,GAAU,YAAA;AAAA;AAAA,UAGK,mBAAA;EACf,SAAA;EACA,aAAA;EACA,aAAA;EACA,QAAA;EACA,MAAA;AAAA;;;;;;;iBAWoB,gBAAA,CACpB,OAAA,EAAS,uBAAA,GACR,OAAA,CAAQ,mBAAA;;;UCzBM,iBAAA;;EAEf,IAAA;;ADPF;;;ECYE,GAAA;EDXA;ECaA,YAAA;EDPA;ECSA,IAAA;EDRS;ECUT,MAAA;EACA,SAAA;AAAA;AAAA,UAGe,YAAA;EACf,OAAA;EACA,OAAA,EAAS,mBAAA;EACT,IAAA,IAAQ,OAAA;AAAA;;;;;;;iBASY,eAAA,CAAgB,OAAA,GAAS,iBAAA,GAAyB,OAAA,CAAQ,YAAA;;;;;;;ADlChF;iBEGsB,cAAA,CAAe,IAAA,UAAc,MAAA,EAAQ,cAAA,GAAiB,OAAA;AAAA,iBAK5D,iBAAA,CAAkB,IAAA,WAAe,cAAA;AAAA,iBAa3B,gBAAA,CAAiB,IAAA,UAAc,SAAA,YAAqB,OAAA,CAAQ,cAAA;;;;cCvBrE,mBAAA;;;KCED,wBAAA,GAA2B,iBAAA,GACrC,IAAA,CAAK,uBAAA;;AJDP;;;;iBIQgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,gBAAA;;;;;;iBA6BlC,iBAAA,CACpB,OAAA,GAAS,wBAAA,GACR,OAAA,CAAQ,YAAA"}
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { a as nextAppEnv, i as applyAppEnvTemplate, n as waitForHandshake, r as writeHandshake, t as readHandshakeSync } from "./handshake-BPtWruO8.mjs";
2
- import { a as bootstrapProject, i as bootLocalServer, n as startLocalZitadel, r as SESSION_COOKIE_NAME, t as connectZitadel } from "./src-DkG0V4A6.mjs";
1
+ import { a as nextAppEnv, i as applyAppEnvTemplate, n as waitForHandshake, r as writeHandshake, t as readHandshakeSync } from "./handshake-ClzWvG8z.mjs";
2
+ import { a as bootstrapProject, i as bootLocalServer, n as startLocalZitadel, r as SESSION_COOKIE_NAME, t as connectZitadel } from "./src-eTcdx-ZS.mjs";
3
3
  export { SESSION_COOKIE_NAME, applyAppEnvTemplate, bootLocalServer, bootstrapProject, connectZitadel, nextAppEnv, readHandshakeSync, startLocalZitadel, waitForHandshake, writeHandshake };
@@ -1,11 +1,237 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_src = require("./src-BIL0PdSd.cjs");
3
- const require_handshake = require("./handshake-CggSIoxF.cjs");
2
+ const require_src = require("./src-DirKLqi4.cjs");
3
+ const require_handshake = require("./handshake-CRKcgkfN.cjs");
4
4
  const require_orchestration = require("./orchestration-D7QBgQ0l.cjs");
5
5
  let node_fs = require("node:fs");
6
6
  let node_url = require("node:url");
7
7
  let node_path = require("node:path");
8
8
  let _playwright_test = require("@playwright/test");
9
+ //#region src/passkey.ts
10
+ /**
11
+ * Attach a virtual passkey authenticator to the page via the Chrome DevTools
12
+ * Protocol. The options mirror a platform authenticator with discoverable
13
+ * credentials and automatic user presence — the profile the consumer journey
14
+ * has run in CI since passkey coverage became mandatory there.
15
+ *
16
+ * Chromium only: the CDP WebAuthn domain does not exist in WebKit or Firefox.
17
+ * The authenticator is bound to this page — drive registration and the later
18
+ * login from the same page, or the credential is gone. Serve the app under
19
+ * test on an origin WebAuthn accepts as a relying-party ID: HTTPS on a real
20
+ * domain, or `http://localhost` for local runs — raw IP origins such as
21
+ * `http://127.0.0.1` are invalid RP IDs.
22
+ */
23
+ async function enableVirtualPasskey(page) {
24
+ let client;
25
+ try {
26
+ client = await page.context().newCDPSession(page);
27
+ } catch (error) {
28
+ throw new Error("enableVirtualPasskey: could not open a CDP session — passkey testing needs Chromium's virtual authenticator, so run passkey specs in a Chromium project.", { cause: error });
29
+ }
30
+ let authenticatorId;
31
+ try {
32
+ await client.send("WebAuthn.enable");
33
+ ({authenticatorId} = await client.send("WebAuthn.addVirtualAuthenticator", { options: {
34
+ protocol: "ctap2",
35
+ transport: "internal",
36
+ hasResidentKey: true,
37
+ hasUserVerification: true,
38
+ isUserVerified: true,
39
+ automaticPresenceSimulation: true
40
+ } }));
41
+ } catch (error) {
42
+ await client.detach().catch(() => void 0);
43
+ throw error;
44
+ }
45
+ return {
46
+ authenticatorId,
47
+ async credentialCount() {
48
+ const { credentials } = await client.send("WebAuthn.getCredentials", { authenticatorId });
49
+ return credentials.length;
50
+ },
51
+ async dispose() {
52
+ try {
53
+ await client.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
54
+ } catch {} finally {
55
+ await client.detach().catch(() => void 0);
56
+ }
57
+ }
58
+ };
59
+ }
60
+ //#endregion
61
+ //#region src/flows.ts
62
+ /**
63
+ * A union locator resolves in DOM order across the whole page, so every
64
+ * candidate built from a broad selector — accessible names, labels, the
65
+ * generic `data-action` attribute — is scoped to the `<zitadel-login>`
66
+ * host; otherwise a same-named control in the app's own chrome (header
67
+ * nav, footer forms) could win the union. The host element exists no
68
+ * matter what the tenant's template renders, so these fallbacks keep
69
+ * working for custom templates that emit no automation hooks — the case
70
+ * they exist for. The `zitadel-*` testid hooks and the `zl-button` atom
71
+ * are namespaced and stay page-global.
72
+ */
73
+ function widgetRoot(page) {
74
+ return page.locator("zitadel-login");
75
+ }
76
+ /**
77
+ * Escape a value for use inside a double-quoted CSS attribute selector,
78
+ * per the CSSOM "serialize a string" rules: NUL becomes U+FFFD, control
79
+ * characters become hex code-point escapes, quote and backslash are
80
+ * backslash-escaped. C1 controls (U+0080–U+009F) are escaped too — CSSOM
81
+ * itself leaves them literal, but the escaped form is equivalent and
82
+ * survives stricter-than-spec selector parsers. Anything the flow schema
83
+ * accepts as an action name yields a parseable selector.
84
+ */
85
+ function cssAttributeValue(value) {
86
+ let out = "";
87
+ for (const ch of value) {
88
+ const code = ch.codePointAt(0) ?? 0;
89
+ if (code === 0) out += "�";
90
+ else if (code <= 31 || code >= 127 && code <= 159) out += `\\${code.toString(16)} `;
91
+ else if (ch === "\"" || ch === "\\") out += `\\${ch}`;
92
+ else out += ch;
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * Locator for a flow action control by its declared action name. Matches
98
+ * every hook shape the default template emits — host testid, native
99
+ * shadow button/link testids, and the raw `action`/`data-action`
100
+ * attributes (the recover link carries only `data-action`).
101
+ */
102
+ function flowAction(page, action, options = {}) {
103
+ const attributeSafe = cssAttributeValue(action);
104
+ let candidates = page.getByTestId(`zitadel-action-${action}`).or(page.getByTestId(`zitadel-action-${action}-button`)).or(page.getByTestId(`zitadel-action-${action}-link`)).or(page.locator(`zl-button[action="${attributeSafe}"]`)).or(widgetRoot(page).locator(`[data-action="${attributeSafe}"]`));
105
+ if (options.name) candidates = candidates.or(widgetRoot(page).getByRole("button", { name: options.name })).or(widgetRoot(page).getByRole("link", { name: options.name }));
106
+ return candidates.first();
107
+ }
108
+ /**
109
+ * Locator for a flow field's input by its normalised hook token
110
+ * (`email`, `password`, a user-schema property name).
111
+ */
112
+ function flowField(page, field, options = {}) {
113
+ let candidates = page.getByTestId(`zitadel-input-${field}`).or(page.getByTestId(`zitadel-field-${field}`).locator("input"));
114
+ if (options.label) candidates = candidates.or(widgetRoot(page).getByLabel(options.label));
115
+ return candidates.first();
116
+ }
117
+ /** Click a flow action (auto-waits like any locator click). */
118
+ async function clickFlowAction(page, action, options) {
119
+ await flowAction(page, action, options).click();
120
+ }
121
+ /** Fill a flow field (auto-waits like any locator fill). */
122
+ async function fillFlowField(page, field, value, options) {
123
+ await flowField(page, field, options).fill(value);
124
+ }
125
+ /**
126
+ * Sign in with email and password from the flow's entry step. Handles both
127
+ * the default split shape (identifier → password) and flows that render
128
+ * the password field on the entry step.
129
+ */
130
+ async function loginWithPassword(page, { email, password }) {
131
+ await emailField(page).fill(email);
132
+ const password_ = passwordField(page);
133
+ if (!await password_.isVisible().catch(() => false)) await flowAction(page, "submit").click();
134
+ await password_.fill(password);
135
+ await flowAction(page, "submit").click();
136
+ }
137
+ /**
138
+ * Sign in with a passkey. With `email`, fills the identifier and takes the
139
+ * step's passkey action; without, taps the entry step's passkey action
140
+ * directly (discoverable-credential one-tap, e.g. the passkey-first
141
+ * preset). Pair with `enableVirtualPasskey` / the `passkey` fixture in
142
+ * headless runs — the ceremony completes automatically once the widget
143
+ * issues the WebAuthn challenge.
144
+ */
145
+ async function loginWithPasskey(page, options = {}) {
146
+ if (options.email !== void 0) await emailField(page).fill(options.email);
147
+ await flowAction(page, "passkey").click();
148
+ }
149
+ /**
150
+ * Register a new user with a password: enter the (unknown) identifier,
151
+ * advance into the registration step, continue on the password path, and
152
+ * submit the password. Ends when the final submit is clicked — assert your
153
+ * app's signed-in surface afterwards. Flows that route through steps the
154
+ * default flow does not (e.g. a passkey upsell) need caller-side handling
155
+ * after this returns.
156
+ */
157
+ async function registerWithPassword(page, { email, password, profile }) {
158
+ await advanceToRegistration(page, email, profile);
159
+ const password_ = passwordField(page);
160
+ if (!await password_.isVisible().catch(() => false)) await flowAction(page, "submit").click();
161
+ await password_.fill(password);
162
+ await flowAction(page, "submit").click();
163
+ }
164
+ /**
165
+ * Register a new user with a passkey: enter the (unknown) identifier,
166
+ * advance into the registration step, and take its `passkey_register`
167
+ * action. Requires an authenticator (see `loginWithPasskey`). The default
168
+ * flow completes registration from the ceremony directly.
169
+ */
170
+ async function registerWithPasskey(page, { email, profile }) {
171
+ await advanceToRegistration(page, email, profile);
172
+ await flowAction(page, "passkey_register").click();
173
+ }
174
+ function emailField(page) {
175
+ return flowField(page, "email", { label: /email/i });
176
+ }
177
+ function passwordField(page) {
178
+ return flowField(page, "password", { label: /password/i });
179
+ }
180
+ /**
181
+ * From the entry step: submit the unknown identifier (the default flow's
182
+ * `user_not_found` transition routes to registration), or take an explicit
183
+ * `register` navigate action when the entry step is a combined
184
+ * email+password step — there, submitting would attempt a password sign-in
185
+ * instead. Then wait for the registration step and fill what it renders.
186
+ */
187
+ async function advanceToRegistration(page, email, profile) {
188
+ await emailField(page).fill(email);
189
+ if (await passwordField(page).isVisible().catch(() => false)) await flowAction(page, "register").click();
190
+ else await flowAction(page, "submit").click();
191
+ await expectRegistrationStep(page);
192
+ await fillIfVisible(emailField(page), email);
193
+ for (const entry of profile ?? []) await fillProfileEntry(page, entry);
194
+ }
195
+ /**
196
+ * Fill one registration field with the verb its control needs: booleans
197
+ * check the `zl-checkbox` native input, strings prefer the `zl-select`
198
+ * native select (option matched by value, falling back to label) and
199
+ * otherwise fill a text-like input. The select/checkbox natives carry the
200
+ * name-first testids the atoms document (`zitadel-select-*`,
201
+ * `zitadel-checkbox-*`); templates without those hooks drive such fields
202
+ * via their own locators.
203
+ */
204
+ async function fillProfileEntry(page, entry) {
205
+ if (typeof entry.value === "boolean") {
206
+ const checkbox = page.getByTestId(`zitadel-checkbox-${entry.field}`).first();
207
+ if (await checkbox.isVisible().catch(() => false)) await checkbox.setChecked(entry.value);
208
+ return;
209
+ }
210
+ const select = page.getByTestId(`zitadel-select-${entry.field}`).first();
211
+ if (await select.isVisible().catch(() => false)) {
212
+ await select.selectOption(entry.value);
213
+ return;
214
+ }
215
+ await fillIfVisible(flowField(page, entry.field, { label: entry.label }), entry.value);
216
+ }
217
+ /**
218
+ * Barrier between the entry submit and probing the registration step's
219
+ * fields: without it, optional-field probes race the re-render and read
220
+ * the outgoing step. The default flow's registration step declares
221
+ * `passkey_register` — a structural, locale-independent signal; the
222
+ * heading regex covers password-only flows on the default English
223
+ * template.
224
+ */
225
+ async function expectRegistrationStep(page) {
226
+ await flowAction(page, "passkey_register").or(widgetRoot(page).getByRole("heading", { name: /create|register|sign up|no-account/i })).first().waitFor({
227
+ state: "visible",
228
+ timeout: 3e4
229
+ });
230
+ }
231
+ async function fillIfVisible(field, value) {
232
+ if (await field.isVisible().catch(() => false)) await field.fill(value);
233
+ }
234
+ //#endregion
9
235
  //#region src/playwright-config.ts
10
236
  /**
11
237
  * Generate the Playwright `webServer` entries that boot an ephemeral seeded
@@ -124,6 +350,11 @@ const test = _playwright_test.test.extend({
124
350
  })
125
351
  });
126
352
  },
353
+ passkey: async ({ page }, use) => {
354
+ const passkey = await enableVirtualPasskey(page);
355
+ await use(passkey);
356
+ await passkey.dispose();
357
+ },
127
358
  authenticatedPage: async ({ browser, zitadel, baseURL }, use) => {
128
359
  if (!baseURL) throw new Error("authenticatedPage requires `use.baseURL` so the session cookie can be scoped to the app under test.");
129
360
  const session = await zitadel.seedSession({ origin: baseURL });
@@ -143,13 +374,22 @@ const test = _playwright_test.test.extend({
143
374
  });
144
375
  //#endregion
145
376
  exports.applyAppEnvTemplate = require_handshake.applyAppEnvTemplate;
377
+ exports.clickFlowAction = clickFlowAction;
378
+ exports.enableVirtualPasskey = enableVirtualPasskey;
146
379
  Object.defineProperty(exports, "expect", {
147
380
  enumerable: true,
148
381
  get: function() {
149
382
  return _playwright_test.expect;
150
383
  }
151
384
  });
385
+ exports.fillFlowField = fillFlowField;
386
+ exports.flowAction = flowAction;
387
+ exports.flowField = flowField;
388
+ exports.loginWithPasskey = loginWithPasskey;
389
+ exports.loginWithPassword = loginWithPassword;
152
390
  exports.nextAppEnv = require_handshake.nextAppEnv;
391
+ exports.registerWithPasskey = registerWithPasskey;
392
+ exports.registerWithPassword = registerWithPassword;
153
393
  exports.test = test;
154
394
  exports.withZitadel = withZitadel;
155
395