@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 +98 -37
- package/dist/app-runner.cjs +1 -1
- package/dist/app-runner.mjs +1 -1
- package/dist/{handshake-CggSIoxF.cjs → handshake-CRKcgkfN.cjs} +3 -2
- package/dist/{handshake-CggSIoxF.cjs.map → handshake-CRKcgkfN.cjs.map} +1 -1
- package/dist/{handshake-BPtWruO8.mjs → handshake-ClzWvG8z.mjs} +3 -2
- package/dist/{handshake-BPtWruO8.mjs.map → handshake-ClzWvG8z.mjs.map} +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.d.mts +3 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/playwright.cjs +242 -2
- package/dist/playwright.cjs.map +1 -1
- package/dist/playwright.d.mts +154 -3
- package/dist/playwright.d.mts.map +1 -1
- package/dist/playwright.mjs +234 -3
- package/dist/playwright.mjs.map +1 -1
- package/dist/{src-BIL0PdSd.cjs → src-DirKLqi4.cjs} +223 -267
- package/dist/src-DirKLqi4.cjs.map +1 -0
- package/dist/{src-DkG0V4A6.mjs → src-eTcdx-ZS.mjs} +11 -12
- package/dist/src-eTcdx-ZS.mjs.map +1 -0
- package/dist/supervisor.cjs +2 -2
- package/dist/supervisor.mjs +2 -2
- package/package.json +5 -5
- package/dist/src-BIL0PdSd.cjs.map +0 -1
- package/dist/src-DkG0V4A6.mjs.map +0 -1
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 +
|
|
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
|
|
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
|
|
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
|
|
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
|
|
148
|
-
`
|
|
149
|
-
|
|
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
|
|
155
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
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:
|
|
177
|
-
| Warm restart (existing data dir) |
|
|
178
|
-
| Stop
|
|
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
|
|
215
|
-
runtime
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
into a shared package and swap the lifecycle
|
|
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.
|
package/dist/app-runner.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_handshake = require("./handshake-
|
|
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
|
package/dist/app-runner.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as applyAppEnvTemplate, n as waitForHandshake } from "./handshake-
|
|
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
|
|
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-
|
|
99
|
+
//# sourceMappingURL=handshake-CRKcgkfN.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handshake-
|
|
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
|
|
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-
|
|
70
|
+
//# sourceMappingURL=handshake-ClzWvG8z.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handshake-
|
|
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-
|
|
3
|
-
const require_handshake = require("./handshake-
|
|
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
|
|
58
|
-
*
|
|
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 +
|
|
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
|
*/
|
package/dist/index.d.mts.map
CHANGED
|
@@ -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
|
|
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-
|
|
2
|
-
import { a as bootstrapProject, i as bootLocalServer, n as startLocalZitadel, r as SESSION_COOKIE_NAME, t as connectZitadel } from "./src-
|
|
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 };
|
package/dist/playwright.cjs
CHANGED
|
@@ -1,11 +1,237 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_src = require("./src-
|
|
3
|
-
const require_handshake = require("./handshake-
|
|
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
|
|