waygraph 0.15.24 → 0.15.25

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.
@@ -0,0 +1,266 @@
1
+ import type { Block, Checkpoint } from "../types.js";
2
+ import type { RunGraphOptions } from "./run-graph.js";
3
+ import type { EngineConfig } from "./config.js";
4
+ import type { BrowserContext, Page } from "@playwright/test";
5
+ import { MemPage } from "../mem-page.js";
6
+ import type { Trait } from "../trait.js";
7
+ import type { DemoPace, HighlightStyleDefaults } from "../highlights.js";
8
+ /**
9
+ * A composed, runnable flow. Always begins from Checkpoint<"__start__">, same as
10
+ * runGraph's `entry`. `run(context, mem)` uses a context you already have (the
11
+ * usual case inside a `@playwright/test` file, via its `context` fixture) -
12
+ * there's no browser for this Flow to launch, so no config to pass. `run(mem,
13
+ * config?)` - no context - has the Engine launch and own its own browser for
14
+ * this one run instead, and closes it in `finally`; use this outside a
15
+ * Playwright test file, or when a flow genuinely needs its own browser instance
16
+ * rather than sharing the test's. `config` here overrides, per key, whatever
17
+ * `new Engine(...)` was constructed with - so one flow (module-level, imported
18
+ * by many specs) can still have every run choose its own `headless`/`slowMo`
19
+ * rather than being stuck with whatever the flow's own file happened to set.
20
+ *
21
+ * `withBlockVerify`/`modBlockVerify` do what {@link withVerify}/{@link modVerify}
22
+ * do for a single Block, one level up - a spec that only imports the finished
23
+ * Flow (not necessarily the individual Blocks it was built from) can still
24
+ * patch one Block's verify for just that call, without editing the flow's own
25
+ * file or hand-rebuilding its Block list. Addressed the same three ways
26
+ * `modVerify` already addresses a Trait - the Block reference itself
27
+ * (`LoginBlock`, imported like any other Block; typo-proof, refactor-safe,
28
+ * jump-to-definition works), a plain string name, or a numeric index into this
29
+ * Flow's Block list (position between `start`/`end`, 0-based) for a spec that
30
+ * genuinely doesn't have the Block imported and doesn't want to spell its
31
+ * name. A reference or string resolves by `.name` under the hood (a Block's
32
+ * own stable identity in this codebase, same as every error message elsewhere
33
+ * in this file already uses) rather than object identity - it stays
34
+ * resolvable even after an earlier patch already swapped that slot for a new
35
+ * object, since the name survives every `withVerify`/`modVerify` in the
36
+ * chain; an index is checked fresh against this Flow's current Block list
37
+ * each call, so it's still correct after a patch too. Returns a new Flow -
38
+ * the original is untouched, same as every other decorator in this module.
39
+ */
40
+ /**
41
+ * One Block's identity as exposed by graph introspection - {@link Flow.blocks},
42
+ * {@link ComposedBlock.steps} - without needing to execute anything. Does not
43
+ * include the Block's literal Checkpoint `In`/`Out` tags: those are TypeScript
44
+ * literal types, erased at runtime, and can only be recovered by a build-time
45
+ * static-analysis tool reading the source's own type annotations - see the
46
+ * `graph-visualization` openspec change for that piece.
47
+ */
48
+ export interface BlockInfo {
49
+ name: string;
50
+ /** `branch()`'s routing table, if this Block has one - see {@link Block.routes}. */
51
+ routes?: Readonly<Record<string, string | null>>;
52
+ /**
53
+ * True when {@link chainFlow} inserted a session-reset boundary
54
+ * immediately before this Block - i.e. this is the first Block of a
55
+ * sub-flow that was wrapped in {@link withSessionReset}, and it isn't
56
+ * the very first sub-flow in the chain. Absent (not just false) from
57
+ * `Flow.blocks()` on an ordinary, non-chained Flow.
58
+ */
59
+ resetSessionBefore?: boolean;
60
+ /**
61
+ * The actual Block, re-runnable on its own via
62
+ * `engine.defineFlow([start, info.block, end])` - e.g. step-through
63
+ * tooling that runs a Flow's Blocks one at a time. Introspection that only
64
+ * wants to read the shape (a visualizer) can ignore this and use `name`/
65
+ * `routes` alone.
66
+ */
67
+ block: Block<any, any>;
68
+ }
69
+ export interface Flow<Out extends Checkpoint<string>> {
70
+ /**
71
+ * True once this Flow has been wrapped in {@link withSessionReset} - a
72
+ * hint for `chainFlow`, not something `run()` itself checks: it marks
73
+ * this Flow as one that expects to start from a genuinely fresh,
74
+ * unauthenticated browser state (e.g. a login flow meant to be
75
+ * re-entered later in a longer chain). `chainFlow` clears cookies and
76
+ * storage right before this Flow's first Block runs, but only when this
77
+ * Flow isn't the very first thing in the chain (nothing to reset yet).
78
+ */
79
+ readonly resetSession: boolean;
80
+ /**
81
+ * Set once this Flow has been wrapped in {@link withTitle} - a display
82
+ * hint for a step-through overlay/showcase (e.g. "Ticket #123"), not
83
+ * something `run()` itself uses.
84
+ */
85
+ readonly title?: string;
86
+ /**
87
+ * Set once this Flow has been wrapped in {@link withExpectedFailure} - a
88
+ * display hint for a step-through overlay: this Flow is DESIGNED to throw
89
+ * (e.g. a blocked user's login attempt genuinely failing), not a bug in
90
+ * the demo itself. `run()` never reads this - the Flow still throws
91
+ * exactly as it would otherwise; only the overlay's error panel reads it,
92
+ * to show an informational "this is the point" panel instead of an
93
+ * alarming one indistinguishable from a real break.
94
+ */
95
+ readonly expectedFailureReason?: string;
96
+ /**
97
+ * Set once this Flow has been wrapped in {@link withHighlightFixtures} -
98
+ * demo narration overrides (unlimited purposes on `.flow.ts`) keyed by
99
+ * block.name. `run()` never reads this; step/`demo` merges onto block stubs.
100
+ */
101
+ readonly highlightFixtures?: import("../highlights.js").HighlightFixtureMap;
102
+ /**
103
+ * Demo pacing for this Flow's episode (`withDemoPace`).
104
+ * `run()` ignores; step/demo applies per Block in the episode.
105
+ */
106
+ readonly demoPace?: import("../highlights.js").DemoPace;
107
+ /**
108
+ * Default highlight size/weight/tone for this Flow's episode (`withHighlightStyle`).
109
+ * Per-slot stub/fixture values win. `run()` ignores.
110
+ */
111
+ readonly highlightStyle?: import("../highlights.js").HighlightStyleDefaults;
112
+ /**
113
+ * Set once this Flow has been wrapped in {@link withMemStub} - opts into
114
+ * auto-filling `requires` keys with registered fake values
115
+ * (`registerMemStub`, `src/mem-stub.ts`) instead of failing preflight when
116
+ * the caller didn't supply them. `run()` never reads this - the CLI's own
117
+ * `--data`/`--mem-stub` seeding, or a bare-library caller's own explicit
118
+ * `seedMemStub(mem, flow)` call, is what actually fills mem before `run()`.
119
+ */
120
+ readonly memStub?: boolean;
121
+ /**
122
+ * This Flow's constituent Blocks, in order between `start`/`end`, as plain
123
+ * data - for introspection/visualization tools, without running anything.
124
+ * @example loginFlow.blocks() // [{ name: "login" }, { name: "add-to-cart", routes: {...} }]
125
+ */
126
+ blocks(): readonly BlockInfo[];
127
+ run(context: BrowserContext, mem: MemPage): Promise<Out>;
128
+ /**
129
+ * `options.closeOnFinish: false` hands the driven page back instead of
130
+ * closing it - use this overload (a literal `false`, not a `boolean`) to
131
+ * get the `{ result, page }` shape typed correctly.
132
+ * @example const { result, page } = await loginFlow.run(context, mem, { closeOnFinish: false });
133
+ */
134
+ run(context: BrowserContext, mem: MemPage, options: RunGraphOptions & {
135
+ closeOnFinish: false;
136
+ }): Promise<{
137
+ result: Out;
138
+ page: Page;
139
+ }>;
140
+ /**
141
+ * `options.page` drives that already-open page instead of opening a new
142
+ * one - see {@link RunGraphOptions}.
143
+ * @example await loginFlow.run(context, mem, { page: existingPage });
144
+ */
145
+ run(context: BrowserContext, mem: MemPage, options?: RunGraphOptions): Promise<Out>;
146
+ run(mem: MemPage, config?: EngineConfig): Promise<Out>;
147
+ /**
148
+ * Replaces the given Block's whole verify list, wherever it sits in this
149
+ * Flow - same rules as {@link withVerify}. Throws if no Block in this Flow
150
+ * matches.
151
+ * @example checkoutFlow.withBlockVerify(LoginBlock, [])
152
+ * @example checkoutFlow.withBlockVerify("login", [])
153
+ * @example checkoutFlow.withBlockVerify(0, [])
154
+ */
155
+ withBlockVerify(block: Block<any, any> | string | number, verify: Trait[] | ((out: any) => Trait[])): Flow<Out>;
156
+ /**
157
+ * Replaces one Trait inside the given Block's verify list - same rules as
158
+ * {@link modVerify}. Throws if no Block in this Flow matches, or if that
159
+ * Block has no verify trait at the given name/index.
160
+ * @example checkoutFlow.modBlockVerify(LoginBlock, "url-matches", Trait.url({ pathname: "/inventory.html" }))
161
+ */
162
+ modBlockVerify(block: Block<any, any> | string | number, nameOrIndex: string | number, newCheck: Trait["check"] | Trait): Flow<Out>;
163
+ }
164
+ export declare function buildFlow<Out extends Checkpoint<string>>(middle: readonly Block<any, any>[], engineConfig: EngineConfig): Flow<Out>;
165
+ /**
166
+ * Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
167
+ * browser state - e.g. a login flow meant to be re-entered later in a longer
168
+ * showcase. Non-destructive, same pattern as {@link withVerify}: returns a
169
+ * new Flow: the original is untouched, and the flag survives any further
170
+ * `withBlockVerify`/`modBlockVerify` patch applied to the result.
171
+ *
172
+ * By itself this does nothing - `Flow.run()` never reads `resetSession`.
173
+ * It's a hint {@link chainFlow} looks for: right before this Flow's first
174
+ * Block runs (skipped if it's the very first thing in the chain - nothing
175
+ * to reset yet), chainFlow clears cookies and storage so this Flow starts
176
+ * exactly as if a human had opened the app fresh, not wherever the
177
+ * previous Flow in the chain happened to leave the page.
178
+ * @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
179
+ */
180
+ export declare function withSessionReset<Out extends Checkpoint<string>>(flow: Flow<Out>): Flow<Out>;
181
+ /**
182
+ * Names a Flow for display - a step-through overlay/showcase (waygraph's
183
+ * `chain --step`) shows this as the running banner while this Flow's own
184
+ * Blocks are executing, instead of one fixed title for the whole run.
185
+ * Non-destructive, same pattern as {@link withSessionReset}.
186
+ * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
187
+ */
188
+ export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
189
+ /**
190
+ * Opts this Flow into memStub: `requires` keys the caller didn't supply get
191
+ * filled from `registerMemStub`'s registry (`src/mem-stub.ts`) instead of
192
+ * failing preflight, for any key that has a registered fake generator - a
193
+ * required key with no registration still fails exactly as before.
194
+ * Non-destructive, same pattern as {@link withTitle}. `run()` never reads
195
+ * this itself - the CLI's `--data`/`--mem-stub` seeding, or a bare-library
196
+ * caller's own explicit `seedMemStub(mem, flow)` call, does the filling.
197
+ * @example const demoLogin = withMemStub(loginFlow);
198
+ */
199
+ export declare function withMemStub<Out extends Checkpoint<string>>(flow: Flow<Out>): Flow<Out>;
200
+ /**
201
+ * Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
202
+ * Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
203
+ * use this when an entire episode should be fast or slow.
204
+ * @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
205
+ * @example withDemoPace(happyFlow, 0.5) // 2x faster than normal
206
+ * @example withDemoPace(gapFlow, 2.5) // 2.5x slower
207
+ * @example withDemoPace(gapFlow, 4500) // absolute ~4.5s gates/dwells
208
+ */
209
+ export declare function withDemoPace<Out extends Checkpoint<string>>(flow: Flow<Out>, pace: DemoPace): Flow<Out>;
210
+ /**
211
+ * Default highlight look for a Flow episode (size / weight / optional tone).
212
+ * Per-slot stub or {@link withHighlightFixtures} values win when set.
213
+ * @example withHighlightStyle(withDemoPace(gapFlow, "slow"), { size: "lg", weight: "bold" })
214
+ */
215
+ export declare function withHighlightStyle<Out extends Checkpoint<string>>(flow: Flow<Out>, style: HighlightStyleDefaults): Flow<Out>;
216
+ /**
217
+ * Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
218
+ * @example withBlockPace(composeBlock("gap-review", [...]), "slow")
219
+ */
220
+ export declare function withBlockPace<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, pace: DemoPace): Block<In, Out>;
221
+ /**
222
+ * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
223
+ * login attempt genuinely failing, demonstrating the app's own real
224
+ * behavior rather than a bug in the demo. Non-destructive, same pattern as
225
+ * {@link withTitle}.
226
+ *
227
+ * By itself this changes nothing about how the Flow runs - it still throws
228
+ * Marks a Flow whose last step is an intentional product failure (demo overlay).
229
+ * Step mode shows stubOnError rings + an amber "expected outcome" panel when:
230
+ * - the last block **throws**, or
231
+ * - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
232
+ * after submit-login branched instead of throwing).
233
+ * Headless `run()` is unchanged - branching success returns the checkpoint.
234
+ * @example const viewerBlockedFlow = withExpectedFailure(
235
+ * engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
236
+ * "locked_out_user stays on LoginPage with the error banner - working as intended.",
237
+ * );
238
+ */
239
+ export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
240
+ /**
241
+ * Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
242
+ * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter/
243
+ * stubOnError slots. Non-destructive, same pattern as {@link withTitle}.
244
+ * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
245
+ * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
246
+ */
247
+ export declare function withHighlightFixtures<Out extends Checkpoint<string>>(flow: Flow<Out>, fixtures: import("../highlights.js").HighlightFixtureMap): Flow<Out>;
248
+ /**
249
+ * Runs several complete, independent Flows one after another, reusing the
250
+ * same page/context/mem in order - each remains its own validated
251
+ * start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
252
+ * untouched), this only sequences whole Flows. Deliberately NOT the same
253
+ * thing as {@link composeBlock}: composeBlock nests several Blocks into
254
+ * one Block that can drop back into another `defineFlow([...])` - a
255
+ * chainFlow result is a Flow, but not meant to be nested inside another
256
+ * Flow's own Block list. Recursive nesting stays composeBlock's job alone;
257
+ * chainFlow is a flat, one-level sequencing of complete showcases (e.g.
258
+ * "ticket #123 end to end, then ticket #456 end to end").
259
+ *
260
+ * A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
261
+ * cleared right before it runs (skipped for the very first flow in the
262
+ * chain - nothing to reset yet). The combined result is whatever the LAST
263
+ * flow resolved to.
264
+ * @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
265
+ */
266
+ export declare function chainFlow(...flows: readonly Flow<any>[]): Flow<any>;
@@ -0,0 +1,387 @@
1
+ // Split out of the former 2,900-line engine.ts (see src/ARCHITECTURE.md). Behavior unchanged.
2
+ import { connect } from "../types.js";
3
+ import { preflight, runGraph } from "./run-graph.js";
4
+ import { resolveLauncher } from "./config.js";
5
+ import { MemPage } from "../mem-page.js";
6
+ import { findBlockIndex, modVerify, withVerify } from "./core.js";
7
+ import { normalizeHighlightSize, normalizeHighlightTone, normalizeHighlightWeight } from "../highlights.js";
8
+ export function buildFlow(middle, engineConfig) {
9
+ // resetSession/title start false/unset here - defineFlow itself carries
10
+ // no such options anymore. They're set by wrapping the result in
11
+ // withSessionReset()/withTitle() instead (non-destructive decorators,
12
+ // same pattern as withVerify), which also re-apply themselves after any
13
+ // further withBlockVerify/modBlockVerify patch so the flag survives.
14
+ // Layouts have to be threaded into every pairwise connect(), not just the
15
+ // trailing runGraph call - see connect()'s own doc comment in types.ts:
16
+ // buildFlow's reduce IS the only place a defineFlow-built Flow's
17
+ // intermediate (non-final) Checkpoints are ever verified at all.
18
+ const chain = middle.reduce((a, b) => connect(a, b, engineConfig.layouts));
19
+ // Declared separately (not as the object-literal method below) so the
20
+ // three-overload `Flow.run` signature can be checked against the actual
21
+ // per-call-shape behavior, then attached with a cast - object-literal
22
+ // methods can't satisfy TS's per-overload implementation check the way a
23
+ // standalone function assigned to the property can.
24
+ const run = async function run(contextOrMem, memOrConfig, options) {
25
+ if (contextOrMem instanceof MemPage) {
26
+ const mem = contextOrMem;
27
+ const config = { ...engineConfig, ...memOrConfig };
28
+ // Checked again inside runGraph too, but doing it here first means a
29
+ // missing key fails before a browser process even launches, not just
30
+ // before a page opens - the same "fail loud, cheaply" guarantee
31
+ // run(context, mem) already has via an existing context.
32
+ preflight(mem, chain);
33
+ const browserName = config.browserName ?? "chromium";
34
+ const launch = resolveLauncher(browserName, config);
35
+ // If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
36
+ // or Flatpak Chromium), launch that instead of Playwright's own bundled
37
+ // binary - only for chromium (the env var names a Chromium build, not a
38
+ // Firefox/WebKit one), and a no-op default anywhere that env var isn't set.
39
+ const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
40
+ const browser = await launch.launch({
41
+ headless: config.headless ?? true,
42
+ ...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
43
+ ...(executablePath ? { executablePath } : {}),
44
+ });
45
+ try {
46
+ const context = await browser.newContext(config.recordVideo ? { recordVideo: config.recordVideo } : {});
47
+ try {
48
+ return await runGraph(chain, undefined, context, mem, 5000, {
49
+ ...(config.layouts !== undefined ? { layouts: config.layouts } : {}),
50
+ });
51
+ }
52
+ finally {
53
+ // Playwright finalizes recordVideo on context.close(); skip when
54
+ // not recording so mem-only runs with stub contexts stay unchanged.
55
+ if (config.recordVideo) {
56
+ await context.close();
57
+ }
58
+ }
59
+ }
60
+ finally {
61
+ await browser.close();
62
+ }
63
+ }
64
+ const context = contextOrMem;
65
+ const mem = memOrConfig;
66
+ // Open (or reuse) the page here, not inside runGraph, so this method -
67
+ // not runGraph - decides whether to close it once the run is over; the
68
+ // page reference has to survive past runGraph's own return either way.
69
+ const gotOwnPage = options?.page === undefined;
70
+ const page = options?.page ?? (await context.newPage());
71
+ // Defaulting close-on-finish to `true` unconditionally would close a
72
+ // page the *caller* opened and handed in via `options.page` - the exact
73
+ // thing this option exists to let a caller keep driving. Only default to
74
+ // closing when this call opened the page itself (no `options.page`
75
+ // given), which is the one case that matches every `run(context, mem)`
76
+ // call from before this option existed. An explicit `closeOnFinish`
77
+ // always wins either way.
78
+ const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
79
+ const resolvedLayouts = options?.layouts ?? engineConfig.layouts;
80
+ const result = await runGraph(chain, undefined, context, mem, 5000, {
81
+ page,
82
+ closeOnFinish: false,
83
+ ...(resolvedLayouts !== undefined ? { layouts: resolvedLayouts } : {}),
84
+ });
85
+ if (closeOnFinish) {
86
+ await page.close();
87
+ }
88
+ // The return *shape* only changes on an explicit `closeOnFinish: false`
89
+ // (the literal the `{ result, page }` overload is typed against) - not
90
+ // on the computed `closeOnFinish` default above. A caller who passed
91
+ // their own `page` and left `closeOnFinish` unset already holds that
92
+ // page reference; handing it back again in a different return shape
93
+ // would silently break the plain-`Out` overload their call actually
94
+ // matched at the type level.
95
+ if (options?.closeOnFinish === false) {
96
+ return { result, page };
97
+ }
98
+ return result;
99
+ };
100
+ return {
101
+ resetSession: false,
102
+ blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
103
+ run: run,
104
+ withBlockVerify(block, verify) {
105
+ const index = findBlockIndex(middle, block);
106
+ const patched = [...middle];
107
+ patched[index] = withVerify(middle[index], verify);
108
+ return buildFlow(patched, engineConfig);
109
+ },
110
+ modBlockVerify(block, nameOrIndex, newCheck) {
111
+ const index = findBlockIndex(middle, block);
112
+ const patched = [...middle];
113
+ patched[index] = modVerify(middle[index], nameOrIndex, newCheck);
114
+ return buildFlow(patched, engineConfig);
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
120
+ * browser state - e.g. a login flow meant to be re-entered later in a longer
121
+ * showcase. Non-destructive, same pattern as {@link withVerify}: returns a
122
+ * new Flow: the original is untouched, and the flag survives any further
123
+ * `withBlockVerify`/`modBlockVerify` patch applied to the result.
124
+ *
125
+ * By itself this does nothing - `Flow.run()` never reads `resetSession`.
126
+ * It's a hint {@link chainFlow} looks for: right before this Flow's first
127
+ * Block runs (skipped if it's the very first thing in the chain - nothing
128
+ * to reset yet), chainFlow clears cookies and storage so this Flow starts
129
+ * exactly as if a human had opened the app fresh, not wherever the
130
+ * previous Flow in the chain happened to leave the page.
131
+ * @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
132
+ */
133
+ export function withSessionReset(flow) {
134
+ return {
135
+ ...flow,
136
+ resetSession: true,
137
+ withBlockVerify: (block, verify) => withSessionReset(flow.withBlockVerify(block, verify)),
138
+ modBlockVerify: (block, nameOrIndex, newCheck) => withSessionReset(flow.modBlockVerify(block, nameOrIndex, newCheck)),
139
+ };
140
+ }
141
+ /**
142
+ * Names a Flow for display - a step-through overlay/showcase (waygraph's
143
+ * `chain --step`) shows this as the running banner while this Flow's own
144
+ * Blocks are executing, instead of one fixed title for the whole run.
145
+ * Non-destructive, same pattern as {@link withSessionReset}.
146
+ * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
147
+ */
148
+ export function withTitle(flow, title) {
149
+ return {
150
+ ...flow,
151
+ title,
152
+ withBlockVerify: (block, verify) => withTitle(flow.withBlockVerify(block, verify), title),
153
+ modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
154
+ };
155
+ }
156
+ /**
157
+ * Opts this Flow into memStub: `requires` keys the caller didn't supply get
158
+ * filled from `registerMemStub`'s registry (`src/mem-stub.ts`) instead of
159
+ * failing preflight, for any key that has a registered fake generator - a
160
+ * required key with no registration still fails exactly as before.
161
+ * Non-destructive, same pattern as {@link withTitle}. `run()` never reads
162
+ * this itself - the CLI's `--data`/`--mem-stub` seeding, or a bare-library
163
+ * caller's own explicit `seedMemStub(mem, flow)` call, does the filling.
164
+ * @example const demoLogin = withMemStub(loginFlow);
165
+ */
166
+ export function withMemStub(flow) {
167
+ return {
168
+ ...flow,
169
+ memStub: true,
170
+ withBlockVerify: (block, verify) => withMemStub(flow.withBlockVerify(block, verify)),
171
+ modBlockVerify: (block, nameOrIndex, newCheck) => withMemStub(flow.modBlockVerify(block, nameOrIndex, newCheck)),
172
+ };
173
+ }
174
+ /**
175
+ * Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
176
+ * Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
177
+ * use this when an entire episode should be fast or slow.
178
+ * @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
179
+ * @example withDemoPace(happyFlow, 0.5) // 2x faster than normal
180
+ * @example withDemoPace(gapFlow, 2.5) // 2.5x slower
181
+ * @example withDemoPace(gapFlow, 4500) // absolute ~4.5s gates/dwells
182
+ */
183
+ export function withDemoPace(flow, pace) {
184
+ const demoPace = pace;
185
+ return {
186
+ ...flow,
187
+ demoPace,
188
+ withBlockVerify: (block, verify) => withDemoPace(flow.withBlockVerify(block, verify), demoPace),
189
+ modBlockVerify: (block, nameOrIndex, newCheck) => withDemoPace(flow.modBlockVerify(block, nameOrIndex, newCheck), demoPace),
190
+ };
191
+ }
192
+ /**
193
+ * Default highlight look for a Flow episode (size / weight / optional tone).
194
+ * Per-slot stub or {@link withHighlightFixtures} values win when set.
195
+ * @example withHighlightStyle(withDemoPace(gapFlow, "slow"), { size: "lg", weight: "bold" })
196
+ */
197
+ export function withHighlightStyle(flow, style) {
198
+ const highlightStyle = {
199
+ ...(style.tone !== undefined ? { tone: normalizeHighlightTone(style.tone) } : {}),
200
+ ...(style.size !== undefined ? { size: normalizeHighlightSize(style.size) } : {}),
201
+ ...(style.weight !== undefined ? { weight: normalizeHighlightWeight(style.weight) } : {}),
202
+ };
203
+ return {
204
+ ...flow,
205
+ highlightStyle,
206
+ withBlockVerify: (block, verify) => withHighlightStyle(flow.withBlockVerify(block, verify), highlightStyle),
207
+ modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightStyle(flow.modBlockVerify(block, nameOrIndex, newCheck), highlightStyle),
208
+ };
209
+ }
210
+ /**
211
+ * Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
212
+ * @example withBlockPace(composeBlock("gap-review", [...]), "slow")
213
+ */
214
+ export function withBlockPace(block, pace) {
215
+ return {
216
+ ...block,
217
+ demoPace: pace,
218
+ };
219
+ }
220
+ /**
221
+ * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
222
+ * login attempt genuinely failing, demonstrating the app's own real
223
+ * behavior rather than a bug in the demo. Non-destructive, same pattern as
224
+ * {@link withTitle}.
225
+ *
226
+ * By itself this changes nothing about how the Flow runs - it still throws
227
+ * Marks a Flow whose last step is an intentional product failure (demo overlay).
228
+ * Step mode shows stubOnError rings + an amber "expected outcome" panel when:
229
+ * - the last block **throws**, or
230
+ * - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
231
+ * after submit-login branched instead of throwing).
232
+ * Headless `run()` is unchanged - branching success returns the checkpoint.
233
+ * @example const viewerBlockedFlow = withExpectedFailure(
234
+ * engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
235
+ * "locked_out_user stays on LoginPage with the error banner - working as intended.",
236
+ * );
237
+ */
238
+ export function withExpectedFailure(flow, reason) {
239
+ return {
240
+ ...flow,
241
+ expectedFailureReason: reason,
242
+ withBlockVerify: (block, verify) => withExpectedFailure(flow.withBlockVerify(block, verify), reason),
243
+ modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
244
+ };
245
+ }
246
+ /**
247
+ * Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
248
+ * (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter/
249
+ * stubOnError slots. Non-destructive, same pattern as {@link withTitle}.
250
+ * Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
251
+ * @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
252
+ */
253
+ export function withHighlightFixtures(flow, fixtures) {
254
+ return {
255
+ ...flow,
256
+ highlightFixtures: fixtures,
257
+ withBlockVerify: (block, verify) => withHighlightFixtures(flow.withBlockVerify(block, verify), fixtures),
258
+ modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightFixtures(flow.modBlockVerify(block, nameOrIndex, newCheck), fixtures),
259
+ };
260
+ }
261
+ function locateInChain(flows, block) {
262
+ if (typeof block === "number") {
263
+ let remaining = block;
264
+ for (let fi = 0; fi < flows.length; fi++) {
265
+ const len = flows[fi].blocks().length;
266
+ if (remaining < len)
267
+ return { flowIndex: fi, localIndex: remaining };
268
+ remaining -= len;
269
+ }
270
+ throw new Error(`chainFlow: no Block at index ${block} across ${flows.length} chained flows`);
271
+ }
272
+ const blockName = typeof block === "string" ? block : block.name;
273
+ for (let fi = 0; fi < flows.length; fi++) {
274
+ const localIndex = flows[fi].blocks().findIndex((bi) => bi.name === blockName);
275
+ if (localIndex !== -1)
276
+ return { flowIndex: fi, localIndex };
277
+ }
278
+ throw new Error(`chainFlow: no Block named "${blockName}" in any chained flow`);
279
+ }
280
+ async function clearSessionState(context, page) {
281
+ await context.clearCookies().catch(() => { });
282
+ await page
283
+ .evaluate(() => {
284
+ try {
285
+ localStorage.clear();
286
+ sessionStorage.clear();
287
+ }
288
+ catch {
289
+ /* storage blocked (e.g. about:blank) - nothing to clear anyway */
290
+ }
291
+ })
292
+ .catch(() => { });
293
+ }
294
+ /**
295
+ * Runs several complete, independent Flows one after another, reusing the
296
+ * same page/context/mem in order - each remains its own validated
297
+ * start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
298
+ * untouched), this only sequences whole Flows. Deliberately NOT the same
299
+ * thing as {@link composeBlock}: composeBlock nests several Blocks into
300
+ * one Block that can drop back into another `defineFlow([...])` - a
301
+ * chainFlow result is a Flow, but not meant to be nested inside another
302
+ * Flow's own Block list. Recursive nesting stays composeBlock's job alone;
303
+ * chainFlow is a flat, one-level sequencing of complete showcases (e.g.
304
+ * "ticket #123 end to end, then ticket #456 end to end").
305
+ *
306
+ * A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
307
+ * cleared right before it runs (skipped for the very first flow in the
308
+ * chain - nothing to reset yet). The combined result is whatever the LAST
309
+ * flow resolved to.
310
+ * @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
311
+ */
312
+ export function chainFlow(...flows) {
313
+ if (flows.length === 0) {
314
+ throw new Error("chainFlow: give at least one Flow");
315
+ }
316
+ const run = async function run(contextOrMem, memOrConfig, options) {
317
+ if (contextOrMem instanceof MemPage) {
318
+ const mem = contextOrMem;
319
+ const config = memOrConfig;
320
+ // One browser for the WHOLE chain - each sub-flow's own run(mem,
321
+ // config) would otherwise each launch a separate browser, defeating
322
+ // the point of sequencing them (a fresh browser is itself a full
323
+ // session reset, silently making withSessionReset a no-op).
324
+ const browserName = config?.browserName ?? "chromium";
325
+ const launch = resolveLauncher(browserName, config);
326
+ const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
327
+ const browser = await launch.launch({
328
+ headless: config?.headless ?? true,
329
+ ...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
330
+ ...(executablePath ? { executablePath } : {}),
331
+ });
332
+ try {
333
+ const context = await browser.newContext(config?.recordVideo ? { recordVideo: config.recordVideo } : {});
334
+ try {
335
+ return await run(context, mem, { closeOnFinish: true });
336
+ }
337
+ finally {
338
+ await context.close();
339
+ }
340
+ }
341
+ finally {
342
+ await browser.close();
343
+ }
344
+ }
345
+ const context = contextOrMem;
346
+ const mem = memOrConfig;
347
+ const gotOwnPage = options?.page === undefined;
348
+ const page = options?.page ?? (await context.newPage());
349
+ const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
350
+ let result;
351
+ for (let i = 0; i < flows.length; i++) {
352
+ const flow = flows[i];
353
+ if (i > 0 && flow.resetSession) {
354
+ await clearSessionState(context, page);
355
+ }
356
+ const outcome = (await flow.run(context, mem, { page, closeOnFinish: false }));
357
+ result = outcome.result;
358
+ }
359
+ if (closeOnFinish) {
360
+ await page.close();
361
+ }
362
+ if (options?.closeOnFinish === false) {
363
+ return { result, page };
364
+ }
365
+ return result;
366
+ };
367
+ return {
368
+ resetSession: false,
369
+ blocks: () => flows.flatMap((flow, idx) => flow.blocks().map((bi, i) => ({
370
+ ...bi,
371
+ ...(i === 0 && idx > 0 && flow.resetSession ? { resetSessionBefore: true } : {}),
372
+ }))),
373
+ run: run,
374
+ withBlockVerify(block, verify) {
375
+ const { flowIndex, localIndex } = locateInChain(flows, block);
376
+ const patched = [...flows];
377
+ patched[flowIndex] = patched[flowIndex].withBlockVerify(localIndex, verify);
378
+ return chainFlow(...patched);
379
+ },
380
+ modBlockVerify(block, nameOrIndex, newCheck) {
381
+ const { flowIndex, localIndex } = locateInChain(flows, block);
382
+ const patched = [...flows];
383
+ patched[flowIndex] = patched[flowIndex].modBlockVerify(localIndex, nameOrIndex, newCheck);
384
+ return chainFlow(...patched);
385
+ },
386
+ };
387
+ }