waygraph 0.15.24 → 0.15.26
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/engine/blocks/effect.d.ts +41 -0
- package/dist/engine/blocks/effect.js +23 -0
- package/dist/engine/blocks/method.d.ts +90 -0
- package/dist/engine/blocks/method.js +70 -0
- package/dist/engine/blocks/nav.d.ts +138 -0
- package/dist/engine/blocks/nav.js +110 -0
- package/dist/engine/blocks/page.d.ts +59 -0
- package/dist/engine/blocks/page.js +91 -0
- package/dist/engine/compose.d.ts +75 -0
- package/dist/engine/compose.js +76 -0
- package/dist/engine/config.d.ts +55 -0
- package/dist/engine/config.js +17 -0
- package/dist/engine/core.d.ts +92 -0
- package/dist/engine/core.js +256 -0
- package/dist/engine/engine-class.d.ts +289 -0
- package/dist/engine/engine-class.js +257 -0
- package/dist/engine/flow.d.ts +266 -0
- package/dist/engine/flow.js +387 -0
- package/dist/engine/locate.d.ts +26 -0
- package/dist/engine/locate.js +62 -0
- package/dist/engine/run-graph.d.ts +147 -0
- package/dist/engine/run-graph.js +223 -0
- package/dist/engine.d.ts +11 -1224
- package/dist/engine.js +12 -1550
- package/dist/highlights/css.d.ts +14 -0
- package/dist/highlights/css.js +58 -0
- package/dist/highlights/demo-pace.d.ts +56 -0
- package/dist/highlights/demo-pace.js +184 -0
- package/dist/highlights/device.d.ts +29 -0
- package/dist/highlights/device.js +171 -0
- package/dist/highlights/merge.d.ts +8 -0
- package/dist/highlights/merge.js +173 -0
- package/dist/highlights/run-phase.d.ts +43 -0
- package/dist/highlights/run-phase.js +211 -0
- package/dist/highlights/stub-ctx.d.ts +10 -0
- package/dist/highlights/stub-ctx.js +281 -0
- package/dist/highlights/style.d.ts +27 -0
- package/dist/highlights/style.js +84 -0
- package/dist/highlights/todo-dock.d.ts +52 -0
- package/dist/highlights/todo-dock.js +248 -0
- package/dist/highlights/types.d.ts +536 -0
- package/dist/highlights/types.js +1 -0
- package/dist/highlights.d.ts +9 -734
- package/dist/highlights.js +10 -1416
- package/examples/saucedemo/.waygraph-auto/008f4e6e.log +0 -0
- package/examples/saucedemo/.waygraph-auto/22fefa13.log +0 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|