@cellajs/cli 0.6.2 → 0.6.5

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/dist/a11y.js ADDED
@@ -0,0 +1,309 @@
1
+ // src/tools/drive.ts
2
+ import { spawnSync as spawnSync2 } from "child_process";
3
+ import { existsSync as existsSync2 } from "fs";
4
+ import process2 from "process";
5
+
6
+ // src/utils/app-facts.ts
7
+ import { execFileSync, spawnSync } from "child_process";
8
+ import { existsSync, readFileSync } from "fs";
9
+ import { createRequire } from "module";
10
+ import { dirname, join, resolve } from "path";
11
+ import { pathToFileURL } from "url";
12
+ import { z } from "zod";
13
+
14
+ // src/utils/env.ts
15
+ import process from "process";
16
+ function getEnv(name) {
17
+ return process.env[name];
18
+ }
19
+ function setEnv(name, value) {
20
+ process.env[name] = value;
21
+ }
22
+
23
+ // src/utils/app-facts.ts
24
+ var appConfigSchema = z.object({
25
+ frontendUrl: z.string(),
26
+ backendUrl: z.string(),
27
+ devPortOffset: z.number(),
28
+ services: z.record(
29
+ z.string(),
30
+ z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() })
31
+ )
32
+ });
33
+ async function importFromWorkspace(forkPath, workspace, name) {
34
+ const require2 = createRequire(join(forkPath, workspace, "package.json"));
35
+ let manifestPath;
36
+ try {
37
+ manifestPath = require2.resolve(`${name}/package.json`);
38
+ } catch {
39
+ }
40
+ const root = manifestPath ? JSON.parse(readFileSync(manifestPath, "utf8")).exports?.["."] : void 0;
41
+ const esm = typeof root?.import === "string" ? root.import : root?.import?.default;
42
+ if (manifestPath && typeof esm === "string") return import(pathToFileURL(join(dirname(manifestPath), esm)).href);
43
+ try {
44
+ return require2(name);
45
+ } catch {
46
+ throw new Error(`${name} is not installed in ${workspace}: run \`pnpm --filter ${workspace} add -D ${name}\``);
47
+ }
48
+ }
49
+
50
+ // src/tools/drive.ts
51
+ function loadPlaywright(forkPath = process2.cwd()) {
52
+ return importFromWorkspace(forkPath, "frontend", "playwright");
53
+ }
54
+ function mintSession(email, { forkPath = process2.cwd(), env, hours = 2 } = {}) {
55
+ const minted = spawnSync2("pnpm", ["--silent", "--filter", "backend", "session:mint", email, String(hours)], {
56
+ cwd: forkPath,
57
+ env,
58
+ encoding: "utf8"
59
+ });
60
+ const cookie = /^((?:__Host-)?([\w-]+)-session-v\d+)=(\S+)$/m.exec(minted.stdout);
61
+ const api = /^curl (\S+)\/me /m.exec(minted.stdout);
62
+ if (!cookie || !api) throw new Error(`could not mint a session for ${email}:
63
+ ${minted.stdout}
64
+ ${minted.stderr}`);
65
+ const frontendUrl = new URL(api[1]).origin;
66
+ return {
67
+ email,
68
+ frontendUrl,
69
+ backendUrl: api[1],
70
+ slug: cookie[2],
71
+ cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: "Strict" },
72
+ header: `${cookie[1]}=${cookie[3]}`
73
+ };
74
+ }
75
+ async function signOut(session) {
76
+ if (!session) return;
77
+ const headers = { cookie: session.header, "content-type": "application/json" };
78
+ await fetch(`${session.backendUrl}/auth/sign-out`, { method: "POST", headers }).catch(() => {
79
+ });
80
+ }
81
+ async function newContext(browser, { session, uiStore, ...options } = {}) {
82
+ const context = await browser.newContext(options);
83
+ await context.addInitScript({ content: "window.__name = (fn) => fn;" });
84
+ if (session) await context.addCookies([session.cookie]);
85
+ if (uiStore) {
86
+ await context.addInitScript(
87
+ ([key, fields]) => {
88
+ const stored = JSON.parse(localStorage.getItem(key) ?? "{}");
89
+ const state = { ...stored.state };
90
+ for (const [name, value] of Object.entries(fields)) {
91
+ const before = state[name];
92
+ state[name] = Array.isArray(value) && Array.isArray(before) ? [.../* @__PURE__ */ new Set([...before, ...value])] : value;
93
+ }
94
+ localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));
95
+ },
96
+ [`${uiStore.slug}-ui`, uiStore.state]
97
+ );
98
+ }
99
+ return context;
100
+ }
101
+ async function settle(page, { quiet = 400, cap = 3e3 } = {}) {
102
+ const waited = page.evaluate(
103
+ ([quietMs, capMs]) => new Promise((resolve2) => {
104
+ const done = () => {
105
+ observer.disconnect();
106
+ clearTimeout(timer);
107
+ clearTimeout(limit);
108
+ resolve2();
109
+ };
110
+ let timer = setTimeout(done, quietMs);
111
+ const limit = setTimeout(done, capMs);
112
+ const observer = new MutationObserver(() => {
113
+ clearTimeout(timer);
114
+ timer = setTimeout(done, quietMs);
115
+ });
116
+ observer.observe(document, { subtree: true, childList: true, characterData: true });
117
+ }),
118
+ [quiet, cap]
119
+ );
120
+ await waited.catch(() => page.waitForTimeout(quiet));
121
+ }
122
+ async function openPage(context, url, { timeout = 3e4 } = {}) {
123
+ const page = await context.newPage();
124
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
125
+ await page.locator('main, [role="main"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });
126
+ await settle(page);
127
+ const landed = new URL(page.url()).pathname;
128
+ const asked = new URL(url).pathname;
129
+ if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);
130
+ return page;
131
+ }
132
+
133
+ // src/tools/a11y/define.ts
134
+ import { z as z3 } from "zod";
135
+
136
+ // src/tools/adapters.ts
137
+ import { existsSync as existsSync3 } from "fs";
138
+ import { join as join2 } from "path";
139
+ import { pathToFileURL as pathToFileURL2 } from "url";
140
+ import { z as z2 } from "zod";
141
+ var signedIn = ({ email, frontendUrl, backendUrl, header }) => ({
142
+ email,
143
+ frontendUrl,
144
+ backendUrl,
145
+ cookie: header
146
+ });
147
+ var fn = () => z2.custom((value) => typeof value === "function", "expected a function");
148
+ var placeholdersSchema = z2.record(z2.string(), fn());
149
+ function fillPath(path, values, file) {
150
+ return path.replace(/\{(\w+)\}/g, (_, name) => {
151
+ const value = values[name];
152
+ if (!value) throw new Error(`no value for {${name}}: its resolver in ${file} found nothing for the signed-in user`);
153
+ return value;
154
+ });
155
+ }
156
+ async function importAdapter(forkPath, file) {
157
+ const { tsImport } = await importFromWorkspace(forkPath, ".", "tsx/esm/api");
158
+ const module = await tsImport(pathToFileURL2(join2(forkPath, file)).href, import.meta.url);
159
+ return module.default;
160
+ }
161
+ function parsed(file, schema, value) {
162
+ const result = schema.safeParse(value);
163
+ if (result.success) return result.data;
164
+ throw new Error(`${file} does not fit this version of the CLI:
165
+ ${z2.prettifyError(result.error)}`);
166
+ }
167
+ async function loadAdapter(forkPath, tool, baseSchema2, appSchema2) {
168
+ const baseFile = `cella/${tool}.ts`;
169
+ const appFile = `cella/${tool}.app.ts`;
170
+ if (!existsSync3(join2(forkPath, baseFile))) {
171
+ throw new Error(`${baseFile} is missing: upgrade the app from cella to get the ${tool} adapter`);
172
+ }
173
+ const base = parsed(baseFile, baseSchema2, await importAdapter(forkPath, baseFile));
174
+ const app = existsSync3(join2(forkPath, appFile)) ? parsed(appFile, appSchema2, await importAdapter(forkPath, appFile)) : void 0;
175
+ return { base, app };
176
+ }
177
+ function applyDelta(base, { remove = [], add = [] }, file) {
178
+ const unknown = remove.filter((id) => !base.some((entry) => entry.id === id));
179
+ if (unknown.length > 0) throw new Error(`${file} removes ${unknown.join(", ")}, which the base does not have`);
180
+ const replaced = base.filter((entry) => !remove.includes(entry.id)).map((entry) => add.find((added) => added.id === entry.id) ?? entry);
181
+ return [...replaced, ...add.filter((added) => !base.some((entry) => entry.id === added.id))];
182
+ }
183
+
184
+ // src/tools/a11y/define.ts
185
+ var stateSchema = z3.strictObject({
186
+ id: z3.string().min(1),
187
+ path: z3.string().startsWith("/").or(z3.string().startsWith("{")),
188
+ auth: z3.boolean(),
189
+ open: fn().optional(),
190
+ overlay: z3.literal(true).optional(),
191
+ context: z3.custom((value) => typeof value === "object" && value !== null).optional(),
192
+ forms: z3.array(z3.enum(["required", "personal", "sign-in"])).optional()
193
+ });
194
+ var baseSchema = z3.strictObject({
195
+ prepare: fn().optional(),
196
+ placeholders: placeholdersSchema.optional(),
197
+ sources: z3.array(z3.string()).min(1),
198
+ scope: z3.array(stateSchema).min(1)
199
+ });
200
+ var appSchema = z3.strictObject({
201
+ prepare: fn().optional(),
202
+ placeholders: placeholdersSchema.optional(),
203
+ remove: z3.array(z3.string()).optional(),
204
+ add: z3.array(stateSchema).optional()
205
+ });
206
+ var auditContrast = "more";
207
+ var defineA11yBase = (base) => base;
208
+ var defineA11yApp = (app) => app;
209
+
210
+ // src/tools/a11y/session.ts
211
+ import process3 from "process";
212
+
213
+ // src/tools/a11y/scope.ts
214
+ import { z as z4 } from "zod";
215
+ async function loadScope(forkPath) {
216
+ setEnv("NODE_ENV", "development");
217
+ const { base, app } = await loadAdapter(forkPath, "a11y", baseSchema, appSchema);
218
+ return {
219
+ prepare: [base.prepare, app?.prepare].filter((step) => step !== void 0),
220
+ placeholders: { ...base.placeholders, ...app?.placeholders },
221
+ sources: base.sources,
222
+ states: app ? applyDelta(base.scope, app, "cella/a11y.app.ts") : base.scope
223
+ };
224
+ }
225
+ var appConfigSchema2 = z4.object({
226
+ name: z4.string(),
227
+ slug: z4.string(),
228
+ description: z4.string(),
229
+ supportEmail: z4.string(),
230
+ frontendUrl: z4.string(),
231
+ company: z4.object({ name: z4.string() }),
232
+ languages: z4.array(z4.string()).min(1),
233
+ defaultLanguage: z4.string(),
234
+ enabledAuthStrategies: z4.array(z4.string())
235
+ });
236
+
237
+ // src/tools/a11y/session.ts
238
+ var defaultViewport = { width: 1280, height: 900 };
239
+ async function startAudit(forkPath, scope, { email, env }) {
240
+ const user = mintSession(email, { forkPath, env, hours: 4 });
241
+ try {
242
+ for (const prepare of scope.prepare) await prepare(signedIn(user));
243
+ const values = {};
244
+ for (const [name, resolve2] of Object.entries(scope.placeholders)) values[name] = await resolve2(signedIn(user));
245
+ const { chromium } = await loadPlaywright(forkPath);
246
+ const browser = await chromium.launch();
247
+ const context = await newContext(browser, { session: user });
248
+ const page = await context.newPage();
249
+ await page.goto(`${user.frontendUrl}/home`, { waitUntil: "domcontentloaded" });
250
+ await page.locator("main").first().waitFor({ timeout: 6e4 });
251
+ await settle(page);
252
+ const storage = await context.storageState();
253
+ await context.close();
254
+ return { browser, user, storage, values };
255
+ } catch (error) {
256
+ await signOut(user);
257
+ throw error;
258
+ }
259
+ }
260
+ async function endAudit(session) {
261
+ await session.browser.close();
262
+ await signOut(session.user);
263
+ }
264
+ function auditContext(session, { auth, mode }, overrides = {}) {
265
+ return newContext(session.browser, {
266
+ viewport: defaultViewport,
267
+ colorScheme: mode,
268
+ // Emulates `prefers-contrast`, which tailwind.css answers on its own, so public pages are covered like signed-in ones
269
+ contrast: "more",
270
+ reducedMotion: "reduce",
271
+ serviceWorkers: "block",
272
+ storageState: auth ? session.storage : void 0,
273
+ // `contrast` here is what the app's own `data-contrast` path reads; the context option above covers the media query
274
+ uiStore: {
275
+ slug: session.user.slug,
276
+ state: { mode, theme: "none", contrast: auditContrast, publicAlertsSeen: ["test-credentials"] }
277
+ },
278
+ ...overrides
279
+ });
280
+ }
281
+ function openStatePath(session, context, state) {
282
+ const path = fillPath(state.path, session.values, "the a11y adapter");
283
+ return openPage(context, `${session.user.frontendUrl}${path}`);
284
+ }
285
+ async function openState(id, { mode = "light", context: overrides, email = getEnv("ADMIN_EMAIL") } = {}) {
286
+ if (!email) throw new Error("no email: pass one, or run through `pnpm cella testbed --`, which sets ADMIN_EMAIL");
287
+ const forkPath = process3.cwd();
288
+ const scope = await loadScope(forkPath);
289
+ const state = scope.states.find((candidate) => candidate.id === id);
290
+ if (!state) throw new Error(`no state "${id}" in the a11y scope`);
291
+ const session = await startAudit(forkPath, scope, { email });
292
+ try {
293
+ const context = await auditContext(session, { auth: state.auth, mode }, { ...state.context, ...overrides });
294
+ const page = await openStatePath(session, context, state);
295
+ await state.open?.(page);
296
+ return { page, context, close: () => endAudit(session) };
297
+ } catch (error) {
298
+ await endAudit(session);
299
+ throw error;
300
+ }
301
+ }
302
+ export {
303
+ auditContrast,
304
+ defineA11yApp,
305
+ defineA11yBase,
306
+ openState,
307
+ settle
308
+ };
309
+ //# sourceMappingURL=a11y.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/tools/drive.ts","../src/utils/app-facts.ts","../src/utils/env.ts","../src/tools/a11y/define.ts","../src/tools/adapters.ts","../src/tools/a11y/session.ts","../src/tools/a11y/scope.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { importFromWorkspace } from '../utils/app-facts';\n\nexport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\n\nexport type Playwright = typeof import('playwright');\n\n/** A signed-in user of the app: where it answers and the cookie that signs a browser or a request in. */\nexport interface Session {\n email: string;\n frontendUrl: string;\n /** The API, a path under the frontend's origin. */\n backendUrl: string;\n /** The app's slug, read off the cookie name; the persisted UI store's key starts with it. */\n slug: string;\n /** In the shape `context.addCookies` takes. */\n cookie: { name: string; value: string; url: string; httpOnly: true; sameSite: 'Strict' };\n /** `name=value`, for a request's cookie header. */\n header: string;\n}\n\nexport interface MintOptions {\n /** The app's root; default the current directory. */\n forkPath?: string;\n /** The environment the mint script runs in: a testbed's names its database. Default this process's. */\n env?: NodeJS.ProcessEnv;\n /** Session lifetime; default 2. */\n hours?: number;\n}\n\n/** Playwright as the app's frontend installed it, so the CLI ships none. */\nexport function loadPlaywright(forkPath = process.cwd()): Promise<Playwright> {\n return importFromWorkspace<Playwright>(forkPath, 'frontend', 'playwright');\n}\n\n/**\n * Checks that Playwright has its Chromium, before anything slow starts.\n * @throws with the install command when it has none\n */\nexport async function requireChromium(forkPath = process.cwd()): Promise<void> {\n const { chromium } = await loadPlaywright(forkPath);\n if (!existsSync(chromium.executablePath())) {\n throw new Error('Playwright has no Chromium yet: run `pnpm --filter frontend exec playwright install chromium`');\n }\n}\n\n/**\n * Signs `email` in through the backend's `session:mint` script, which refuses outside development and test. The script\n * prints `<cookie name>=<signed value>` and a curl line with the API URL, both from the config of the environment it runs\n * in, so a testbed's shifted ports and a linked worktree's are written down nowhere.\n * @throws when the script prints no session, as for an email with no user\n */\nexport function mintSession(email: string, { forkPath = process.cwd(), env, hours = 2 }: MintOptions = {}): Session {\n const minted = spawnSync('pnpm', ['--silent', '--filter', 'backend', 'session:mint', email, String(hours)], {\n cwd: forkPath,\n env,\n encoding: 'utf8',\n });\n const cookie = /^((?:__Host-)?([\\w-]+)-session-v\\d+)=(\\S+)$/m.exec(minted.stdout);\n const api = /^curl (\\S+)\\/me /m.exec(minted.stdout);\n if (!cookie || !api) throw new Error(`could not mint a session for ${email}:\\n${minted.stdout}\\n${minted.stderr}`);\n const frontendUrl = new URL(api[1]).origin;\n return {\n email,\n frontendUrl,\n backendUrl: api[1],\n slug: cookie[2],\n cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: 'Strict' },\n header: `${cookie[1]}=${cookie[3]}`,\n };\n}\n\n/**\n * Signs a minted session out, so a finished run leaves no live session in the user's sessions list. A JSON content type\n * passes the CSRF check without an Origin header; a stack that is already down leaves the session to expire.\n */\nexport async function signOut(session: Session | null | undefined): Promise<void> {\n if (!session) return;\n const headers = { cookie: session.header, 'content-type': 'application/json' };\n await fetch(`${session.backendUrl}/auth/sign-out`, { method: 'POST', headers }).catch(() => {});\n}\n\nexport interface ContextOptions extends BrowserContextOptions {\n /** Signs the context in. */\n session?: Session;\n /**\n * Fields written into the app's persisted UI store (`<slug>-ui`) before any page script runs, so the first paint is\n * already in the mode they set. Merged into what the store holds: a list joins the stored one, any other value wins.\n */\n uiStore?: { slug: string; state: Record<string, unknown> };\n}\n\n/** A browser context, signed in and with its UI store seeded when asked. */\nexport async function newContext(\n browser: Browser,\n { session, uiStore, ...options }: ContextOptions = {},\n): Promise<BrowserContext> {\n const context = await browser.newContext(options);\n // tsx and esbuild's keepNames wrap named functions in an `__name` helper that a page script does not have\n await context.addInitScript({ content: 'window.__name = (fn) => fn;' });\n if (session) await context.addCookies([session.cookie]);\n if (uiStore) {\n await context.addInitScript(\n ([key, fields]) => {\n const stored = JSON.parse(localStorage.getItem(key) ?? '{}');\n const state = { ...stored.state };\n for (const [name, value] of Object.entries(fields)) {\n const before = state[name];\n state[name] = Array.isArray(value) && Array.isArray(before) ? [...new Set([...before, ...value])] : value;\n }\n // A fresh entry needs the store's own version, or zustand drops it as written by an older build\n localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));\n },\n [`${uiStore.slug}-ui`, uiStore.state] as const,\n );\n }\n return context;\n}\n\n/**\n * Resolves once the page has stopped adding or changing content for `quiet` ms, or after `cap` ms. Lazy sections, query\n * results and opening overlays all show up as DOM changes; the network never goes idle in development.\n */\nexport async function settle(page: Page, { quiet = 400, cap = 3000 } = {}): Promise<void> {\n const waited = page.evaluate(\n ([quietMs, capMs]) =>\n new Promise<void>((resolve) => {\n const done = () => {\n observer.disconnect();\n clearTimeout(timer);\n clearTimeout(limit);\n resolve();\n };\n let timer = setTimeout(done, quietMs);\n const limit = setTimeout(done, capMs);\n const observer = new MutationObserver(() => {\n clearTimeout(timer);\n timer = setTimeout(done, quietMs);\n });\n observer.observe(document, { subtree: true, childList: true, characterData: true });\n }),\n [quiet, cap] as const,\n );\n // A navigation during the wait tears down the page's script context\n await waited.catch(() => page.waitForTimeout(quiet));\n}\n\n/**\n * Opens `url` in a new page and settles once the route has rendered its content.\n * @throws when the route redirects: a check or a shot would judge another page under this one's name\n */\nexport async function openPage(context: BrowserContext, url: string, { timeout = 30_000 } = {}): Promise<Page> {\n const page = await context.newPage();\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout });\n await page.locator('main, [role=\"main\"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });\n await settle(page);\n const landed = new URL(page.url()).pathname;\n const asked = new URL(url).pathname;\n if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);\n return page;\n}\n","import { execFileSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { setEnv } from './env';\n\nconst appConfigSchema = z.object({\n frontendUrl: z.string(),\n backendUrl: z.string(),\n devPortOffset: z.number(),\n services: z.record(\n z.string(),\n z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() }),\n ),\n});\n\n/** The part of the app's development config the develop commands read. */\nexport type AppFacts = z.infer<typeof appConfigSchema>;\n\n/** The app's local database helpers (`shared/src/local-database.ts`), as the develop commands call them. */\nexport interface LocalDatabases {\n port: (database: string) => number;\n url: (database: string, role: 'postgres' | 'runtime_role' | 'admin_role') => string;\n /** The env `backend/compose.yaml` reads: the project name and each database's host port. */\n composeEnv: () => Record<string, string>;\n}\n\n/** Node strips the types of a file outside node_modules, so the app's own TS loads without a build or tsx. */\nexport async function importFromApp(forkPath: string, file: string): Promise<Record<string, unknown>> {\n const path = join(forkPath, file);\n if (!existsSync(path)) throw new Error(`not an app built from cella: ${path} is missing`);\n // The app's config reads the mode once, at its first import, and the dev port offset applies in development mode only\n setEnv('NODE_ENV', 'development');\n return import(pathToFileURL(path).href);\n}\n\n/**\n * The app's config in development mode, from its own `shared` package. In a linked worktree the import claims that\n * worktree's port slot, as `pnpm dev` would.\n */\nexport async function loadAppFacts(forkPath: string): Promise<AppFacts> {\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig this CLI can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** The app's local database helpers. */\nexport async function loadLocalDatabases(forkPath: string): Promise<LocalDatabases> {\n const module = await importFromApp(forkPath, 'shared/src/local-database.ts');\n const { localDatabasePort, localDatabaseUrl, composeEnv } = module;\n if (\n typeof localDatabasePort !== 'function' ||\n typeof localDatabaseUrl !== 'function' ||\n typeof composeEnv !== 'function'\n ) {\n throw new Error('shared/src/local-database.ts lacks localDatabasePort, localDatabaseUrl or composeEnv');\n }\n return {\n port: (database) => localDatabasePort(database),\n url: (database, role) => localDatabaseUrl(database, role),\n composeEnv: () => composeEnv(),\n };\n}\n\n/**\n * A package as the app's `workspace` installed it, so a tool drives the app's own Playwright, TypeScript or Tailwind and\n * the CLI ships none. The package's ESM entry when it has one, else its CommonJS exports.\n * @throws when the workspace does not have the package installed\n */\nexport async function importFromWorkspace<T>(forkPath: string, workspace: string, name: string): Promise<T> {\n const require = createRequire(join(forkPath, workspace, 'package.json'));\n let manifestPath: string | undefined;\n try {\n manifestPath = require.resolve(`${name}/package.json`);\n } catch {\n // A package whose exports leave out package.json resolves by its main entry below\n }\n const root = manifestPath ? JSON.parse(readFileSync(manifestPath, 'utf8')).exports?.['.'] : undefined;\n const esm = typeof root?.import === 'string' ? root.import : root?.import?.default;\n if (manifestPath && typeof esm === 'string') return import(pathToFileURL(join(dirname(manifestPath), esm)).href);\n try {\n return require(name);\n } catch {\n throw new Error(`${name} is not installed in ${workspace}: run \\`pnpm --filter ${workspace} add -D ${name}\\``);\n }\n}\n\n/**\n * A Docker image as a tool runs it, pinned by digest (`name[:tag]@sha256:…`) so every run scans with the same code.\n * Pulls it on first use; the pull shows Docker's own progress.\n * @throws when the reference has no digest, Docker does not answer, or the pull fails\n */\nexport function resolveImage(reference: string): string {\n if (!/@sha256:[0-9a-f]{64}$/.test(reference)) throw new Error(`${reference} is not pinned by digest`);\n if (spawnSync('docker', ['info'], { stdio: 'ignore' }).status !== 0) {\n throw new Error('Docker does not answer: start Docker (or OrbStack) first');\n }\n if (spawnSync('docker', ['image', 'inspect', reference], { stdio: 'ignore' }).status === 0) return reference;\n if (spawnSync('docker', ['pull', reference], { stdio: ['ignore', 'inherit', 'inherit'] }).status !== 0) {\n throw new Error(`could not pull ${reference}`);\n }\n return reference;\n}\n\n/**\n * Where a tool keeps its results and state: `<git dir>/cella-<tool>/` of this checkout, or of the git directory every\n * worktree shares. Inside the git directory nothing shows in `git status`.\n */\nexport function toolDir(forkPath: string, tool: string, scope: 'checkout' | 'repository' = 'checkout'): string {\n const flag = scope === 'checkout' ? '--absolute-git-dir' : '--git-common-dir';\n const gitDir = execFileSync('git', ['rev-parse', flag], { cwd: forkPath, encoding: 'utf8' }).trim();\n return join(resolve(forkPath, gitDir), `cella-${tool}`);\n}\n","import process from 'node:process';\n\n/**\n * Centralized environment access for CLI/runtime integration points.\n */\nexport function getEnv(name: string): string | undefined {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return process.env[name];\n}\n\n/** Whether the variable is set at all, even to an empty string. */\nexport function hasEnv(name: string): boolean {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return name in process.env;\n}\n\n/** A mutable copy of the full environment, for passing to child processes. */\nexport function getEnvSnapshot(): NodeJS.ProcessEnv {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return { ...process.env };\n}\n\n/** Set a variable for this process and the children it spawns from here on. */\nexport function setEnv(name: string, value: string): void {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n process.env[name] = value;\n}\n","import { z } from 'zod';\nimport { fn, type Placeholders, type Prepare, placeholdersSchema } from '../adapters';\nimport type { BrowserContextOptions, Page } from '../drive';\n\ntype Opener = (page: Page) => Promise<void>;\n\n/** A form probe a state runs on its page. */\nexport type FormCheck =\n /** Submitting the form empty identifies each error in text (3.3.1). */\n | 'required'\n /** Inputs for the user's own details declare their purpose (1.3.5). */\n | 'personal'\n /** Sign-in fields accept autofill and pasted text (3.3.8). */\n | 'sign-in';\n\n/** A page or state of the audit scope. */\nexport interface A11yState {\n id: string;\n /** Path on the frontend; a `{name}` placeholder takes the value its resolver returns. */\n path: string;\n auth: boolean;\n /** Brings the page into the state to audit, such as an open dialog. Runs after the page has loaded. */\n open?: Opener;\n /** `open` leaves an overlay (dialog, sheet, menu) open that holds focus until it closes. */\n overlay?: true;\n /** Browser settings of this state's visits, such as a viewport the state only exists at. */\n context?: BrowserContextOptions;\n forms?: FormCheck[];\n}\n\n/** `cella/a11y.ts`: the template's scope, synced with the UI it describes. */\nexport interface A11yBase {\n /** Brings the audit user into the state the pages expect, before the first visit. */\n prepare?: Prepare;\n /** Values for the `{name}` placeholders of the paths, read as the audit user before the first visit. */\n placeholders?: Placeholders;\n /** What the code checks search, as `git grep` pathspecs from the app's root. */\n sources: string[];\n /** The sample of pages and states the audit covers (WCAG-EM steps 2 and 3), in report order. */\n scope: A11yState[];\n}\n\n/** `cella/a11y.app.ts`, optional: what an app adds to the base, removes from it or overrides. */\nexport interface A11yApp {\n /** Runs after the base's. */\n prepare?: Prepare;\n /** Joins the base's; a name in both takes this one. */\n placeholders?: Placeholders;\n /** Base state ids the app does not have. */\n remove?: string[];\n /** States of the app's own; one with a base state's id replaces it in place. */\n add?: A11yState[];\n}\n\nconst stateSchema = z.strictObject({\n id: z.string().min(1),\n path: z.string().startsWith('/').or(z.string().startsWith('{')),\n auth: z.boolean(),\n open: fn<Opener>().optional(),\n overlay: z.literal(true).optional(),\n context: z.custom<BrowserContextOptions>((value) => typeof value === 'object' && value !== null).optional(),\n forms: z.array(z.enum(['required', 'personal', 'sign-in'])).optional(),\n}) satisfies z.ZodType<A11yState>;\n\nexport const baseSchema = z.strictObject({\n prepare: fn<Prepare>().optional(),\n placeholders: placeholdersSchema.optional(),\n sources: z.array(z.string()).min(1),\n scope: z.array(stateSchema).min(1),\n}) satisfies z.ZodType<A11yBase>;\n\nexport const appSchema = z.strictObject({\n prepare: fn<Prepare>().optional(),\n placeholders: placeholdersSchema.optional(),\n remove: z.array(z.string()).optional(),\n add: z.array(stateSchema).optional(),\n}) satisfies z.ZodType<A11yApp>;\n\n/**\n * The audit measures the app with increased contrast turned on, which is what raises the edge tokens to 3:1. It is a\n * user setting, not the default, so every report built from a run says so: the ledger records it and the report prints\n * it. `prepare` stores it for the audit user, since `/me` overwrites the seeded UI store on every authenticated page.\n */\nexport const auditContrast = 'more' as const;\n\nexport const defineA11yBase = (base: A11yBase): A11yBase => base;\n\nexport const defineA11yApp = (app: A11yApp): A11yApp => app;\n","import { existsSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { importFromWorkspace } from '../utils/app-facts';\nimport type { Session } from './drive';\n\n/** What `prepare` and a placeholder resolver get: the signed-in tool user, to call the API as. */\nexport type SignedIn = Pick<Session, 'email' | 'frontendUrl' | 'backendUrl'> & {\n /** `name=value`: the cookie header that signs a request in. */\n cookie: string;\n};\n\nexport const signedIn = ({ email, frontendUrl, backendUrl, header }: Session): SignedIn => ({\n email,\n frontendUrl,\n backendUrl,\n cookie: header,\n});\n\n/** Brings the tool user into the state the pages expect, before the first visit. */\nexport type Prepare = (user: SignedIn) => Promise<void>;\n\n/**\n * Values for the `{name}` placeholders of the paths, by name. A resolver that finds nothing returns null, and a path\n * that needs its value then fails with a message naming it.\n */\nexport type Placeholders = Record<string, (user: SignedIn) => Promise<string | null>>;\n\n/** A zod schema that accepts any function; the adapter's types say which. */\nexport const fn = <T>() => z.custom<T>((value) => typeof value === 'function', 'expected a function');\n\nexport const placeholdersSchema = z.record(z.string(), fn<Placeholders[string]>());\n\n/** Fills the `{name}` placeholders of `path`. */\nexport function fillPath(path: string, values: Record<string, string | null>, file: string): string {\n return path.replace(/\\{(\\w+)\\}/g, (_, name: string) => {\n const value = values[name];\n if (!value) throw new Error(`no value for {${name}}: its resolver in ${file} found nothing for the signed-in user`);\n return value;\n });\n}\n\ninterface TsxApi {\n tsImport: (specifier: string, parentURL: string) => Promise<Record<string, unknown>>;\n}\n\n/**\n * Imports an adapter file under the app's own tsx. An adapter imports the app's SDK, whose generated files import each\n * other without an extension, and Node's own type stripping resolves no such import.\n */\nasync function importAdapter(forkPath: string, file: string): Promise<unknown> {\n const { tsImport } = await importFromWorkspace<TsxApi>(forkPath, '.', 'tsx/esm/api');\n const module = await tsImport(pathToFileURL(join(forkPath, file)).href, import.meta.url);\n return module.default;\n}\n\nfunction parsed<T>(file: string, schema: z.ZodType<T>, value: unknown): T {\n const result = schema.safeParse(value);\n if (result.success) return result.data;\n throw new Error(`${file} does not fit this version of the CLI:\\n${z.prettifyError(result.error)}`);\n}\n\n/**\n * A tool's adapter in the app's `cella/` folder: the base `<tool>.ts`, synced from the template, and the optional delta\n * `<tool>.app.ts`, pinned to the app, each checked against its schema. A missing delta means the base alone.\n * @throws when the base is missing or a file does not fit its schema\n */\nexport async function loadAdapter<Base, App>(\n forkPath: string,\n tool: string,\n baseSchema: z.ZodType<Base>,\n appSchema: z.ZodType<App>,\n): Promise<{ base: Base; app: App | undefined }> {\n const baseFile = `cella/${tool}.ts`;\n const appFile = `cella/${tool}.app.ts`;\n if (!existsSync(join(forkPath, baseFile))) {\n throw new Error(`${baseFile} is missing: upgrade the app from cella to get the ${tool} adapter`);\n }\n const base = parsed(baseFile, baseSchema, await importAdapter(forkPath, baseFile));\n const app = existsSync(join(forkPath, appFile))\n ? parsed(appFile, appSchema, await importAdapter(forkPath, appFile))\n : undefined;\n return { base, app };\n}\n\n/**\n * The base's entries with the delta applied: `remove` drops ids, and an added entry replaces the base entry of its id in\n * place or else comes last.\n * @throws when `remove` names an id the base lacks, so a renamed base entry is noticed\n */\nexport function applyDelta<T extends { id: string }>(\n base: T[],\n { remove = [], add = [] }: { remove?: string[]; add?: T[] },\n file: string,\n): T[] {\n const unknown = remove.filter((id) => !base.some((entry) => entry.id === id));\n if (unknown.length > 0) throw new Error(`${file} removes ${unknown.join(', ')}, which the base does not have`);\n const replaced = base\n .filter((entry) => !remove.includes(entry.id))\n .map((entry) => add.find((added) => added.id === entry.id) ?? entry);\n return [...replaced, ...add.filter((added) => !base.some((entry) => entry.id === added.id))];\n}\n","import process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { getEnv } from '../../utils/env';\nimport { fillPath, signedIn } from '../adapters';\nimport { loadPlaywright, mintSession, newContext, openPage, type Session, settle, signOut } from '../drive';\nimport { type A11yState, auditContrast } from './define';\nimport { loadScope, type Scope } from './scope';\n\nexport type Mode = 'light' | 'dark';\n\ntype StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;\n\n/** A signed-in audit: one browser for every visit, and what each visit starts from. */\nexport interface AuditSession {\n browser: Browser;\n user: Session;\n /** Cookies and local storage of a browser that has loaded the app once, as after a real sign-in. */\n storage: StorageState;\n /** Resolved `{name}` placeholders of the scope paths. */\n values: Record<string, string | null>;\n}\n\n/** Elements that exist only in development builds; scans and probes ignore them. */\nexport const devOnlySelectors = [\n '.TanStackRouterDevtools',\n '.tsqd-parent-container',\n '[aria-label=\"toggle debug toolbar\"]',\n];\n\nexport const defaultViewport = { width: 1280, height: 900 };\n\n/**\n * Signs the audit user in, runs the scope's `prepare` steps, resolves its placeholders and starts the browser.\n * `env` points the session mint at the database the app runs on.\n */\nexport async function startAudit(\n forkPath: string,\n scope: Scope,\n { email, env }: { email: string; env?: NodeJS.ProcessEnv },\n): Promise<AuditSession> {\n const user = mintSession(email, { forkPath, env, hours: 4 });\n try {\n for (const prepare of scope.prepare) await prepare(signedIn(user));\n const values: AuditSession['values'] = {};\n for (const [name, resolve] of Object.entries(scope.placeholders)) values[name] = await resolve(signedIn(user));\n\n const { chromium } = await loadPlaywright(forkPath);\n const browser = await chromium.launch();\n // Route guards read the user store, which a real sign-in fills before any deep link is opened\n const context = await newContext(browser, { session: user });\n const page = await context.newPage();\n await page.goto(`${user.frontendUrl}/home`, { waitUntil: 'domcontentloaded' });\n await page.locator('main').first().waitFor({ timeout: 60_000 });\n await settle(page);\n const storage = await context.storageState();\n await context.close();\n return { browser, user, storage, values };\n } catch (error) {\n await signOut(user);\n throw error;\n }\n}\n\n/** Closes the browser and signs the audit user out, so a finished audit leaves no live session behind. */\nexport async function endAudit(session: AuditSession): Promise<void> {\n await session.browser.close();\n await signOut(session.user);\n}\n\n/**\n * A fresh context for one visit: color mode, more contrast, reduced motion, the dev banner dismissed, and signed in\n * when the state needs it. `overrides` change the browser settings, such as the viewport or the motion preference.\n */\nexport function auditContext(\n session: AuditSession,\n { auth, mode }: { auth: boolean; mode: Mode },\n overrides: BrowserContextOptions = {},\n): Promise<BrowserContext> {\n return newContext(session.browser, {\n viewport: defaultViewport,\n colorScheme: mode,\n // Emulates `prefers-contrast`, which tailwind.css answers on its own, so public pages are covered like signed-in ones\n contrast: 'more',\n reducedMotion: 'reduce',\n serviceWorkers: 'block',\n storageState: auth ? session.storage : undefined,\n // `contrast` here is what the app's own `data-contrast` path reads; the context option above covers the media query\n uiStore: {\n slug: session.user.slug,\n state: { mode, theme: 'none', contrast: auditContrast, publicAlertsSeen: ['test-credentials'] },\n },\n ...overrides,\n });\n}\n\n/** Opens a state's path in `context`; its `open` step is the caller's. */\nexport function openStatePath(session: AuditSession, context: BrowserContext, state: A11yState): Promise<Page> {\n const path = fillPath(state.path, session.values, 'the a11y adapter');\n return openPage(context, `${session.user.frontendUrl}${path}`);\n}\n\ninterface OpenStateOptions {\n mode?: Mode;\n /** Over the state's own browser settings, such as a phone or `{ reducedMotion: 'no-preference' }`. */\n context?: BrowserContextOptions;\n /** Default `ADMIN_EMAIL`, which a testbed hands the command it runs. */\n email?: string;\n}\n\n/**\n * Opens a state of the app's audit scope by id, signed in: the start of a short script that checks a fix in the\n * running app. Run the script from the app's root through `pnpm cella testbed -- node <script>`; `close` ends it.\n * @throws when the scope has no state of that id, or no email is given and `ADMIN_EMAIL` is unset\n */\nexport async function openState(\n id: string,\n { mode = 'light', context: overrides, email = getEnv('ADMIN_EMAIL') }: OpenStateOptions = {},\n): Promise<{ page: Page; context: BrowserContext; close: () => Promise<void> }> {\n if (!email) throw new Error('no email: pass one, or run through `pnpm cella testbed --`, which sets ADMIN_EMAIL');\n const forkPath = process.cwd();\n const scope = await loadScope(forkPath);\n const state = scope.states.find((candidate) => candidate.id === id);\n if (!state) throw new Error(`no state \"${id}\" in the a11y scope`);\n const session = await startAudit(forkPath, scope, { email });\n try {\n const context = await auditContext(session, { auth: state.auth, mode }, { ...state.context, ...overrides });\n const page = await openStatePath(session, context, state);\n await state.open?.(page);\n return { page, context, close: () => endAudit(session) };\n } catch (error) {\n await endAudit(session);\n throw error;\n }\n}\n","import type { Page } from 'playwright';\nimport { z } from 'zod';\nimport { importFromApp } from '../../utils/app-facts';\nimport { setEnv } from '../../utils/env';\nimport { applyDelta, loadAdapter, type Placeholders, type Prepare } from '../adapters';\nimport { type A11yState, appSchema, baseSchema } from './define';\n\n/** The audit scope with the app's delta applied. */\nexport interface Scope {\n /** The base's, then the delta's. */\n prepare: Prepare[];\n placeholders: Placeholders;\n sources: string[];\n states: A11yState[];\n}\n\nexport async function loadScope(forkPath: string): Promise<Scope> {\n // The adapters read the app config, which takes the development ports and URLs in development mode only\n setEnv('NODE_ENV', 'development');\n const { base, app } = await loadAdapter(forkPath, 'a11y', baseSchema, appSchema);\n return {\n prepare: [base.prepare, app?.prepare].filter((step) => step !== undefined),\n placeholders: { ...base.placeholders, ...app?.placeholders },\n sources: base.sources,\n states: app ? applyDelta(base.scope, app, 'cella/a11y.app.ts') : base.scope,\n };\n}\n\nconst appConfigSchema = z.object({\n name: z.string(),\n slug: z.string(),\n description: z.string(),\n supportEmail: z.string(),\n frontendUrl: z.string(),\n company: z.object({ name: z.string() }),\n languages: z.array(z.string()).min(1),\n defaultLanguage: z.string(),\n enabledAuthStrategies: z.array(z.string()),\n});\n\n/** The part of the app's config the audit and the report read. */\nexport type AppConfig = z.infer<typeof appConfigSchema>;\n\n/** The app's config in `mode`: the report describes the production app, the audit runs against development builds. */\nexport async function loadAppConfig(forkPath: string, mode: 'development' | 'production'): Promise<AppConfig> {\n setEnv('NODE_ENV', mode);\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig the audit can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** Overlays that hold focus while open. Toasts render as dialogs but never take focus. */\nexport const overlaySelector =\n '[role=\"dialog\"]:not([data-slot^=\"toast\"]), [role=\"alertdialog\"]:not([data-slot^=\"toast\"]), [role=\"menu\"], [role=\"listbox\"]';\n\n/** Opens the state's overlay again when a check (a resize, an Escape) closed it. */\nexport async function ensureOpen(page: Page, state: A11yState): Promise<void> {\n if (!state.overlay || !state.open || (await page.locator(overlaySelector).count())) return;\n await state.open(page);\n}\n"],"mappings":"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","names":["spawnSync","existsSync","process","require","process","process","spawnSync","resolve","z","existsSync","join","pathToFileURL","z","z","pathToFileURL","join","baseSchema","appSchema","existsSync","z","process","z","appConfigSchema","z","resolve","process"]}
@@ -0,0 +1,16 @@
1
+ import { Session } from './drive.js';
2
+
3
+ /** What `prepare` and a placeholder resolver get: the signed-in tool user, to call the API as. */
4
+ type SignedIn = Pick<Session, 'email' | 'frontendUrl' | 'backendUrl'> & {
5
+ /** `name=value`: the cookie header that signs a request in. */
6
+ cookie: string;
7
+ };
8
+ /** Brings the tool user into the state the pages expect, before the first visit. */
9
+ type Prepare = (user: SignedIn) => Promise<void>;
10
+ /**
11
+ * Values for the `{name}` placeholders of the paths, by name. A resolver that finds nothing returns null, and a path
12
+ * that needs its value then fails with a message naming it.
13
+ */
14
+ type Placeholders = Record<string, (user: SignedIn) => Promise<string | null>>;
15
+
16
+ export type { Prepare as P, SignedIn as S, Placeholders as a };
@@ -0,0 +1,179 @@
1
+ import { IncomingHttpHeaders } from 'node:http';
2
+
3
+ /** Percentiles of a histogram in milliseconds; zeros when it recorded nothing. */
4
+ interface Distribution {
5
+ count: number;
6
+ min: number;
7
+ mean: number;
8
+ p50: number;
9
+ p95: number;
10
+ p99: number;
11
+ max: number;
12
+ }
13
+
14
+ /** A stretch of load: `arrivalRate` new VUs per second (ramping to `rampTo`), or `arrivalCount` VUs spread over `duration` seconds. */
15
+ interface Phase {
16
+ name: string;
17
+ duration: number;
18
+ arrivalRate?: number;
19
+ rampTo?: number;
20
+ arrivalCount?: number;
21
+ }
22
+ /** The statistics a threshold or a primary latency names after a histogram's name. */
23
+ declare const stats: readonly ["p50", "p95", "p99", "max", "min", "mean"];
24
+ type Stat = (typeof stats)[number];
25
+ type BuiltinHistogram = 'http.response_time' | 'http.total_time';
26
+ /** A histogram statistic by name, such as `http.response_time.p95`. */
27
+ type MetricKey<H extends string> = `${BuiltinHistogram | H}.${Stat}`;
28
+ /** What a run must meet. Percentages are 0-100; latencies are milliseconds. */
29
+ interface Checks<C extends string, H extends string> {
30
+ thresholds?: Partial<Record<MetricKey<H>, number>>;
31
+ /** Highest share of VUs that may fail: created minus completed, over created. */
32
+ maxErrorRate?: number;
33
+ /** Highest share of responses that may be other than 2xx; 1 when unset. */
34
+ maxRejectedShare?: number;
35
+ /** Counters that must have counted (full runs only: a short run plays each role once). */
36
+ expect?: readonly C[];
37
+ /** Counters that must stay at zero. */
38
+ forbid?: readonly C[];
39
+ }
40
+ interface HttpRequestOptions {
41
+ json?: unknown;
42
+ body?: string | Uint8Array;
43
+ headers?: Record<string, string>;
44
+ /** Endpoint label for the per-endpoint table; method and path without the query when unset. */
45
+ name?: string;
46
+ /** Milliseconds; 10 s when unset. */
47
+ timeout?: number;
48
+ }
49
+ interface HttpResponse {
50
+ status: number;
51
+ ok: boolean;
52
+ headers: IncomingHttpHeaders;
53
+ body: Buffer;
54
+ text(): string;
55
+ json(): unknown;
56
+ }
57
+ /** Requests over one keep-alive connection per VU. Resolves for every status; rejects on a transport error or timeout. */
58
+ interface HttpClient {
59
+ request(method: string, url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
60
+ get(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
61
+ post(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
62
+ put(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
63
+ patch(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
64
+ delete(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
65
+ }
66
+ interface SseEvent {
67
+ event: string;
68
+ data: string;
69
+ id?: string;
70
+ }
71
+ interface SseOptions {
72
+ signal?: AbortSignal;
73
+ headers?: Record<string, string>;
74
+ name?: string;
75
+ }
76
+ /** What one virtual user gets. A throw marks it failed and counts `errors.<code>`. */
77
+ interface Vu<C extends string = string, H extends string = string> {
78
+ /** Arrival number from 0, unique in the run. */
79
+ index: number;
80
+ role: string | null;
81
+ short: boolean;
82
+ /** Aborts when the run stops before this VU is done. */
83
+ signal: AbortSignal;
84
+ http: HttpClient;
85
+ /** Reads an event stream until it ends or `signal` aborts; resolves to the response status. */
86
+ sse(url: string, onEvent: (event: SseEvent) => void, options?: SseOptions): Promise<number>;
87
+ /** An open WebSocket, closed when the VU ends. */
88
+ ws(url: string, protocols?: string | string[]): Promise<WebSocket>;
89
+ think(ms: number): Promise<void>;
90
+ counter(name: C, n?: number): void;
91
+ histogram(name: H, value: number): void;
92
+ /** Prints a line without breaking the live display. */
93
+ log(text: string): void;
94
+ /** Adds data to the run's log under `name`. */
95
+ attach(name: string, data: unknown): void;
96
+ }
97
+ type VuFn<C extends string, H extends string> = (vu: Vu<C, H>) => Promise<void>;
98
+ interface Role<C extends string, H extends string> {
99
+ name: string;
100
+ weight: number;
101
+ vu: VuFn<C, H>;
102
+ }
103
+ interface ScenarioBase<C extends string, H extends string> {
104
+ description: string;
105
+ /** Every counter the scenario counts: `vu.counter`, `expect` and `forbid` accept these names only. */
106
+ counters?: readonly C[];
107
+ /** Every histogram the scenario records, in milliseconds. */
108
+ histograms?: readonly H[];
109
+ checks?: Checks<NoInfer<C>, NoInfer<H>>;
110
+ /** The latency a baseline compares first; `http.response_time.p95` when unset. */
111
+ primary?: MetricKey<NoInfer<H>>;
112
+ /** Seconds the run waits for VUs in flight after the last arrival before it aborts them; 120 when unset. */
113
+ drainTimeout?: number;
114
+ }
115
+ /** A load scenario (`phases` with `vu` or weighted `roles`), or a single-run one (`run`, called once). */
116
+ type Scenario<C extends string = string, H extends string = string> = ScenarioBase<C, H> & ({
117
+ phases: Phase[];
118
+ vu: VuFn<NoInfer<C>, NoInfer<H>>;
119
+ roles?: never;
120
+ run?: never;
121
+ } | {
122
+ phases: Phase[];
123
+ roles: Role<NoInfer<C>, NoInfer<H>>[];
124
+ vu?: never;
125
+ run?: never;
126
+ } | {
127
+ run: VuFn<NoInfer<C>, NoInfer<H>>;
128
+ phases?: never;
129
+ vu?: never;
130
+ roles?: never;
131
+ });
132
+ interface CheckResult {
133
+ id: string;
134
+ ok: boolean;
135
+ text: string;
136
+ }
137
+
138
+ /** Identity: types a scenario module and checks its counter and histogram names, nothing at runtime. */
139
+ declare function defineScenario<const C extends string = never, const H extends string = never>(scenario: Scenario<C, H>): Scenario<C, H>;
140
+
141
+ /** What a scenario's run left for the app to check. */
142
+ interface RunOutcome {
143
+ counters: Record<string, number>;
144
+ /** The runner's checks: a run whose responses were rejected may skip the app's. */
145
+ checks: CheckResult[];
146
+ short: boolean;
147
+ }
148
+ interface AppChecks {
149
+ checks: CheckResult[];
150
+ /** Cells of the `--all` table by column name. */
151
+ columns?: Record<string, string>;
152
+ }
153
+ /** The app's side of a bench run: the stack it measures and its hooks around the scenarios. */
154
+ interface Bench<State = undefined> {
155
+ /** Base URL that relative request paths join; one run at a time holds it. */
156
+ target: string;
157
+ /** Waits for the stack to answer; throws what is missing. */
158
+ preflight?: () => Promise<void>;
159
+ /** Writes the data the scenarios read, unless `--skip-seed`. */
160
+ seed?: () => Promise<void>;
161
+ /** After the seed, and with `--skip-seed` too: a throw stops the run before anything is measured. */
162
+ beforeAll?: (options: {
163
+ skipSeed: boolean;
164
+ }) => Promise<void>;
165
+ /** Before each scenario; what it returns reaches `afterEach`. */
166
+ beforeEach?: (scenario: string) => Promise<State>;
167
+ /** The app's checks of what the stack did with a scenario's load. `state` is undefined without `beforeEach`. */
168
+ afterEach?: (outcome: RunOutcome, state: State | undefined) => Promise<AppChecks>;
169
+ /** Seconds between the full runs of `--all`; 15 when unset. */
170
+ cooldownSeconds?: number;
171
+ }
172
+ /**
173
+ * The app's bench entry: runs what `pnpm cella bench` asked for with the app's hooks. The preflight, a lock on the
174
+ * target, the seed and `beforeAll` come first; then each scenario between `beforeEach` and `afterEach`, the app's checks
175
+ * joining the scenario's own. Sets the exit code: 1 when a scenario failed or a hook threw.
176
+ */
177
+ declare function runBench<State = undefined>(bench: Bench<State>): Promise<void>;
178
+
179
+ export { type AppChecks, type Bench, type CheckResult, type Checks, type Distribution, type HttpRequestOptions, type HttpResponse, type Phase, type Role, type RunOutcome, type Scenario, type SseEvent, type Vu, defineScenario, runBench };