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.
- 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/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -1,1550 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* A stealth-patched or otherwise customized launcher (e.g. `playwright-extra`
|
|
14
|
-
* plus a stealth plugin) is a drop-in replacement for `chromium`/`firefox`/
|
|
15
|
-
* `webkit` - same `.launch()` shape - so accepting one here costs nothing for
|
|
16
|
-
* a consumer who never sets it.
|
|
17
|
-
*/
|
|
18
|
-
function resolveLauncher(browserName, config) {
|
|
19
|
-
return config?.browsers?.[browserName] ?? LAUNCHERS[browserName];
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Checks `block.requires` against `mem` and throws, naming every missing key at
|
|
23
|
-
* once, if any are unset. `connect()` unions each Block's `requires` into the
|
|
24
|
-
* composed Block, so calling this on a `defineFlow`-built Flow's entry - which
|
|
25
|
-
* `runGraph` does automatically, before opening a tab - covers every Block in
|
|
26
|
-
* that flow. It does not see Blocks only reachable through `branch()` routing,
|
|
27
|
-
* since those depend on a runtime decision that hasn't happened yet.
|
|
28
|
-
*/
|
|
29
|
-
export function preflight(mem, block) {
|
|
30
|
-
const missing = (block.requires ?? []).filter((k) => !mem.has(k));
|
|
31
|
-
if (missing.length > 0) {
|
|
32
|
-
throw new Error(`preflight: MemPage is missing required key(s) before "${block.name}" can run: ${missing
|
|
33
|
-
.map((k) => `"${k.name}"`)
|
|
34
|
-
.join(", ")}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* @example
|
|
39
|
-
* const AppShellLayout = defineLayout({
|
|
40
|
-
* name: "app-shell",
|
|
41
|
-
* appliesTo: ["AppHome", "Chats", "Notifications", "Settings"],
|
|
42
|
-
* verify: [Trait.visible(SidebarSel.root)],
|
|
43
|
-
* });
|
|
44
|
-
* const engine = new Engine({ layouts: [AppShellLayout] });
|
|
45
|
-
* // Now every Block resolving to one of those four Checkpoints, in any
|
|
46
|
-
* // Flow this engine defines, also gets AppShellLayout's own verify run
|
|
47
|
-
* // automatically - a page that silently drops the sidebar fails loud,
|
|
48
|
-
* // even though no individual Block's own `verify` mentions it.
|
|
49
|
-
*/
|
|
50
|
-
export function defineLayout(options) {
|
|
51
|
-
const appliesTo = typeof options.appliesTo === "function"
|
|
52
|
-
? options.appliesTo
|
|
53
|
-
: (tag) => options.appliesTo.includes(tag);
|
|
54
|
-
return {
|
|
55
|
-
name: options.name,
|
|
56
|
-
...(options.description ? { description: options.description } : {}),
|
|
57
|
-
appliesTo,
|
|
58
|
-
verify: options.verify,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Runs every registered layout whose `appliesTo(tag)` matches the just-
|
|
63
|
-
* resolved Checkpoint, in registration order, right after the Block's own
|
|
64
|
-
* `verify` already ran. A layout's own Trait failing throws the same way
|
|
65
|
-
* any other `runVerify` failure does, naming the layout so it's clearly
|
|
66
|
-
* distinguishable from the Block's own verify in the error message.
|
|
67
|
-
*/
|
|
68
|
-
async function runLayouts(layouts, tag, out, page, mem, blockName) {
|
|
69
|
-
if (!layouts)
|
|
70
|
-
return;
|
|
71
|
-
for (const layout of layouts) {
|
|
72
|
-
if (!layout.appliesTo(tag))
|
|
73
|
-
continue;
|
|
74
|
-
await runVerify(layout.verify, out, page, mem, `${blockName} (layout: "${layout.name}")`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Runs `entry` against a fresh tab and returns the resulting terminal Checkpoint.
|
|
79
|
-
* The engine owns the tab's lifecycle, not any individual Block: the tab is
|
|
80
|
-
* created before the first `act` runs and closed in `finally`, so it closes even
|
|
81
|
-
* if a step throws.
|
|
82
|
-
*
|
|
83
|
-
* After each Block resolves, its `next` (if any, from `branch()`) is followed to
|
|
84
|
-
* the following Block, in the same tab - a Block with no `next` runs exactly once,
|
|
85
|
-
* unchanged from before `branch()` existed. `next` returning the same Block is a
|
|
86
|
-
* self-loop; `maxSteps` bounds the loop so a routing bug fails loud instead of
|
|
87
|
-
* hanging.
|
|
88
|
-
*
|
|
89
|
-
* `terminals`, if given, is a sanity check: the Checkpoint the run stops on must be
|
|
90
|
-
* one of the tags the caller actually expects, or the run fails loud instead of
|
|
91
|
-
* silently returning an unexpected result. Omit it when there is only ever one
|
|
92
|
-
* reachable outcome by construction (as with a `defineFlow`-built Flow, which has
|
|
93
|
-
* no routing at all) - there is nothing to validate against.
|
|
94
|
-
*/
|
|
95
|
-
export async function runGraph(entry, terminals, context, mem, maxSteps = 5000, options) {
|
|
96
|
-
preflight(mem, entry);
|
|
97
|
-
const page = options?.page ?? (await context.newPage());
|
|
98
|
-
const closeOnFinish = options?.closeOnFinish ?? true;
|
|
99
|
-
try {
|
|
100
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
-
let current = entry;
|
|
102
|
-
let input = checkpoint("__start__");
|
|
103
|
-
let steps = 0;
|
|
104
|
-
for (;;) {
|
|
105
|
-
if (++steps > maxSteps) {
|
|
106
|
-
throw new Error(`runGraph: exceeded ${maxSteps} steps - check for an unintended self-loop`);
|
|
107
|
-
}
|
|
108
|
-
await runPrecondition(current.instruction.precondition, input, page, mem, current.name);
|
|
109
|
-
await current.instruction.act(page, input, mem);
|
|
110
|
-
const observed = current.instruction.observe
|
|
111
|
-
? await current.instruction.observe(page, mem)
|
|
112
|
-
: undefined;
|
|
113
|
-
const checkpoint = await current.instruction.resolve(observed);
|
|
114
|
-
await runVerify(current.instruction.verify, checkpoint, page, mem, current.name);
|
|
115
|
-
await runLayouts(options?.layouts, checkpoint.__state, checkpoint, page, mem, current.name);
|
|
116
|
-
const next = current.next?.(checkpoint);
|
|
117
|
-
if (!next) {
|
|
118
|
-
if (terminals && !terminals.has(checkpoint.__state)) {
|
|
119
|
-
throw new Error(`runGraph: "${current.name}" resolved to "${checkpoint.__state}", which is not a registered terminal (expected one of: ${[...terminals].join(", ")})`);
|
|
120
|
-
}
|
|
121
|
-
return checkpoint;
|
|
122
|
-
}
|
|
123
|
-
current = next;
|
|
124
|
-
input = checkpoint;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
finally {
|
|
128
|
-
if (closeOnFinish) {
|
|
129
|
-
await page.close();
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Drives a genuinely separate second tab through its own Block/Flow, in the
|
|
135
|
-
* same browser context an existing `page` already belongs to - the primitive
|
|
136
|
-
* `observe()` is specifically allowed to reach for (see {@link Instruction.observe}),
|
|
137
|
-
* since `act`/`resolve` can't touch `page.context()` at all. A thin, named
|
|
138
|
-
* wrapper around `runGraph` (which already opens its own page from whatever
|
|
139
|
-
* context it's given) rather than a new mechanism - the point is making the
|
|
140
|
-
* pattern discoverable and giving it a real name, not inventing new tab-lifecycle
|
|
141
|
-
* logic. The spawned tab is closed (by `runGraph`'s own `finally`) before this
|
|
142
|
-
* resolves; the original `page` is never touched.
|
|
143
|
-
* @example
|
|
144
|
-
* async observe(page, mem) {
|
|
145
|
-
* const receipt = await spawnTab(EmailReceiptFlow, page, mem);
|
|
146
|
-
* mem.set(ReceiptCode, receipt.__state);
|
|
147
|
-
* }
|
|
148
|
-
*/
|
|
149
|
-
export async function spawnTab(entry, page, mem, maxSteps = 5000) {
|
|
150
|
-
return runGraph(entry, undefined, page.context(), mem, maxSteps);
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Wraps one `act()` interaction with a caption and highlight, for a Block
|
|
154
|
-
* author who wants to explicitly narrate a specific step rather than rely
|
|
155
|
-
* on the generic per-fill/per-click narration `waygraph chain
|
|
156
|
-
* WAYGRAPH_STEP=1` already does automatically -
|
|
157
|
-
* `await narrate(submitButton, "confirms the order", () => submitButton.click())`
|
|
158
|
-
* reads at the call site instead of being inferred.
|
|
159
|
-
*
|
|
160
|
-
* A true no-op everywhere except inside a `WAYGRAPH_STEP=1` run: it checks
|
|
161
|
-
* for a `window.__wgPositionRing` hook the step-mode overlay installs on
|
|
162
|
-
* the page before doing anything, so a headless/CI/automated run (nobody
|
|
163
|
-
* watching, per-character delays and highlight pauses would just be wasted
|
|
164
|
-
* time) pays zero extra cost - not even the boundingBox() lookup runs. The
|
|
165
|
-
* `action` itself always runs regardless; only the narration is
|
|
166
|
-
* conditional.
|
|
167
|
-
* @example await narrate(submitButton, "confirms the order", () => submitButton.click())
|
|
168
|
-
*/
|
|
169
|
-
export async function narrate(locator, caption, action) {
|
|
170
|
-
const page = locator.page();
|
|
171
|
-
const active = await page
|
|
172
|
-
.evaluate(() => typeof globalThis.__wgPositionRing === "function")
|
|
173
|
-
.catch(() => false);
|
|
174
|
-
if (!active)
|
|
175
|
-
return action();
|
|
176
|
-
let owning = false;
|
|
177
|
-
try {
|
|
178
|
-
const box = await locator.boundingBox();
|
|
179
|
-
if (box) {
|
|
180
|
-
await page
|
|
181
|
-
.evaluate(({ box, caption }) => {
|
|
182
|
-
const w = globalThis;
|
|
183
|
-
if (w.__wgPositionRing)
|
|
184
|
-
w.__wgPositionRing(box, caption, "planned");
|
|
185
|
-
// The CLI's own automatic per-fill/per-click narration checks
|
|
186
|
-
// this before showing (and overwriting) its own guess - an
|
|
187
|
-
// explicitly authored caption should win, not get immediately
|
|
188
|
-
// replaced a moment later by the generic one.
|
|
189
|
-
w.__wgLastNarrate = Date.now();
|
|
190
|
-
// Also claim the ring itself: the auto-highlight click/fill
|
|
191
|
-
// patches each hide the ring right after THEIR own step
|
|
192
|
-
// finishes, which - if action() below does more than one thing
|
|
193
|
-
// (e.g. a click then a wait for a toast) - would wipe this
|
|
194
|
-
// caption after just the first sub-step instead of the whole
|
|
195
|
-
// narrated action. Their hideRing() calls no-op while this is
|
|
196
|
-
// true; narrate() below is the one that actually clears it.
|
|
197
|
-
w.__wgNarrateOwnsRing = true;
|
|
198
|
-
}, { box, caption })
|
|
199
|
-
.catch(() => { });
|
|
200
|
-
owning = true;
|
|
201
|
-
// The ring alone is pointless if action() fires on the very next
|
|
202
|
-
// tick - a click that navigates (e.g. a form submit) wipes the ring
|
|
203
|
-
// before a human can read the caption at all. Give it the same
|
|
204
|
-
// "pop for a few seconds" dwell as the CLI's own auto-highlight
|
|
205
|
-
// clicks before the real action runs.
|
|
206
|
-
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
207
|
-
// Re-stamp __wgLastNarrate right before handing off to action() - the
|
|
208
|
-
// CLI's auto-highlight patches only treat a narrate() as "just
|
|
209
|
-
// handled" within a short window (500ms), which the dwell above
|
|
210
|
-
// already burned through. Without this, action()'s own click/fill
|
|
211
|
-
// (same patched Locator prototype) would stomp this caption with its
|
|
212
|
-
// own generic one a moment after it finally became visible.
|
|
213
|
-
await page
|
|
214
|
-
.evaluate(() => {
|
|
215
|
-
globalThis.__wgLastNarrate = Date.now();
|
|
216
|
-
})
|
|
217
|
-
.catch(() => { });
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// best-effort - the real action below still runs either way
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
return await action();
|
|
225
|
-
}
|
|
226
|
-
finally {
|
|
227
|
-
if (owning) {
|
|
228
|
-
await page
|
|
229
|
-
.evaluate(() => {
|
|
230
|
-
const w = globalThis;
|
|
231
|
-
w.__wgNarrateOwnsRing = false;
|
|
232
|
-
if (w.__wgHideRing)
|
|
233
|
-
w.__wgHideRing();
|
|
234
|
-
})
|
|
235
|
-
.catch(() => { });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
/** Reserved markers bookending a `defineFlow([start, ...blocks, end])` call. */
|
|
240
|
-
export const start = Symbol("waygraph.start");
|
|
241
|
-
export const end = Symbol("waygraph.end");
|
|
242
|
-
function findBlockIndex(middle, block) {
|
|
243
|
-
if (typeof block === "number") {
|
|
244
|
-
if (block < 0 || block >= middle.length) {
|
|
245
|
-
throw new Error(`Flow: no Block at index ${block} in this flow (has ${middle.length})`);
|
|
246
|
-
}
|
|
247
|
-
return block;
|
|
248
|
-
}
|
|
249
|
-
const blockName = typeof block === "string" ? block : block.name;
|
|
250
|
-
const index = middle.findIndex((b) => b.name === blockName);
|
|
251
|
-
if (index === -1) {
|
|
252
|
-
throw new Error(`Flow: no Block named "${blockName}" in this flow (has: ${middle.map((b) => b.name).join(", ")})`);
|
|
253
|
-
}
|
|
254
|
-
return index;
|
|
255
|
-
}
|
|
256
|
-
function buildFlow(middle, engineConfig) {
|
|
257
|
-
// resetSession/title start false/unset here - defineFlow itself carries
|
|
258
|
-
// no such options anymore. They're set by wrapping the result in
|
|
259
|
-
// withSessionReset()/withTitle() instead (non-destructive decorators,
|
|
260
|
-
// same pattern as withVerify), which also re-apply themselves after any
|
|
261
|
-
// further withBlockVerify/modBlockVerify patch so the flag survives.
|
|
262
|
-
// Layouts have to be threaded into every pairwise connect(), not just the
|
|
263
|
-
// trailing runGraph call - see connect()'s own doc comment in types.ts:
|
|
264
|
-
// buildFlow's reduce IS the only place a defineFlow-built Flow's
|
|
265
|
-
// intermediate (non-final) Checkpoints are ever verified at all.
|
|
266
|
-
const chain = middle.reduce((a, b) => connect(a, b, engineConfig.layouts));
|
|
267
|
-
// Declared separately (not as the object-literal method below) so the
|
|
268
|
-
// three-overload `Flow.run` signature can be checked against the actual
|
|
269
|
-
// per-call-shape behavior, then attached with a cast - object-literal
|
|
270
|
-
// methods can't satisfy TS's per-overload implementation check the way a
|
|
271
|
-
// standalone function assigned to the property can.
|
|
272
|
-
const run = async function run(contextOrMem, memOrConfig, options) {
|
|
273
|
-
if (contextOrMem instanceof MemPage) {
|
|
274
|
-
const mem = contextOrMem;
|
|
275
|
-
const config = { ...engineConfig, ...memOrConfig };
|
|
276
|
-
// Checked again inside runGraph too, but doing it here first means a
|
|
277
|
-
// missing key fails before a browser process even launches, not just
|
|
278
|
-
// before a page opens - the same "fail loud, cheaply" guarantee
|
|
279
|
-
// run(context, mem) already has via an existing context.
|
|
280
|
-
preflight(mem, chain);
|
|
281
|
-
const browserName = config.browserName ?? "chromium";
|
|
282
|
-
const launch = resolveLauncher(browserName, config);
|
|
283
|
-
// If CHROME_PATH/CHROMIUM_PATH is set in the environment (e.g. a system
|
|
284
|
-
// or Flatpak Chromium), launch that instead of Playwright's own bundled
|
|
285
|
-
// binary - only for chromium (the env var names a Chromium build, not a
|
|
286
|
-
// Firefox/WebKit one), and a no-op default anywhere that env var isn't set.
|
|
287
|
-
const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
|
|
288
|
-
const browser = await launch.launch({
|
|
289
|
-
headless: config.headless ?? true,
|
|
290
|
-
...(config.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
|
|
291
|
-
...(executablePath ? { executablePath } : {}),
|
|
292
|
-
});
|
|
293
|
-
try {
|
|
294
|
-
const context = await browser.newContext(config.recordVideo ? { recordVideo: config.recordVideo } : {});
|
|
295
|
-
try {
|
|
296
|
-
return await runGraph(chain, undefined, context, mem, 5000, {
|
|
297
|
-
...(config.layouts !== undefined ? { layouts: config.layouts } : {}),
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
finally {
|
|
301
|
-
// Playwright finalizes recordVideo on context.close(); skip when
|
|
302
|
-
// not recording so mem-only runs with stub contexts stay unchanged.
|
|
303
|
-
if (config.recordVideo) {
|
|
304
|
-
await context.close();
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
finally {
|
|
309
|
-
await browser.close();
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
const context = contextOrMem;
|
|
313
|
-
const mem = memOrConfig;
|
|
314
|
-
// Open (or reuse) the page here, not inside runGraph, so this method -
|
|
315
|
-
// not runGraph - decides whether to close it once the run is over; the
|
|
316
|
-
// page reference has to survive past runGraph's own return either way.
|
|
317
|
-
const gotOwnPage = options?.page === undefined;
|
|
318
|
-
const page = options?.page ?? (await context.newPage());
|
|
319
|
-
// Defaulting close-on-finish to `true` unconditionally would close a
|
|
320
|
-
// page the *caller* opened and handed in via `options.page` - the exact
|
|
321
|
-
// thing this option exists to let a caller keep driving. Only default to
|
|
322
|
-
// closing when this call opened the page itself (no `options.page`
|
|
323
|
-
// given), which is the one case that matches every `run(context, mem)`
|
|
324
|
-
// call from before this option existed. An explicit `closeOnFinish`
|
|
325
|
-
// always wins either way.
|
|
326
|
-
const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
|
|
327
|
-
const resolvedLayouts = options?.layouts ?? engineConfig.layouts;
|
|
328
|
-
const result = await runGraph(chain, undefined, context, mem, 5000, {
|
|
329
|
-
page,
|
|
330
|
-
closeOnFinish: false,
|
|
331
|
-
...(resolvedLayouts !== undefined ? { layouts: resolvedLayouts } : {}),
|
|
332
|
-
});
|
|
333
|
-
if (closeOnFinish) {
|
|
334
|
-
await page.close();
|
|
335
|
-
}
|
|
336
|
-
// The return *shape* only changes on an explicit `closeOnFinish: false`
|
|
337
|
-
// (the literal the `{ result, page }` overload is typed against) - not
|
|
338
|
-
// on the computed `closeOnFinish` default above. A caller who passed
|
|
339
|
-
// their own `page` and left `closeOnFinish` unset already holds that
|
|
340
|
-
// page reference; handing it back again in a different return shape
|
|
341
|
-
// would silently break the plain-`Out` overload their call actually
|
|
342
|
-
// matched at the type level.
|
|
343
|
-
if (options?.closeOnFinish === false) {
|
|
344
|
-
return { result, page };
|
|
345
|
-
}
|
|
346
|
-
return result;
|
|
347
|
-
};
|
|
348
|
-
return {
|
|
349
|
-
resetSession: false,
|
|
350
|
-
blocks: () => middle.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
|
|
351
|
-
run: run,
|
|
352
|
-
withBlockVerify(block, verify) {
|
|
353
|
-
const index = findBlockIndex(middle, block);
|
|
354
|
-
const patched = [...middle];
|
|
355
|
-
patched[index] = withVerify(middle[index], verify);
|
|
356
|
-
return buildFlow(patched, engineConfig);
|
|
357
|
-
},
|
|
358
|
-
modBlockVerify(block, nameOrIndex, newCheck) {
|
|
359
|
-
const index = findBlockIndex(middle, block);
|
|
360
|
-
const patched = [...middle];
|
|
361
|
-
patched[index] = modVerify(middle[index], nameOrIndex, newCheck);
|
|
362
|
-
return buildFlow(patched, engineConfig);
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* Marks a Flow as expecting to start from a genuinely fresh, unauthenticated
|
|
368
|
-
* browser state - e.g. a login flow meant to be re-entered later in a longer
|
|
369
|
-
* showcase. Non-destructive, same pattern as {@link withVerify}: returns a
|
|
370
|
-
* new Flow: the original is untouched, and the flag survives any further
|
|
371
|
-
* `withBlockVerify`/`modBlockVerify` patch applied to the result.
|
|
372
|
-
*
|
|
373
|
-
* By itself this does nothing - `Flow.run()` never reads `resetSession`.
|
|
374
|
-
* It's a hint {@link chainFlow} looks for: right before this Flow's first
|
|
375
|
-
* Block runs (skipped if it's the very first thing in the chain - nothing
|
|
376
|
-
* to reset yet), chainFlow clears cookies and storage so this Flow starts
|
|
377
|
-
* exactly as if a human had opened the app fresh, not wherever the
|
|
378
|
-
* previous Flow in the chain happened to leave the page.
|
|
379
|
-
* @example const loginFlow = withSessionReset(engine.defineFlow([start, LoginBlock, end]));
|
|
380
|
-
*/
|
|
381
|
-
export function withSessionReset(flow) {
|
|
382
|
-
return {
|
|
383
|
-
...flow,
|
|
384
|
-
resetSession: true,
|
|
385
|
-
withBlockVerify: (block, verify) => withSessionReset(flow.withBlockVerify(block, verify)),
|
|
386
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withSessionReset(flow.modBlockVerify(block, nameOrIndex, newCheck)),
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
/**
|
|
390
|
-
* Names a Flow for display - a step-through overlay/showcase (waygraph's
|
|
391
|
-
* `chain --step`) shows this as the running banner while this Flow's own
|
|
392
|
-
* Blocks are executing, instead of one fixed title for the whole run.
|
|
393
|
-
* Non-destructive, same pattern as {@link withSessionReset}.
|
|
394
|
-
* @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
|
|
395
|
-
*/
|
|
396
|
-
export function withTitle(flow, title) {
|
|
397
|
-
return {
|
|
398
|
-
...flow,
|
|
399
|
-
title,
|
|
400
|
-
withBlockVerify: (block, verify) => withTitle(flow.withBlockVerify(block, verify), title),
|
|
401
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* Opts this Flow into memStub: `requires` keys the caller didn't supply get
|
|
406
|
-
* filled from `registerMemStub`'s registry (`src/mem-stub.ts`) instead of
|
|
407
|
-
* failing preflight, for any key that has a registered fake generator - a
|
|
408
|
-
* required key with no registration still fails exactly as before.
|
|
409
|
-
* Non-destructive, same pattern as {@link withTitle}. `run()` never reads
|
|
410
|
-
* this itself - the CLI's `--data`/`--mem-stub` seeding, or a bare-library
|
|
411
|
-
* caller's own explicit `seedMemStub(mem, flow)` call, does the filling.
|
|
412
|
-
* @example const demoLogin = withMemStub(loginFlow);
|
|
413
|
-
*/
|
|
414
|
-
export function withMemStub(flow) {
|
|
415
|
-
return {
|
|
416
|
-
...flow,
|
|
417
|
-
memStub: true,
|
|
418
|
-
withBlockVerify: (block, verify) => withMemStub(flow.withBlockVerify(block, verify)),
|
|
419
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withMemStub(flow.modBlockVerify(block, nameOrIndex, newCheck)),
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
|
|
424
|
-
* Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
|
|
425
|
-
* use this when an entire episode should be fast or slow.
|
|
426
|
-
* @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
|
|
427
|
-
* @example withDemoPace(happyFlow, 0.5) // 2x faster than normal
|
|
428
|
-
* @example withDemoPace(gapFlow, 2.5) // 2.5x slower
|
|
429
|
-
* @example withDemoPace(gapFlow, 4500) // absolute ~4.5s gates/dwells
|
|
430
|
-
*/
|
|
431
|
-
export function withDemoPace(flow, pace) {
|
|
432
|
-
const demoPace = pace;
|
|
433
|
-
return {
|
|
434
|
-
...flow,
|
|
435
|
-
demoPace,
|
|
436
|
-
withBlockVerify: (block, verify) => withDemoPace(flow.withBlockVerify(block, verify), demoPace),
|
|
437
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withDemoPace(flow.modBlockVerify(block, nameOrIndex, newCheck), demoPace),
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Default highlight look for a Flow episode (size / weight / optional tone).
|
|
442
|
-
* Per-slot stub or {@link withHighlightFixtures} values win when set.
|
|
443
|
-
* @example withHighlightStyle(withDemoPace(gapFlow, "slow"), { size: "lg", weight: "bold" })
|
|
444
|
-
*/
|
|
445
|
-
export function withHighlightStyle(flow, style) {
|
|
446
|
-
const highlightStyle = {
|
|
447
|
-
...(style.tone !== undefined ? { tone: normalizeHighlightTone(style.tone) } : {}),
|
|
448
|
-
...(style.size !== undefined ? { size: normalizeHighlightSize(style.size) } : {}),
|
|
449
|
-
...(style.weight !== undefined ? { weight: normalizeHighlightWeight(style.weight) } : {}),
|
|
450
|
-
};
|
|
451
|
-
return {
|
|
452
|
-
...flow,
|
|
453
|
-
highlightStyle,
|
|
454
|
-
withBlockVerify: (block, verify) => withHighlightStyle(flow.withBlockVerify(block, verify), highlightStyle),
|
|
455
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightStyle(flow.modBlockVerify(block, nameOrIndex, newCheck), highlightStyle),
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
|
|
460
|
-
* @example withBlockPace(composeBlock("gap-review", [...]), "slow")
|
|
461
|
-
*/
|
|
462
|
-
export function withBlockPace(block, pace) {
|
|
463
|
-
return {
|
|
464
|
-
...block,
|
|
465
|
-
demoPace: pace,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
|
|
470
|
-
* login attempt genuinely failing, demonstrating the app's own real
|
|
471
|
-
* behavior rather than a bug in the demo. Non-destructive, same pattern as
|
|
472
|
-
* {@link withTitle}.
|
|
473
|
-
*
|
|
474
|
-
* By itself this changes nothing about how the Flow runs - it still throws
|
|
475
|
-
* Marks a Flow whose last step is an intentional product failure (demo overlay).
|
|
476
|
-
* Step mode shows stubOnError rings + an amber "expected outcome" panel when:
|
|
477
|
-
* - the last block **throws**, or
|
|
478
|
-
* - the last block **succeeds** on the fail branch (e.g. LoginPage + error banner
|
|
479
|
-
* after submit-login branched instead of throwing).
|
|
480
|
-
* Headless `run()` is unchanged - branching success returns the checkpoint.
|
|
481
|
-
* @example const viewerBlockedFlow = withExpectedFailure(
|
|
482
|
-
* engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, end]),
|
|
483
|
-
* "locked_out_user stays on LoginPage with the error banner - working as intended.",
|
|
484
|
-
* );
|
|
485
|
-
*/
|
|
486
|
-
export function withExpectedFailure(flow, reason) {
|
|
487
|
-
return {
|
|
488
|
-
...flow,
|
|
489
|
-
expectedFailureReason: reason,
|
|
490
|
-
withBlockVerify: (block, verify) => withExpectedFailure(flow.withBlockVerify(block, verify), reason),
|
|
491
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
/**
|
|
495
|
-
* Attaches demo highlight fixtures to a Flow - unlimited purposes on `.flow.ts`
|
|
496
|
-
* (AC copy, BUG/GATE tags, detail) merged onto each block's stubBefore/stubAfter/
|
|
497
|
-
* stubOnError slots. Non-destructive, same pattern as {@link withTitle}.
|
|
498
|
-
* Demo/`chain --step` reads `flow.highlightFixtures`; `run()` ignores it.
|
|
499
|
-
* @example withHighlightFixtures(issueFlow, { "submit-login": { stubBefore: { email: { label: "AC-1" } } } })
|
|
500
|
-
*/
|
|
501
|
-
export function withHighlightFixtures(flow, fixtures) {
|
|
502
|
-
return {
|
|
503
|
-
...flow,
|
|
504
|
-
highlightFixtures: fixtures,
|
|
505
|
-
withBlockVerify: (block, verify) => withHighlightFixtures(flow.withBlockVerify(block, verify), fixtures),
|
|
506
|
-
modBlockVerify: (block, nameOrIndex, newCheck) => withHighlightFixtures(flow.modBlockVerify(block, nameOrIndex, newCheck), fixtures),
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
function locateInChain(flows, block) {
|
|
510
|
-
if (typeof block === "number") {
|
|
511
|
-
let remaining = block;
|
|
512
|
-
for (let fi = 0; fi < flows.length; fi++) {
|
|
513
|
-
const len = flows[fi].blocks().length;
|
|
514
|
-
if (remaining < len)
|
|
515
|
-
return { flowIndex: fi, localIndex: remaining };
|
|
516
|
-
remaining -= len;
|
|
517
|
-
}
|
|
518
|
-
throw new Error(`chainFlow: no Block at index ${block} across ${flows.length} chained flows`);
|
|
519
|
-
}
|
|
520
|
-
const blockName = typeof block === "string" ? block : block.name;
|
|
521
|
-
for (let fi = 0; fi < flows.length; fi++) {
|
|
522
|
-
const localIndex = flows[fi].blocks().findIndex((bi) => bi.name === blockName);
|
|
523
|
-
if (localIndex !== -1)
|
|
524
|
-
return { flowIndex: fi, localIndex };
|
|
525
|
-
}
|
|
526
|
-
throw new Error(`chainFlow: no Block named "${blockName}" in any chained flow`);
|
|
527
|
-
}
|
|
528
|
-
async function clearSessionState(context, page) {
|
|
529
|
-
await context.clearCookies().catch(() => { });
|
|
530
|
-
await page
|
|
531
|
-
.evaluate(() => {
|
|
532
|
-
try {
|
|
533
|
-
localStorage.clear();
|
|
534
|
-
sessionStorage.clear();
|
|
535
|
-
}
|
|
536
|
-
catch {
|
|
537
|
-
/* storage blocked (e.g. about:blank) - nothing to clear anyway */
|
|
538
|
-
}
|
|
539
|
-
})
|
|
540
|
-
.catch(() => { });
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Runs several complete, independent Flows one after another, reusing the
|
|
544
|
-
* same page/context/mem in order - each remains its own validated
|
|
545
|
-
* start-to-end unit (a Flow's own `[start, ...blocks, end]` contract is
|
|
546
|
-
* untouched), this only sequences whole Flows. Deliberately NOT the same
|
|
547
|
-
* thing as {@link composeBlock}: composeBlock nests several Blocks into
|
|
548
|
-
* one Block that can drop back into another `defineFlow([...])` - a
|
|
549
|
-
* chainFlow result is a Flow, but not meant to be nested inside another
|
|
550
|
-
* Flow's own Block list. Recursive nesting stays composeBlock's job alone;
|
|
551
|
-
* chainFlow is a flat, one-level sequencing of complete showcases (e.g.
|
|
552
|
-
* "ticket #123 end to end, then ticket #456 end to end").
|
|
553
|
-
*
|
|
554
|
-
* A sub-flow wrapped in {@link withSessionReset} gets its cookies/storage
|
|
555
|
-
* cleared right before it runs (skipped for the very first flow in the
|
|
556
|
-
* chain - nothing to reset yet). The combined result is whatever the LAST
|
|
557
|
-
* flow resolved to.
|
|
558
|
-
* @example chainFlow(ticket123Flow, ticket456Flow).run(context, mem)
|
|
559
|
-
*/
|
|
560
|
-
export function chainFlow(...flows) {
|
|
561
|
-
if (flows.length === 0) {
|
|
562
|
-
throw new Error("chainFlow: give at least one Flow");
|
|
563
|
-
}
|
|
564
|
-
const run = async function run(contextOrMem, memOrConfig, options) {
|
|
565
|
-
if (contextOrMem instanceof MemPage) {
|
|
566
|
-
const mem = contextOrMem;
|
|
567
|
-
const config = memOrConfig;
|
|
568
|
-
// One browser for the WHOLE chain - each sub-flow's own run(mem,
|
|
569
|
-
// config) would otherwise each launch a separate browser, defeating
|
|
570
|
-
// the point of sequencing them (a fresh browser is itself a full
|
|
571
|
-
// session reset, silently making withSessionReset a no-op).
|
|
572
|
-
const browserName = config?.browserName ?? "chromium";
|
|
573
|
-
const launch = resolveLauncher(browserName, config);
|
|
574
|
-
const executablePath = browserName === "chromium" ? process.env.CHROME_PATH || process.env.CHROMIUM_PATH : undefined;
|
|
575
|
-
const browser = await launch.launch({
|
|
576
|
-
headless: config?.headless ?? true,
|
|
577
|
-
...(config?.slowMo !== undefined ? { slowMo: config.slowMo } : {}),
|
|
578
|
-
...(executablePath ? { executablePath } : {}),
|
|
579
|
-
});
|
|
580
|
-
try {
|
|
581
|
-
const context = await browser.newContext(config?.recordVideo ? { recordVideo: config.recordVideo } : {});
|
|
582
|
-
try {
|
|
583
|
-
return await run(context, mem, { closeOnFinish: true });
|
|
584
|
-
}
|
|
585
|
-
finally {
|
|
586
|
-
await context.close();
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
finally {
|
|
590
|
-
await browser.close();
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
const context = contextOrMem;
|
|
594
|
-
const mem = memOrConfig;
|
|
595
|
-
const gotOwnPage = options?.page === undefined;
|
|
596
|
-
const page = options?.page ?? (await context.newPage());
|
|
597
|
-
const closeOnFinish = options?.closeOnFinish ?? gotOwnPage;
|
|
598
|
-
let result;
|
|
599
|
-
for (let i = 0; i < flows.length; i++) {
|
|
600
|
-
const flow = flows[i];
|
|
601
|
-
if (i > 0 && flow.resetSession) {
|
|
602
|
-
await clearSessionState(context, page);
|
|
603
|
-
}
|
|
604
|
-
const outcome = (await flow.run(context, mem, { page, closeOnFinish: false }));
|
|
605
|
-
result = outcome.result;
|
|
606
|
-
}
|
|
607
|
-
if (closeOnFinish) {
|
|
608
|
-
await page.close();
|
|
609
|
-
}
|
|
610
|
-
if (options?.closeOnFinish === false) {
|
|
611
|
-
return { result, page };
|
|
612
|
-
}
|
|
613
|
-
return result;
|
|
614
|
-
};
|
|
615
|
-
return {
|
|
616
|
-
resetSession: false,
|
|
617
|
-
blocks: () => flows.flatMap((flow, idx) => flow.blocks().map((bi, i) => ({
|
|
618
|
-
...bi,
|
|
619
|
-
...(i === 0 && idx > 0 && flow.resetSession ? { resetSessionBefore: true } : {}),
|
|
620
|
-
}))),
|
|
621
|
-
run: run,
|
|
622
|
-
withBlockVerify(block, verify) {
|
|
623
|
-
const { flowIndex, localIndex } = locateInChain(flows, block);
|
|
624
|
-
const patched = [...flows];
|
|
625
|
-
patched[flowIndex] = patched[flowIndex].withBlockVerify(localIndex, verify);
|
|
626
|
-
return chainFlow(...patched);
|
|
627
|
-
},
|
|
628
|
-
modBlockVerify(block, nameOrIndex, newCheck) {
|
|
629
|
-
const { flowIndex, localIndex } = locateInChain(flows, block);
|
|
630
|
-
const patched = [...flows];
|
|
631
|
-
patched[flowIndex] = patched[flowIndex].modBlockVerify(localIndex, nameOrIndex, newCheck);
|
|
632
|
-
return chainFlow(...patched);
|
|
633
|
-
},
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
export function composeBlock(name, steps) {
|
|
637
|
-
if (steps.length === 0) {
|
|
638
|
-
throw new Error(`composeBlock: "${name}" needs at least one step`);
|
|
639
|
-
}
|
|
640
|
-
const chain = steps.reduce((a, b) => connect(a, b));
|
|
641
|
-
return {
|
|
642
|
-
...chain,
|
|
643
|
-
name,
|
|
644
|
-
steps: () => steps.map((b) => ({ name: b.name, block: b, ...(b.routes ? { routes: b.routes } : {}) })),
|
|
645
|
-
withStepVerify(step, verify) {
|
|
646
|
-
const index = findBlockIndex(steps, step);
|
|
647
|
-
const patched = [...steps];
|
|
648
|
-
patched[index] = withVerify(steps[index], verify);
|
|
649
|
-
return composeBlock(name, patched);
|
|
650
|
-
},
|
|
651
|
-
modStepVerify(step, nameOrIndex, newCheck) {
|
|
652
|
-
const index = findBlockIndex(steps, step);
|
|
653
|
-
const patched = [...steps];
|
|
654
|
-
patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
|
|
655
|
-
return composeBlock(name, patched);
|
|
656
|
-
},
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
/** True when `block` came from {@link fastForwardComposeBlock}. */
|
|
660
|
-
export function isFastForwardBlock(block) {
|
|
661
|
-
return (block.fastForward === true &&
|
|
662
|
-
typeof block.steps === "function");
|
|
663
|
-
}
|
|
664
|
-
function wrapFfError(ffName, err) {
|
|
665
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
666
|
-
if (msg.startsWith(`${ffName} > `)) {
|
|
667
|
-
return err instanceof Error ? err : new Error(msg);
|
|
668
|
-
}
|
|
669
|
-
const wrapped = new Error(`${ffName} > ${msg}`);
|
|
670
|
-
if (err instanceof Error && err.stack) {
|
|
671
|
-
wrapped.stack = err.stack;
|
|
672
|
-
}
|
|
673
|
-
return wrapped;
|
|
674
|
-
}
|
|
675
|
-
export function fastForwardComposeBlock(name, steps) {
|
|
676
|
-
if (steps.length === 0) {
|
|
677
|
-
throw new Error(`fastForwardComposeBlock: "${name}" needs at least one step`);
|
|
678
|
-
}
|
|
679
|
-
const base = composeBlock(name, steps);
|
|
680
|
-
const origAct = base.instruction.act.bind(base.instruction);
|
|
681
|
-
const ff = {
|
|
682
|
-
...base,
|
|
683
|
-
fastForward: true,
|
|
684
|
-
instruction: {
|
|
685
|
-
...base.instruction,
|
|
686
|
-
async act(page, input, mem) {
|
|
687
|
-
try {
|
|
688
|
-
await origAct(page, input, mem);
|
|
689
|
-
}
|
|
690
|
-
catch (e) {
|
|
691
|
-
throw wrapFfError(name, e);
|
|
692
|
-
}
|
|
693
|
-
},
|
|
694
|
-
},
|
|
695
|
-
withStepVerify(step, verify) {
|
|
696
|
-
const index = findBlockIndex(steps, step);
|
|
697
|
-
const patched = [...steps];
|
|
698
|
-
patched[index] = withVerify(steps[index], verify);
|
|
699
|
-
return fastForwardComposeBlock(name, patched);
|
|
700
|
-
},
|
|
701
|
-
modStepVerify(step, nameOrIndex, newCheck) {
|
|
702
|
-
const index = findBlockIndex(steps, step);
|
|
703
|
-
const patched = [...steps];
|
|
704
|
-
patched[index] = modVerify(steps[index], nameOrIndex, newCheck);
|
|
705
|
-
return fastForwardComposeBlock(name, patched);
|
|
706
|
-
},
|
|
707
|
-
};
|
|
708
|
-
return ff;
|
|
709
|
-
}
|
|
710
|
-
/**
|
|
711
|
-
* Holds engine-level configuration shared across every flow defined from it -
|
|
712
|
-
* for now, just how to launch a browser when a Flow owns its own
|
|
713
|
-
* (`headless`/`browserName`/`slowMo`); traits/interstitials/watchers land here
|
|
714
|
-
* in a later change.
|
|
715
|
-
* @example const engine = new Engine({ headless: false, slowMo: 250 });
|
|
716
|
-
*/
|
|
717
|
-
export class Engine {
|
|
718
|
-
config;
|
|
719
|
-
constructor(config = {}) {
|
|
720
|
-
this.config = config;
|
|
721
|
-
}
|
|
722
|
-
defineFlow(blocks) {
|
|
723
|
-
return buildFlow(blocks.slice(1, -1), this.config);
|
|
724
|
-
}
|
|
725
|
-
/**
|
|
726
|
-
* Fluent, kind-checked alternative to `defineFlow([start, ...blocks, end])` -
|
|
727
|
-
* see {@link MapBuilder}. Real, direct request this responds to: pia/zsign's
|
|
728
|
-
* own agents kept hand-editing/hand-composing Blocks into ad hoc shapes
|
|
729
|
-
* ("locks" convention tried, still got broken) - `map()` forces every step
|
|
730
|
-
* through this Engine's own `define*Block` factories (checked by the same
|
|
731
|
-
* `__waygraphKind`/`__waygraphSalt` runtime markers `graph.ts`/`map-check.ts`
|
|
732
|
-
* already trust), so a hand-rolled plain-object Block can never silently
|
|
733
|
-
* pass as a real navigation/assertion/method step. `homeOrigin`, if given,
|
|
734
|
-
* also gates `.gotoPage()`/`.gotoExternal()` against each Nav/Page Block's
|
|
735
|
-
* own static `url` (skipped, honestly, for click-based/dynamic nav - not
|
|
736
|
-
* statically checkable, same limitation `map-check.ts` already documents).
|
|
737
|
-
* @example
|
|
738
|
-
* const flow = engine.map({ homeOrigin: "https://app.example.com" })
|
|
739
|
-
* .start()
|
|
740
|
-
* .gotoPage(NavHomeBlock)
|
|
741
|
-
* .assert(AssertHelloBlock)
|
|
742
|
-
* .gotoExternal(NavMailpitBlock)
|
|
743
|
-
* .end();
|
|
744
|
-
*/
|
|
745
|
-
map(options) {
|
|
746
|
-
return MapBuilder.begin(this, options?.homeOrigin);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
function mapKindOf(block) {
|
|
750
|
-
return block?.__waygraphKind;
|
|
751
|
-
}
|
|
752
|
-
function mapSaltOf(block) {
|
|
753
|
-
return block?.__waygraphSalt;
|
|
754
|
-
}
|
|
755
|
-
function mapNavUrlOf(block) {
|
|
756
|
-
const url = block?.__waygraphNavUrl;
|
|
757
|
-
return typeof url === "string" ? url : undefined;
|
|
758
|
-
}
|
|
759
|
-
const MAP_KIND_FACTORY_HINT = {
|
|
760
|
-
nav: "defineNavBlock/defineMemNavBlock",
|
|
761
|
-
page: "definePageBlock",
|
|
762
|
-
assert: "defineAssertBlock",
|
|
763
|
-
};
|
|
764
|
-
const MAP_SALT_FACTORY_HINT = {
|
|
765
|
-
method: "defineMethodBlock",
|
|
766
|
-
effect: "defineEffectBlock",
|
|
767
|
-
};
|
|
768
|
-
function assertMapKind(block, allowed, method) {
|
|
769
|
-
const kind = mapKindOf(block);
|
|
770
|
-
if (kind !== undefined && allowed.includes(kind))
|
|
771
|
-
return;
|
|
772
|
-
const wanted = allowed.map((k) => MAP_KIND_FACTORY_HINT[k] ?? k).join(" or ");
|
|
773
|
-
const found = kind ? `a Block of kind "${kind}"` : "an object with no waygraph kind marker at all";
|
|
774
|
-
throw new Error(`Waygraph map: .${method}("${block?.name ?? "?"}") requires a Block built with ${wanted} ` +
|
|
775
|
-
`(found ${found} - a hand-built plain object doesn't count). This check is the whole point ` +
|
|
776
|
-
"of the map() builder: only real Blocks from waygraph's own factories can enter a chain.");
|
|
777
|
-
}
|
|
778
|
-
function assertMapSalt(block, allowed, method) {
|
|
779
|
-
const salt = mapSaltOf(block);
|
|
780
|
-
if (salt !== undefined && allowed.includes(salt))
|
|
781
|
-
return;
|
|
782
|
-
const wanted = allowed.map((s) => MAP_SALT_FACTORY_HINT[s] ?? s).join(" or ");
|
|
783
|
-
const found = salt ? `a Block salted "${salt}"` : "an object with no waygraph salt marker at all";
|
|
784
|
-
throw new Error(`Waygraph map: .${method}("${block?.name ?? "?"}") requires a Block built with ${wanted} ` +
|
|
785
|
-
`(found ${found} - a hand-built plain object doesn't count). This check is the whole point ` +
|
|
786
|
-
"of the map() builder: only real Blocks from waygraph's own factories can enter a chain.");
|
|
787
|
-
}
|
|
788
|
-
function assertMapOrigin(block, homeOrigin, expect, method) {
|
|
789
|
-
if (!homeOrigin)
|
|
790
|
-
return; // not configured - can't validate, honest no-op
|
|
791
|
-
const navUrl = mapNavUrlOf(block);
|
|
792
|
-
if (navUrl === undefined)
|
|
793
|
-
return; // click-based/dynamic nav - not statically checkable
|
|
794
|
-
let targetOrigin;
|
|
795
|
-
let wantOrigin;
|
|
796
|
-
try {
|
|
797
|
-
targetOrigin = new URL(navUrl, homeOrigin).origin;
|
|
798
|
-
wantOrigin = new URL(homeOrigin).origin;
|
|
799
|
-
}
|
|
800
|
-
catch {
|
|
801
|
-
return; // malformed URL - not this check's job to validate that
|
|
802
|
-
}
|
|
803
|
-
const isExternal = targetOrigin !== wantOrigin;
|
|
804
|
-
if (expect === "internal" && isExternal) {
|
|
805
|
-
throw new Error(`Waygraph map: .gotoPage("${block.name}") targets ${targetOrigin}, which is NOT this map's ` +
|
|
806
|
-
`home origin (${wantOrigin}) - use .gotoExternal() for a genuinely cross-origin destination.`);
|
|
807
|
-
}
|
|
808
|
-
if (expect === "external" && !isExternal) {
|
|
809
|
-
throw new Error(`Waygraph map: .gotoExternal("${block.name}") targets ${targetOrigin}, which IS this map's ` +
|
|
810
|
-
`home origin (${wantOrigin}) - use .gotoPage() for an internal destination.`);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Fluent builder over {@link Engine.defineFlow} - see `map()`'s own doc
|
|
815
|
-
* comment for why this exists. Each step method is scoped to exactly the
|
|
816
|
-
* Block kind its name promises, checked at RUNTIME against the
|
|
817
|
-
* `__waygraphKind`/`__waygraphSalt` markers `defineNavBlock`/`definePageBlock`/
|
|
818
|
-
* `defineAssertBlock`/`defineMethodBlock`/`defineEffectBlock` already set
|
|
819
|
-
* (the same markers `graph.ts` and `map-check.ts` trust) - not just a type
|
|
820
|
-
* hint, since a hand-rolled object can satisfy the TypeScript `Block<In,Out>`
|
|
821
|
-
* shape without ever going through a real factory. Each call also
|
|
822
|
-
* typechecks the accumulated Checkpoint chain exactly like `defineFlow`'s
|
|
823
|
-
* own tuple overloads do (a step's `In` must equal the previous step's
|
|
824
|
-
* `Out`) - this is what "no teleporting" means: there is no method on this
|
|
825
|
-
* builder that can skip from one Checkpoint to an unrelated one without a
|
|
826
|
-
* real, kind-correct Block in between.
|
|
827
|
-
*/
|
|
828
|
-
export class MapBuilder {
|
|
829
|
-
engine;
|
|
830
|
-
steps;
|
|
831
|
-
homeOrigin;
|
|
832
|
-
ff;
|
|
833
|
-
constructor(engine, steps, homeOrigin, ff = null) {
|
|
834
|
-
this.engine = engine;
|
|
835
|
-
this.steps = steps;
|
|
836
|
-
this.homeOrigin = homeOrigin;
|
|
837
|
-
this.ff = ff;
|
|
838
|
-
}
|
|
839
|
-
/** @internal - use `engine.map()` or the standalone `map()` export. */
|
|
840
|
-
static begin(engine, homeOrigin) {
|
|
841
|
-
return new MapBuilder(engine, [], homeOrigin, null);
|
|
842
|
-
}
|
|
843
|
-
/** Readable chain-opener - mirrors `defineFlow`'s leading `start` sentinel. Returns `this` unchanged; entirely optional. */
|
|
844
|
-
start() {
|
|
845
|
-
return this;
|
|
846
|
-
}
|
|
847
|
-
/**
|
|
848
|
-
* Open a fast-forward window - steps until {@link ffEnd} collapse into one
|
|
849
|
-
* opaque `fastForwardComposeBlock` (blitz theater). Kind/salt checks still
|
|
850
|
-
* run on each inner step as it is added.
|
|
851
|
-
*/
|
|
852
|
-
ffStart(name) {
|
|
853
|
-
if (this.ff) {
|
|
854
|
-
throw new Error(`Waygraph map: .ffStart() while already inside "${this.ff.name}" - call .ffEnd() first`);
|
|
855
|
-
}
|
|
856
|
-
const ffName = typeof name === "string" && name.trim()
|
|
857
|
-
? name.trim()
|
|
858
|
-
: `ff-${this.steps.length + 1}`;
|
|
859
|
-
return new MapBuilder(this.engine, this.steps, this.homeOrigin, {
|
|
860
|
-
name: ffName,
|
|
861
|
-
buffer: [],
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Close the current fast-forward window and append one FFCompose step.
|
|
866
|
-
* Throws if empty or if {@link ffStart} was never opened.
|
|
867
|
-
*/
|
|
868
|
-
ffEnd() {
|
|
869
|
-
if (!this.ff) {
|
|
870
|
-
throw new Error("Waygraph map: .ffEnd() with no open .ffStart()");
|
|
871
|
-
}
|
|
872
|
-
if (this.ff.buffer.length === 0) {
|
|
873
|
-
throw new Error(`Waygraph map: .ffEnd() for "${this.ff.name}" has no steps - add .gotoPage/.method/… inside the window`);
|
|
874
|
-
}
|
|
875
|
-
const composed = fastForwardComposeBlock(this.ff.name,
|
|
876
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
877
|
-
this.ff.buffer);
|
|
878
|
-
return new MapBuilder(this.engine, [...this.steps, composed], this.homeOrigin, null);
|
|
879
|
-
}
|
|
880
|
-
appendStep(block) {
|
|
881
|
-
if (this.ff) {
|
|
882
|
-
return new MapBuilder(this.engine, this.steps, this.homeOrigin, {
|
|
883
|
-
name: this.ff.name,
|
|
884
|
-
buffer: [...this.ff.buffer, block],
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
return new MapBuilder(this.engine, [...this.steps, block], this.homeOrigin, null);
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Appends an internal navigation/page-arrival step - requires a Block from
|
|
891
|
-
* `defineNavBlock`/`defineMemNavBlock`/`definePageBlock`. Throws if given
|
|
892
|
-
* anything else, including a `homeOrigin`-mismatched static `url`.
|
|
893
|
-
* Compile-time: {@link NavBlock} or {@link PageBlock} only (not Assert/Method).
|
|
894
|
-
*/
|
|
895
|
-
gotoPage(block) {
|
|
896
|
-
assertMapKind(block, ["nav", "page"], "gotoPage");
|
|
897
|
-
assertMapOrigin(block, this.homeOrigin, "internal", "gotoPage");
|
|
898
|
-
return this.appendStep(block);
|
|
899
|
-
}
|
|
900
|
-
/**
|
|
901
|
-
* Appends a genuinely cross-origin navigation step (e.g. `(external)/`
|
|
902
|
-
* Blocks in the Waygraph Map convention: mailpit, maildrop.cc) - same
|
|
903
|
-
* kind requirement as {@link gotoPage}, plus the inverse `homeOrigin` check.
|
|
904
|
-
* Compile-time: {@link NavBlock} or {@link PageBlock} only.
|
|
905
|
-
*/
|
|
906
|
-
gotoExternal(block) {
|
|
907
|
-
assertMapKind(block, ["nav", "page"], "gotoExternal");
|
|
908
|
-
assertMapOrigin(block, this.homeOrigin, "external", "gotoExternal");
|
|
909
|
-
return this.appendStep(block);
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Appends a self-loop verification step - requires a Block from
|
|
913
|
-
* `defineAssertBlock`. Compile-time: {@link AssertBlock} only (not `.method()`).
|
|
914
|
-
*/
|
|
915
|
-
assert(block) {
|
|
916
|
-
assertMapKind(block, ["assert"], "assert");
|
|
917
|
-
return this.appendStep(block);
|
|
918
|
-
}
|
|
919
|
-
/**
|
|
920
|
-
* Appends a non-navigating page action (submit, upload, add/remove an
|
|
921
|
-
* instance) - requires a Block from `defineMethodBlock`/`defineActionBlock`/
|
|
922
|
-
* `defineEffectBlock`/`defineMemEffectBlock`.
|
|
923
|
-
* Compile-time: {@link MethodBlock} or {@link EffectBlock} only - Assert
|
|
924
|
-
* Blocks must use `.assert()`. Runtime also rejects `__waygraphKind =
|
|
925
|
-
* "assert"` (those still carry method salt from the factory internals).
|
|
926
|
-
*/
|
|
927
|
-
method(block) {
|
|
928
|
-
if (mapKindOf(block) === "assert") {
|
|
929
|
-
throw new Error(`Waygraph map: .method("${block.name}") got a defineAssertBlock - use .assert() for ` +
|
|
930
|
-
"self-loop verify steps (Assert Blocks share method salt internally but are not methods).");
|
|
931
|
-
}
|
|
932
|
-
assertMapSalt(block, ["method", "effect"], "method");
|
|
933
|
-
return this.appendStep(block);
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Finalizes this chain into a real, runnable {@link Flow} - same object
|
|
937
|
-
* `defineFlow` returns, so `withBlockVerify`/`modBlockVerify` and every
|
|
938
|
-
* `withTitle`/`withHighlightFixtures`/etc. decorator still apply exactly
|
|
939
|
-
* as they do today; this builder only changes how the chain is assembled,
|
|
940
|
-
* never what it produces. Throws if no step was ever added - an empty map
|
|
941
|
-
* isn't a flow.
|
|
942
|
-
*/
|
|
943
|
-
end() {
|
|
944
|
-
if (this.ff) {
|
|
945
|
-
throw new Error(`Waygraph map: .end() while .ffStart("${this.ff.name}") is still open - call .ffEnd() first`);
|
|
946
|
-
}
|
|
947
|
-
if (this.steps.length === 0) {
|
|
948
|
-
throw new Error("Waygraph map: .end() called with zero steps - add at least one .gotoPage()/.gotoExternal()/" +
|
|
949
|
-
".assert()/.method() before .end()");
|
|
950
|
-
}
|
|
951
|
-
const chain = [start, ...this.steps, end];
|
|
952
|
-
return this.engine.defineFlow(chain);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Standalone convenience for `new Engine(config).map(options)` - use this
|
|
957
|
-
* when a call site doesn't otherwise need its own `Engine` instance (no
|
|
958
|
-
* shared `headless`/`slowMo`/`layouts` across several flows).
|
|
959
|
-
* @example const flow = map({ homeOrigin: "https://app.example.com" }).gotoPage(NavHomeBlock).end();
|
|
960
|
-
*/
|
|
961
|
-
export function map(options) {
|
|
962
|
-
return new Engine(options).map(options);
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* Attaches routing to `block` without touching its act/observe/resolve/verify -
|
|
966
|
-
* `routes` must cover every tag of `block`'s output (a missing route is a compile
|
|
967
|
-
* error), and the returned Block runs exactly as `block` always did, just followed
|
|
968
|
-
* by whichever next Block the resolved tag routes to. `null` for a tag means that
|
|
969
|
-
* tag is terminal - the run stops there, same as a Block with no routing at all.
|
|
970
|
-
* A route pointing back at `block` itself is a self-loop; see `runGraph`'s
|
|
971
|
-
* `maxSteps` for its safety net. The return type is deliberately `Block<In, any>` -
|
|
972
|
-
* a route can lead anywhere, including back through itself, so there is no fixed
|
|
973
|
-
* Out type to express; state the expected outcomes to `runGraph` via `terminals`
|
|
974
|
-
* instead.
|
|
975
|
-
*/
|
|
976
|
-
export function branch(block, routes) {
|
|
977
|
-
const base = stripMethods(block);
|
|
978
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
979
|
-
let routed;
|
|
980
|
-
const routesAsData = {};
|
|
981
|
-
for (const [tag, target] of Object.entries(routes)) {
|
|
982
|
-
routesAsData[tag] = target === null ? null : target === block ? base.name : target.name;
|
|
983
|
-
}
|
|
984
|
-
routed = defineBlock({
|
|
985
|
-
...base,
|
|
986
|
-
next: (checkpoint) => {
|
|
987
|
-
const target = routes[checkpoint.__state];
|
|
988
|
-
if (target === undefined || target === null)
|
|
989
|
-
return undefined;
|
|
990
|
-
// A route naming `block` itself (the only self-reference in scope when
|
|
991
|
-
// `routes` was written) means "loop back with routing intact" - substitute
|
|
992
|
-
// the routed Block, not the original with no `next` of its own.
|
|
993
|
-
return target === block ? routed : target;
|
|
994
|
-
},
|
|
995
|
-
routes: routesAsData,
|
|
996
|
-
});
|
|
997
|
-
return routed;
|
|
998
|
-
}
|
|
999
|
-
/**
|
|
1000
|
-
* Strips `withVerify`/`modVerify`/`modVerifyAll` off a Block, keeping everything
|
|
1001
|
-
* else - `name`, `instruction`, `next`, `requires`. Every function below that
|
|
1002
|
-
* returns a new Block calls this on its input first, then rebuilds through
|
|
1003
|
-
* `defineBlock`. Without it, a Block's attached methods close over the shape it
|
|
1004
|
-
* had *when they were attached* - call `.withVerify()` on a Block that
|
|
1005
|
-
* `branch()` added routing to afterward, and without this step the result would
|
|
1006
|
-
* silently drop that routing, since the stale closure never knew about it.
|
|
1007
|
-
*/
|
|
1008
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1009
|
-
function stripMethods(block) {
|
|
1010
|
-
return {
|
|
1011
|
-
name: block.name,
|
|
1012
|
-
instruction: block.instruction,
|
|
1013
|
-
...(block.description ? { description: block.description } : {}),
|
|
1014
|
-
...(block.next ? { next: block.next } : {}),
|
|
1015
|
-
...(block.requires ? { requires: block.requires } : {}),
|
|
1016
|
-
...(block.routes ? { routes: block.routes } : {}),
|
|
1017
|
-
...(block.instanceOptions ? { instanceOptions: block.instanceOptions } : {}),
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
/** Copy non-enumerable waygraph markers so decorate/withVerify keep map() kind checks. */
|
|
1021
|
-
function copyWaygraphRuntime(from, to) {
|
|
1022
|
-
for (const key of [
|
|
1023
|
-
"__waygraphKind",
|
|
1024
|
-
"__waygraphSalt",
|
|
1025
|
-
"__waygraphNavUrl",
|
|
1026
|
-
"__waygraphNavClick",
|
|
1027
|
-
]) {
|
|
1028
|
-
const desc = Object.getOwnPropertyDescriptor(from, key);
|
|
1029
|
-
if (!desc)
|
|
1030
|
-
continue;
|
|
1031
|
-
// Re-define as configurable so later decorate chains can copy again
|
|
1032
|
-
// (factory markers are non-configurable on the original).
|
|
1033
|
-
Object.defineProperty(to, key, {
|
|
1034
|
-
value: desc.value,
|
|
1035
|
-
enumerable: false,
|
|
1036
|
-
writable: false,
|
|
1037
|
-
configurable: true,
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
const ff = from.fastForward;
|
|
1041
|
-
if (ff === true) {
|
|
1042
|
-
to.fastForward = true;
|
|
1043
|
-
}
|
|
1044
|
-
const pace = from.demoPace;
|
|
1045
|
-
if (pace !== undefined) {
|
|
1046
|
-
to.demoPace = pace;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
/**
|
|
1050
|
-
* Builds a Block with `withVerify`/`modVerify`/`modVerifyAll` attached as
|
|
1051
|
-
* methods - `LoginBlock.withVerify([...])`, discoverable by typing
|
|
1052
|
-
* `LoginBlock.` in an editor, not just callable as `withVerify(LoginBlock, ...)`
|
|
1053
|
-
* if you already know it exists. A raw object literal is still a perfectly
|
|
1054
|
-
* valid Block (every test fixture in this codebase is one) - it just won't have
|
|
1055
|
-
* these attached. The methods are thin: they delegate to the identically-named
|
|
1056
|
-
* free functions, which remain the one place this logic actually lives.
|
|
1057
|
-
*/
|
|
1058
|
-
export function defineBlock(base) {
|
|
1059
|
-
const plain = {
|
|
1060
|
-
name: base.name,
|
|
1061
|
-
instruction: base.instruction,
|
|
1062
|
-
...(base.description ? { description: base.description } : {}),
|
|
1063
|
-
...(base.next ? { next: base.next } : {}),
|
|
1064
|
-
...(base.requires ? { requires: base.requires } : {}),
|
|
1065
|
-
...(base.routes ? { routes: base.routes } : {}),
|
|
1066
|
-
...(base.instanceOptions ? { instanceOptions: base.instanceOptions } : {}),
|
|
1067
|
-
};
|
|
1068
|
-
// Methods must close over `defined` (this object), not `plain` - factories
|
|
1069
|
-
// attach __waygraphKind/__waygraphSalt on the returned object after
|
|
1070
|
-
// defineBlock returns; decorate/withVerify need those markers.
|
|
1071
|
-
const defined = {
|
|
1072
|
-
...plain,
|
|
1073
|
-
withVerify: (verify) => withVerify(defined, verify),
|
|
1074
|
-
modVerify: (nameOrIndex, newCheck) => modVerify(defined, nameOrIndex, newCheck),
|
|
1075
|
-
modVerifyAll: (patches) => modVerifyAll(defined, patches),
|
|
1076
|
-
stubBefore: (stub) => withStubBefore(defined, stub),
|
|
1077
|
-
stubAfter: (stub) => withStubAfter(defined, stub),
|
|
1078
|
-
stubOnError: (stub) => withStubOnError(defined, stub),
|
|
1079
|
-
};
|
|
1080
|
-
return defined;
|
|
1081
|
-
}
|
|
1082
|
-
/**
|
|
1083
|
-
* Builds a {@link NavBlock}. The generated `act()` is always exactly
|
|
1084
|
-
* `page.goto(url)` or `page.locator(click).click()` - never anything else,
|
|
1085
|
-
* never author-supplied. Callers get a real `Page` here internally (the one
|
|
1086
|
-
* legitimate place navigation belongs); `defineBlock`'s own `ActionPage`
|
|
1087
|
-
* narrowing is what discourages navigation everywhere else, and never
|
|
1088
|
-
* applies to this generated function since no author ever writes it by
|
|
1089
|
-
* hand.
|
|
1090
|
-
* @example defineNavBlock({ name: "nav-web-login", checkpoint: "LoginForm", url: "/login" })
|
|
1091
|
-
* @example defineNavBlock({ name: "nav-web-request-detail", checkpoint: "RequestDetail", url: (mem) => `/dashboard/requests/${mem.get(RequestId.key)}` })
|
|
1092
|
-
* @example defineNavClickBlock({ name: "nav-web-documents", checkpoint: "DocumentsList", click: "nav >> text=Documents" })
|
|
1093
|
-
* For click-nav in app code prefer {@link defineNavClickBlock} (url stays here).
|
|
1094
|
-
*/
|
|
1095
|
-
export function defineNavBlock(options) {
|
|
1096
|
-
const built = defineBlock({
|
|
1097
|
-
name: options.name,
|
|
1098
|
-
...(options.description ? { description: options.description } : {}),
|
|
1099
|
-
...(options.requires ? { requires: options.requires } : {}),
|
|
1100
|
-
...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
|
|
1101
|
-
instruction: {
|
|
1102
|
-
async act(page, _input, mem) {
|
|
1103
|
-
if (options.url !== undefined) {
|
|
1104
|
-
const url = typeof options.url === "function" ? options.url(mem) : options.url;
|
|
1105
|
-
await page.goto(url);
|
|
1106
|
-
}
|
|
1107
|
-
else {
|
|
1108
|
-
// Always Locator.click() so step-mode's demo cursor/pulse patch
|
|
1109
|
-
// (instrumentInteractionHighlighting) sees NavBlock click-nav -
|
|
1110
|
-
// never page.click() / goto teleport.
|
|
1111
|
-
const selector = typeof options.click === "function" ? options.click(mem) : options.click;
|
|
1112
|
-
await page.locator(selector).click();
|
|
1113
|
-
}
|
|
1114
|
-
},
|
|
1115
|
-
resolve: () => checkpoint(options.checkpoint),
|
|
1116
|
-
...(options.verify ? { verify: options.verify } : {}),
|
|
1117
|
-
...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
|
|
1118
|
-
...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
|
|
1119
|
-
...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
|
|
1120
|
-
...(options.highlights ? { highlights: options.highlights } : {}),
|
|
1121
|
-
},
|
|
1122
|
-
});
|
|
1123
|
-
// Non-enumerable so it never shows up in Object.keys/JSON/autocomplete -
|
|
1124
|
-
// purely a runtime marker `waygraph check` (or anything else walking a
|
|
1125
|
-
// project's Blocks) can test for to tell a NavBlock apart from a regular
|
|
1126
|
-
// one, since the TypeScript type alone (NavBlock = DefinedBlock) doesn't
|
|
1127
|
-
// survive to runtime.
|
|
1128
|
-
Object.defineProperty(built, "__waygraphKind", {
|
|
1129
|
-
value: "nav",
|
|
1130
|
-
enumerable: false,
|
|
1131
|
-
configurable: false,
|
|
1132
|
-
});
|
|
1133
|
-
// Demo/step tooling: remember the click selector (or factory) so
|
|
1134
|
-
// overlays can label "nav click" without re-parsing source.
|
|
1135
|
-
if (options.click !== undefined) {
|
|
1136
|
-
Object.defineProperty(built, "__waygraphNavClick", {
|
|
1137
|
-
value: options.click,
|
|
1138
|
-
enumerable: false,
|
|
1139
|
-
configurable: false,
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
// Same idea, for the url case - only when it's a plain string (a
|
|
1143
|
-
// function URL is mem-dependent/dynamic, can't be compared statically
|
|
1144
|
-
// without running it). Real, direct request this responds to: an agent
|
|
1145
|
-
// driving Blind Pilot had no structured way to ask "which of the links
|
|
1146
|
-
// on THIS page are already covered by a NavBlock" without re-parsing
|
|
1147
|
-
// every nav.block.ts's own source - this is what `AutoSession` now reads
|
|
1148
|
-
// to warn about real, unmapped nav links found live on the page.
|
|
1149
|
-
if (typeof options.url === "string") {
|
|
1150
|
-
Object.defineProperty(built, "__waygraphNavUrl", {
|
|
1151
|
-
value: options.url,
|
|
1152
|
-
enumerable: false,
|
|
1153
|
-
configurable: false,
|
|
1154
|
-
});
|
|
1155
|
-
}
|
|
1156
|
-
return built;
|
|
1157
|
-
}
|
|
1158
|
-
/** @see {@link NavClickBlockOptions} */
|
|
1159
|
-
export function defineNavClickBlock(options) {
|
|
1160
|
-
return defineNavBlock({
|
|
1161
|
-
name: options.name,
|
|
1162
|
-
checkpoint: options.checkpoint,
|
|
1163
|
-
click: options.click,
|
|
1164
|
-
...(options.description ? { description: options.description } : {}),
|
|
1165
|
-
...(options.requires ? { requires: options.requires } : {}),
|
|
1166
|
-
...(options.verify ? { verify: options.verify } : {}),
|
|
1167
|
-
...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
|
|
1168
|
-
...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
|
|
1169
|
-
...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
|
|
1170
|
-
...(options.highlights ? { highlights: options.highlights } : {}),
|
|
1171
|
-
...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1174
|
-
export function defineMethodBlock(base) {
|
|
1175
|
-
const built = defineBlock(base);
|
|
1176
|
-
Object.defineProperty(built, "__waygraphSalt", {
|
|
1177
|
-
value: "method",
|
|
1178
|
-
enumerable: false,
|
|
1179
|
-
configurable: false,
|
|
1180
|
-
});
|
|
1181
|
-
return built;
|
|
1182
|
-
}
|
|
1183
|
-
/** @deprecated Prefer {@link defineMethodBlock} - identical helper (0.7 name). */
|
|
1184
|
-
export const defineActionBlock = defineMethodBlock;
|
|
1185
|
-
/**
|
|
1186
|
-
* Sugar for a self-loop-only "pure assertion" Block: no hand-written
|
|
1187
|
-
* `act`/`resolve`, just a name, a `checkpoint` tag, and a `verify` array.
|
|
1188
|
-
* `resolve` is generated from the one `checkpoint` field given, closing the
|
|
1189
|
-
* copy/rename footgun where a hand-restated checkpoint string can silently
|
|
1190
|
-
* drift wrong across a copy/pasted file. Mirrors `definePageBlock`'s own
|
|
1191
|
-
* construction pattern (flat options in, `defineBlock` built internally).
|
|
1192
|
-
* See openspec/changes/waygraph-agent-skill-hardening/spec.md.
|
|
1193
|
-
* @example
|
|
1194
|
-
* defineAssertBlock({
|
|
1195
|
-
* name: "assert-raw-materials-table",
|
|
1196
|
-
* checkpoint: "RawMaterials",
|
|
1197
|
-
* waitForHeading: "Raw Materials & Lead Times",
|
|
1198
|
-
* verify: [Trait.visible(RawMaterialsSel.reorderBadge)],
|
|
1199
|
-
* });
|
|
1200
|
-
*/
|
|
1201
|
-
export function defineAssertBlock(options) {
|
|
1202
|
-
const built = defineMethodBlock({
|
|
1203
|
-
name: options.name,
|
|
1204
|
-
...(options.description ? { description: options.description } : {}),
|
|
1205
|
-
...(options.requires ? { requires: options.requires } : {}),
|
|
1206
|
-
instruction: {
|
|
1207
|
-
async act(page) {
|
|
1208
|
-
if (options.waitForHeading !== undefined) {
|
|
1209
|
-
await page.getByRole("heading", { name: options.waitForHeading }).waitFor();
|
|
1210
|
-
}
|
|
1211
|
-
},
|
|
1212
|
-
resolve: () => checkpoint(options.checkpoint),
|
|
1213
|
-
verify: options.verify,
|
|
1214
|
-
...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
|
|
1215
|
-
...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
|
|
1216
|
-
...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
|
|
1217
|
-
...(options.slides ? { slides: options.slides } : {}),
|
|
1218
|
-
},
|
|
1219
|
-
});
|
|
1220
|
-
// Real bug this closes: `waygraph graph`'s static discovery only knows
|
|
1221
|
-
// how to read a Block's Checkpoint tags from an EXPLICIT `<In, Out>`
|
|
1222
|
-
// generic written in the source text (or, for Nav/Page Blocks, by calling
|
|
1223
|
-
// their own input-independent `resolve()` directly). `defineAssertBlock`'s
|
|
1224
|
-
// whole point is skipping that generic - called exactly as documented
|
|
1225
|
-
// (`defineAssertBlock({ name, checkpoint, verify })`, no generics), it was
|
|
1226
|
-
// silently invisible to both `waygraph graph` and the live Pilot menu
|
|
1227
|
-
// ("2 Block(s) skipped (Out not resolvable)", confirmed live against a
|
|
1228
|
-
// real veciro-waygraph session). `resolve()` here is ALSO input-
|
|
1229
|
-
// independent (always `checkpoint(options.checkpoint)`), so the same
|
|
1230
|
-
// resolve()-calling path Nav/Page Blocks already use is reused via this
|
|
1231
|
-
// marker instead of requiring authors to write generics just to be found.
|
|
1232
|
-
Object.defineProperty(built, "__waygraphKind", {
|
|
1233
|
-
value: "assert",
|
|
1234
|
-
enumerable: false,
|
|
1235
|
-
configurable: false,
|
|
1236
|
-
});
|
|
1237
|
-
// Brand for map().assert() - distinct from MethodBlock so .method(assert)
|
|
1238
|
-
// is a compile error even though runtime still carries method salt.
|
|
1239
|
-
return built;
|
|
1240
|
-
}
|
|
1241
|
-
function resolvePageMethods(methods) {
|
|
1242
|
-
if (!methods)
|
|
1243
|
-
return [];
|
|
1244
|
-
const entries = Array.isArray(methods) ? methods : Object.values(methods);
|
|
1245
|
-
return entries.map((entry) => (typeof entry === "function" ? entry() : entry));
|
|
1246
|
-
}
|
|
1247
|
-
/** @see {@link PageBlockOptions} */
|
|
1248
|
-
export function definePageBlock(options) {
|
|
1249
|
-
if (options.url !== undefined && options.click !== undefined) {
|
|
1250
|
-
throw new Error(`definePageBlock("${options.name}"): pass url XOR click (or neither for arrival-only hub)`);
|
|
1251
|
-
}
|
|
1252
|
-
const methodList = resolvePageMethods(options.methods);
|
|
1253
|
-
const built = defineBlock({
|
|
1254
|
-
name: options.name,
|
|
1255
|
-
...(options.description ? { description: options.description } : {}),
|
|
1256
|
-
...(options.requires ? { requires: options.requires } : {}),
|
|
1257
|
-
...(options.instanceOptions ? { instanceOptions: options.instanceOptions } : {}),
|
|
1258
|
-
instruction: {
|
|
1259
|
-
async act(page, _input, mem) {
|
|
1260
|
-
if (options.url !== undefined) {
|
|
1261
|
-
const url = typeof options.url === "function" ? options.url(mem) : options.url;
|
|
1262
|
-
await page.goto(url);
|
|
1263
|
-
}
|
|
1264
|
-
else if (options.click !== undefined) {
|
|
1265
|
-
const selector = typeof options.click === "function" ? options.click(mem) : options.click;
|
|
1266
|
-
await page.locator(selector).click();
|
|
1267
|
-
}
|
|
1268
|
-
// else: arrival-only hub — previous Block already landed here
|
|
1269
|
-
},
|
|
1270
|
-
resolve: () => checkpoint(options.checkpoint),
|
|
1271
|
-
...(options.verify ? { verify: options.verify } : {}),
|
|
1272
|
-
...(options.stubBefore ? { stubBefore: options.stubBefore } : {}),
|
|
1273
|
-
...(options.stubAfter ? { stubAfter: options.stubAfter } : {}),
|
|
1274
|
-
...(options.stubOnError ? { stubOnError: options.stubOnError } : {}),
|
|
1275
|
-
...(options.highlights ? { highlights: options.highlights } : {}),
|
|
1276
|
-
},
|
|
1277
|
-
});
|
|
1278
|
-
Object.defineProperty(built, "__waygraphKind", {
|
|
1279
|
-
value: "page",
|
|
1280
|
-
enumerable: false,
|
|
1281
|
-
configurable: false,
|
|
1282
|
-
});
|
|
1283
|
-
Object.defineProperty(built, "__waygraphSalt", {
|
|
1284
|
-
value: "page",
|
|
1285
|
-
enumerable: false,
|
|
1286
|
-
configurable: false,
|
|
1287
|
-
});
|
|
1288
|
-
Object.defineProperty(built, "methods", {
|
|
1289
|
-
value: Object.freeze([...methodList]),
|
|
1290
|
-
enumerable: true,
|
|
1291
|
-
configurable: false,
|
|
1292
|
-
writable: false,
|
|
1293
|
-
});
|
|
1294
|
-
Object.defineProperty(built, "__waygraphMethods", {
|
|
1295
|
-
value: methodList.map((m) => m.name),
|
|
1296
|
-
enumerable: false,
|
|
1297
|
-
configurable: false,
|
|
1298
|
-
});
|
|
1299
|
-
// Same marker `defineNavBlock` sets, same reason - a page hub with a
|
|
1300
|
-
// `url` (deep-link) is exactly as "known, coverable" as a plain
|
|
1301
|
-
// `defineNavBlock`, and in practice most real projects' own nav Blocks
|
|
1302
|
-
// ARE page hubs (methods hanging off them), not bare NavBlocks - the real
|
|
1303
|
-
// saucedemo fixture this whole package tests against uses definePageBlock
|
|
1304
|
-
// for every one of its own nav-* Blocks. Missing this here meant the
|
|
1305
|
-
// marker existed but was never actually set on any real project's Blocks.
|
|
1306
|
-
if (typeof options.url === "string") {
|
|
1307
|
-
Object.defineProperty(built, "__waygraphNavUrl", {
|
|
1308
|
-
value: options.url,
|
|
1309
|
-
enumerable: false,
|
|
1310
|
-
configurable: false,
|
|
1311
|
-
});
|
|
1312
|
-
}
|
|
1313
|
-
if (options.url !== undefined || options.click !== undefined) {
|
|
1314
|
-
Object.defineProperty(built, "__waygraphPageDeepLink", {
|
|
1315
|
-
value: true,
|
|
1316
|
-
enumerable: false,
|
|
1317
|
-
configurable: false,
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
if (options.click !== undefined) {
|
|
1321
|
-
Object.defineProperty(built, "__waygraphNavClick", {
|
|
1322
|
-
value: options.click,
|
|
1323
|
-
enumerable: false,
|
|
1324
|
-
configurable: false,
|
|
1325
|
-
});
|
|
1326
|
-
}
|
|
1327
|
-
return built;
|
|
1328
|
-
}
|
|
1329
|
-
/**
|
|
1330
|
-
* {@link defineBlock} constrained for effect-style instance menus: `requires` +
|
|
1331
|
-
* `instanceOptions` are mandatory. Prefer this when the Block adds / removes /
|
|
1332
|
-
* toggles a live instance rather than navigating. Runtime result is an ordinary
|
|
1333
|
-
* Block - the helper is TypeScript salt only.
|
|
1334
|
-
*/
|
|
1335
|
-
export function defineEffectBlock(base) {
|
|
1336
|
-
const built = defineBlock(base);
|
|
1337
|
-
Object.defineProperty(built, "__waygraphMemKind", {
|
|
1338
|
-
value: "effect",
|
|
1339
|
-
enumerable: false,
|
|
1340
|
-
configurable: false,
|
|
1341
|
-
});
|
|
1342
|
-
Object.defineProperty(built, "__waygraphSalt", {
|
|
1343
|
-
value: "effect",
|
|
1344
|
-
enumerable: false,
|
|
1345
|
-
configurable: false,
|
|
1346
|
-
});
|
|
1347
|
-
return built;
|
|
1348
|
-
}
|
|
1349
|
-
/** @deprecated Prefer {@link defineEffectBlock} - identical helper. */
|
|
1350
|
-
export const defineMemEffectBlock = defineEffectBlock;
|
|
1351
|
-
/**
|
|
1352
|
-
* {@link defineNavBlock} constrained to mem-picked navigation: requires +
|
|
1353
|
-
* instanceOptions mandatory, and `click`/`url` should depend on that mem
|
|
1354
|
-
* (typically `click: (mem) => …`). Prefer this when the Block opens one of
|
|
1355
|
-
* several live rows/items rather than a fixed nav target. Still a Block.
|
|
1356
|
-
*/
|
|
1357
|
-
export function defineMemNavBlock(options) {
|
|
1358
|
-
const built = defineNavBlock(options);
|
|
1359
|
-
Object.defineProperty(built, "__waygraphMemKind", {
|
|
1360
|
-
value: "nav",
|
|
1361
|
-
enumerable: false,
|
|
1362
|
-
configurable: false,
|
|
1363
|
-
});
|
|
1364
|
-
return built;
|
|
1365
|
-
}
|
|
1366
|
-
/**
|
|
1367
|
-
* "Where am I" - reverse-matches a live `page` against every NavBlock /
|
|
1368
|
-
* PageBlock in `library`, in order, returning the first one whose own `verify`
|
|
1369
|
-
* Traits all pass (its `checkpoint`), or `null` if none match. Deliberately
|
|
1370
|
-
* Nav/Page hubs only: each has exactly one fixed `checkpoint` and a `verify`
|
|
1371
|
-
* list whose whole job is already "confirm arrival here," so it's a reliable
|
|
1372
|
-
* fingerprint - a regular `defineBlock` can branch to different `Out` tags
|
|
1373
|
-
* depending on runtime evidence, so its `verify` alone doesn't identify a
|
|
1374
|
-
* single state the way a hub's does.
|
|
1375
|
-
*
|
|
1376
|
-
* The primitive `waygraph auto`'s discovered graph is *for* - the graph says
|
|
1377
|
-
* what pages exist and how to reach them; `locate()` is how an autonomous
|
|
1378
|
-
* run figures out which one it's actually looking at right now, without a
|
|
1379
|
-
* human telling it.
|
|
1380
|
-
*
|
|
1381
|
-
* More than one NavBlock matching is a real possibility (two Checkpoints
|
|
1382
|
-
* whose `verify` Traits are both satisfied by the same page) - `locate()`
|
|
1383
|
-
* returns the first match rather than treating that as an error; resolving
|
|
1384
|
-
* genuine ambiguity is a follow-up, not solved here.
|
|
1385
|
-
* @example const here = await locate(page, [NavLoginBlock, NavDashboardBlock]);
|
|
1386
|
-
*/
|
|
1387
|
-
export async function locate(page, library, options) {
|
|
1388
|
-
const mem = new MemPage();
|
|
1389
|
-
const timeoutMs = options?.timeoutMs;
|
|
1390
|
-
if (timeoutMs !== undefined) {
|
|
1391
|
-
page.setDefaultTimeout(timeoutMs);
|
|
1392
|
-
}
|
|
1393
|
-
try {
|
|
1394
|
-
for (const block of library) {
|
|
1395
|
-
try {
|
|
1396
|
-
// NavBlock's own resolve() ignores whatever it's called with - always
|
|
1397
|
-
// exactly `checkpoint(options.checkpoint)` - so this is safe to call
|
|
1398
|
-
// before verify, unlike a regular Block where resolve depends on
|
|
1399
|
-
// observed evidence.
|
|
1400
|
-
const resolved = await block.instruction.resolve(undefined);
|
|
1401
|
-
const verify = block.instruction.verify;
|
|
1402
|
-
const traits = typeof verify === "function" ? verify(resolved) : (verify ?? []);
|
|
1403
|
-
let allPass = true;
|
|
1404
|
-
for (const trait of traits) {
|
|
1405
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1406
|
-
if (!(await trait.check(page, mem))) {
|
|
1407
|
-
allPass = false;
|
|
1408
|
-
break;
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
if (allPass)
|
|
1412
|
-
return resolved.__state;
|
|
1413
|
-
}
|
|
1414
|
-
catch {
|
|
1415
|
-
// This NavBlock's own verify threw (e.g. a waitFor timing out) -
|
|
1416
|
-
// treat that the same as "didn't match," try the next candidate.
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
return null;
|
|
1420
|
-
}
|
|
1421
|
-
finally {
|
|
1422
|
-
if (timeoutMs !== undefined) {
|
|
1423
|
-
page.setDefaultTimeout(30_000);
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
/**
|
|
1428
|
-
* Returns a new Block that runs exactly like `block` - same act/observe/resolve,
|
|
1429
|
-
* same routing - but with `verify` replaced. For giving one flow stricter (or
|
|
1430
|
-
* looser, or just different) confirmation on a Block it didn't author, without
|
|
1431
|
-
* that Block needing to have been written as a parameterized factory in
|
|
1432
|
-
* anticipation. Same move as `branch()`: decorate an existing Block, don't ask
|
|
1433
|
-
* every Block to pre-build its own override mechanism.
|
|
1434
|
-
*
|
|
1435
|
-
* Wrap the one Block you want to change, in place, wherever it already sits in
|
|
1436
|
-
* a `defineFlow([...])` array - every other Block stays untouched:
|
|
1437
|
-
* `defineFlow([start, LoginBlock, withVerify(AddToCartBlock, [...]), end])`.
|
|
1438
|
-
* There is no separate positional array to keep in sync and no `null`
|
|
1439
|
-
* placeholder for "skip this one" - `defineFlow` has no per-slot verify
|
|
1440
|
-
* argument at all, only whatever `verify` each Block's own `instruction` (or
|
|
1441
|
-
* a `withVerify(...)` wrapping it) carries with it.
|
|
1442
|
-
*/
|
|
1443
|
-
export function withVerify(block, verify) {
|
|
1444
|
-
const base = stripMethods(block);
|
|
1445
|
-
const next = defineBlock({ ...base, instruction: { ...base.instruction, verify } });
|
|
1446
|
-
copyWaygraphRuntime(block, next);
|
|
1447
|
-
return next;
|
|
1448
|
-
}
|
|
1449
|
-
function isHighlightStubPhase(v) {
|
|
1450
|
-
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1451
|
-
}
|
|
1452
|
-
/** Run a prior stub (fn or slot map) into an open ctx so decorate can chain. */
|
|
1453
|
-
async function applyStubIntoCtx(raw, ctx) {
|
|
1454
|
-
if (raw === undefined)
|
|
1455
|
-
return;
|
|
1456
|
-
if (typeof raw === "function") {
|
|
1457
|
-
const ret = await raw(ctx);
|
|
1458
|
-
if (isHighlightStubPhase(ret)) {
|
|
1459
|
-
for (const [id, stub] of Object.entries(ret)) {
|
|
1460
|
-
if (stub)
|
|
1461
|
-
ctx.ring(id, stub);
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
if (isHighlightStubPhase(raw)) {
|
|
1467
|
-
for (const [id, stub] of Object.entries(raw)) {
|
|
1468
|
-
if (stub)
|
|
1469
|
-
ctx.ring(id, stub);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
function withStubPhase(block, phase, stub) {
|
|
1474
|
-
const base = stripMethods(block);
|
|
1475
|
-
const instr = base.instruction;
|
|
1476
|
-
const prev = instr[phase];
|
|
1477
|
-
const chained = async (ctx) => {
|
|
1478
|
-
await applyStubIntoCtx(prev, ctx);
|
|
1479
|
-
await applyStubIntoCtx(stub, ctx);
|
|
1480
|
-
};
|
|
1481
|
-
const next = defineBlock({
|
|
1482
|
-
...base,
|
|
1483
|
-
instruction: { ...base.instruction, [phase]: chained },
|
|
1484
|
-
});
|
|
1485
|
-
copyWaygraphRuntime(block, next);
|
|
1486
|
-
return next;
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Attach / chain a stubBefore lifecycle on a Block. Prefer
|
|
1490
|
-
* `(ctx) => { ctx.ring(...); const x = ctx.mem?.get(Key); }`.
|
|
1491
|
-
* @example FillUsernameBlock.stubBefore((ctx) => { ctx.ring("username", { ... }) })
|
|
1492
|
-
*/
|
|
1493
|
-
export function withStubBefore(block, stub) {
|
|
1494
|
-
return withStubPhase(block, "stubBefore", stub);
|
|
1495
|
-
}
|
|
1496
|
-
/** @see {@link withStubBefore} */
|
|
1497
|
-
export function withStubAfter(block, stub) {
|
|
1498
|
-
return withStubPhase(block, "stubAfter", stub);
|
|
1499
|
-
}
|
|
1500
|
-
/** @see {@link withStubBefore} */
|
|
1501
|
-
export function withStubOnError(block, stub) {
|
|
1502
|
-
return withStubPhase(block, "stubOnError", stub);
|
|
1503
|
-
}
|
|
1504
|
-
/**
|
|
1505
|
-
* Replaces one Trait inside `block`'s verify list, leaving every other check
|
|
1506
|
-
* untouched - the v1/v2/legacy-variant case, where one check needs a different
|
|
1507
|
-
* selector or URL pattern but the rest of the Block is identical. Addressed by
|
|
1508
|
-
* the Trait's own `name` (recommended - inserting or reordering other checks
|
|
1509
|
-
* can't silently retarget it) or, if you genuinely know the position and don't
|
|
1510
|
-
* need that safety, a numeric index. Either way a bad address fails loud
|
|
1511
|
-
* immediately instead of quietly doing nothing. `newCheck` can be a bare
|
|
1512
|
-
* `check` function (keeps the existing name) or a full `Trait` (to rename too).
|
|
1513
|
-
* Only works on a flat `verify` array - a function-form `verify` (from
|
|
1514
|
-
* `verify: (out) => Trait[]`) has no fixed list to address into, so this
|
|
1515
|
-
* throws rather than guess; use `withVerify` to replace it wholesale instead.
|
|
1516
|
-
*/
|
|
1517
|
-
export function modVerify(block, nameOrIndex, newCheck) {
|
|
1518
|
-
const verify = block.instruction.verify;
|
|
1519
|
-
if (typeof verify === "function") {
|
|
1520
|
-
throw new Error(`modVerify: "${block.name}" has a function-form verify, which has no fixed list to address into - use withVerify to replace it wholesale instead`);
|
|
1521
|
-
}
|
|
1522
|
-
const traits = verify ?? [];
|
|
1523
|
-
const index = typeof nameOrIndex === "number" ? nameOrIndex : traits.findIndex((t) => t.name === nameOrIndex);
|
|
1524
|
-
const existing = traits[index];
|
|
1525
|
-
if (!existing) {
|
|
1526
|
-
throw new Error(typeof nameOrIndex === "number"
|
|
1527
|
-
? `modVerify: "${block.name}" has no verify trait at index ${nameOrIndex} (has ${traits.length})`
|
|
1528
|
-
: `modVerify: "${block.name}" has no verify trait named "${nameOrIndex}" (has: ${traits.map((t) => t.name).join(", ") || "none"})`);
|
|
1529
|
-
}
|
|
1530
|
-
const replacement = typeof newCheck === "function"
|
|
1531
|
-
? { name: typeof nameOrIndex === "string" ? nameOrIndex : existing.name, check: newCheck }
|
|
1532
|
-
: newCheck;
|
|
1533
|
-
const updated = [...traits];
|
|
1534
|
-
updated[index] = replacement;
|
|
1535
|
-
return withVerify(block, updated);
|
|
1536
|
-
}
|
|
1537
|
-
/**
|
|
1538
|
-
* `modVerify`, but for several named checks in one call - a v2 variant that
|
|
1539
|
-
* differs in five ways from v1 shouldn't need five nested `modVerify(modVerify(
|
|
1540
|
-
* modVerify(...)))` calls to track by eye. `patches` keys are plain strings (a
|
|
1541
|
-
* Trait's `name`, not a `MemKey` instance), so an ordinary object literal is
|
|
1542
|
-
* fine here - unlike `MemPage`, there's no identity-collision reason to need
|
|
1543
|
-
* `[name, check]` pairs. Applies each patch in turn via `modVerify`, so the
|
|
1544
|
-
* same fail-loud-on-unknown-name behavior holds for every entry.
|
|
1545
|
-
*/
|
|
1546
|
-
export function modVerifyAll(block, patches) {
|
|
1547
|
-
const seed = defineBlock(stripMethods(block));
|
|
1548
|
-
copyWaygraphRuntime(block, seed);
|
|
1549
|
-
return Object.entries(patches).reduce((current, [name, newCheck]) => modVerify(current, name, newCheck), seed);
|
|
1550
|
-
}
|
|
1
|
+
// Barrel: the engine lives in src/engine/ (see src/ARCHITECTURE.md). Kept so existing imports of "./engine.js" don't change.
|
|
2
|
+
export * from "./engine/config.js";
|
|
3
|
+
export * from "./engine/run-graph.js";
|
|
4
|
+
export * from "./engine/flow.js";
|
|
5
|
+
export * from "./engine/core.js";
|
|
6
|
+
export * from "./engine/compose.js";
|
|
7
|
+
export * from "./engine/engine-class.js";
|
|
8
|
+
export * from "./engine/blocks/nav.js";
|
|
9
|
+
export * from "./engine/blocks/method.js";
|
|
10
|
+
export * from "./engine/blocks/page.js";
|
|
11
|
+
export * from "./engine/blocks/effect.js";
|
|
12
|
+
export * from "./engine/locate.js";
|