waygraph 0.15.21 → 0.15.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { findBlock, findFlow, parseChainSpec } from "./discovery.js";
4
+ import { seedMemForBlock, seedMemForFlow } from "./seed-mem.js";
5
+ import { join } from "node:path";
6
+ import { mkdirSync } from "node:fs";
7
+ import { isExpectedChainFailure, parseVideoViewport, runChainedFlows, runJsonReportMode } from "./chain-run.js";
8
+ import { runStepMode } from "./step-mode.js";
9
+ import { connect } from "../types.js";
10
+ import { MemPage } from "../mem-page.js";
11
+ import { Engine, start, end, chainFlow, isFastForwardBlock } from "../engine.js";
12
+
13
+ async function main() {
14
+ const projectDir = process.argv[2];
15
+ const spec = process.argv[3];
16
+ const mem = new MemPage();
17
+ // Computed early - JSON-report mode's stdout is meant to be ONE parseable
18
+ // JSON object for whatever's reading it afterward (a script, an agent),
19
+ // not a human's console. Every informational console.log below is
20
+ // skipped for it; only the final JSON report (or a thrown Error, for a
21
+ // truly unexpected failure outside runJsonReportMode's own try/catch)
22
+ // reaches stdout.
23
+ const jsonReport = process.env.WAYGRAPH_JSON === "1";
24
+ const ffExpand =
25
+ process.env.WAYGRAPH_FF_EXPAND === "1" || process.env.WAYGRAPH_FF_DISABLED === "1";
26
+ const ffDisabled = process.env.WAYGRAPH_FF_DISABLED === "1";
27
+ /** Expand or keep fastForwardComposeBlock units for demo/run step lists. */
28
+ const flattenBlockInfos = (blockInfos) => {
29
+ const out = [];
30
+ for (const bi of blockInfos) {
31
+ const b = bi.block;
32
+ if (ffExpand && isFastForwardBlock(b)) {
33
+ for (const step of b.steps()) {
34
+ out.push({
35
+ name: step.name,
36
+ block: step.block,
37
+ ...(step.routes ? { routes: step.routes } : {}),
38
+ ...(bi.resetSessionBefore ? { resetSessionBefore: true } : {}),
39
+ // Dispute / --ff-disabled: keep blitz pacing on former FF inners so
40
+ // wall-clock stays comparable to opaque FF (same real acts).
41
+ wasFastForward: true,
42
+ ffSource: b.name,
43
+ });
44
+ }
45
+ } else {
46
+ out.push(bi);
47
+ }
48
+ }
49
+ return out;
50
+ };
51
+ let resolved = [];
52
+ let chainFlows = null;
53
+ // A bare identifier (no "(", no "then") might name an existing Flow
54
+ // that's already wired up (e.g. loginFlow) - try that FIRST so pointing
55
+ // at real, already-built flows needs no chain-spec typing at all. Falls
56
+ // through to ordinary block-chain parsing if nothing matches.
57
+ const bareRef = /^[A-Za-z_$][\w]*$/.test(spec.trim()) ? spec.trim() : null;
58
+ if (bareRef) {
59
+ const flow = await findFlow(projectDir, bareRef);
60
+ if (flow && typeof flow.blocks === "function") {
61
+ const blockInfos = flattenBlockInfos(flow.blocks());
62
+ // Same --data / WAYGRAPH_DATA seeding as the multi-segment path.
63
+ // Without this, `waygraph run shop.flow.ts --data '{...}'` (and bare
64
+ // export names) hit preflight with an empty MemPage.
65
+ const stubActive = flow.memStub === true || process.env.WAYGRAPH_MEM_STUB === "1";
66
+ const seedMem = () => seedMemForFlow(mem, blockInfos, undefined, bareRef, stubActive);
67
+ resolved = blockInfos.map((bi, idx) => ({
68
+ block: bi.block,
69
+ exportName: bi.name,
70
+ seedMem: idx === 0 ? seedMem : undefined,
71
+ highlightFixtures: flow.highlightFixtures,
72
+ demoPace: flow.demoPace,
73
+ highlightStyle: flow.highlightStyle,
74
+ expectedFailureReason:
75
+ flow.expectedFailureReason && idx === blockInfos.length - 1
76
+ ? flow.expectedFailureReason
77
+ : undefined,
78
+ ...(bi.wasFastForward ? { wasFastForward: true, ffSource: bi.ffSource } : {}),
79
+ }));
80
+ chainFlows = [flow];
81
+ if (!jsonReport) {
82
+ console.log(
83
+ 'waygraph: running existing flow "' + bareRef + '" - ' +
84
+ resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
85
+ (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
86
+ );
87
+ if (ffDisabled) {
88
+ console.error(
89
+ "waygraph demo: --ff-disabled - FFCompose expanded to inners (dispute / step locus); blitz pacing kept on former FF steps",
90
+ );
91
+ }
92
+ }
93
+ }
94
+ }
95
+ if (resolved.length === 0) {
96
+ const segments = parseChainSpec(spec);
97
+ if (segments.length === 0) {
98
+ throw new Error("waygraph chain: empty spec - give at least one block name");
99
+ }
100
+ // Each "then"-separated segment can ALSO name an existing Flow (a
101
+ // Flow's own requires, unioned across its Blocks, take the same
102
+ // JSON-payload shape a single Block's requires would), not just a
103
+ // Block. "loginFlow then dashboardFlow" chains two whole Flows one
104
+ // after another - a bare Block segment gets wrapped as its own
105
+ // trivial one-Block Flow so the whole spec reduces to one thing:
106
+ // chainFlow(...) does the actual sequencing (session-reset boundaries
107
+ // included) that used to be hand-rolled here - this is a thin wrapper
108
+ // over the real engine primitive, not a second copy of its logic.
109
+ const wrapEngine = new Engine();
110
+ const flows = [];
111
+ // Parallel to flows - only a REAL named Flow (found via findFlow)
112
+ // counts as an "episode" a human would want labeled; a bare Block
113
+ // segment gets wrapped as its own trivial one-Block Flow so it can
114
+ // still flow through chainFlow's sequencing uniformly, but it's not
115
+ // an authored scenario and gets no episode number - keeps the plain
116
+ // "quick ad hoc block chain" case free of meaningless "Episode 1"/
117
+ // "Episode 2" labels on things that were never episodes to begin with.
118
+ const flowMeta = [];
119
+ let episodeCounter = 0;
120
+ // seedMem is a closure, NOT called here - seeding every segment's own
121
+ // json payload eagerly, all upfront during resolution, was a real bug:
122
+ // mem is one shared object, so when two segments require the SAME key
123
+ // (e.g. two segments both requiring LoginCreds, each with a DIFFERENT
124
+ // payload - "login as standard_user then login as locked_out_user"),
125
+ // the LAST segment resolved silently overwrote the first's value
126
+ // before either segment had even started running - so the FIRST
127
+ // segment's own blocks would run against the SECOND segment's
128
+ // credentials. Deferred to execution time instead (runStepMode calls
129
+ // this exactly once, right as each segment's first block is reached -
130
+ // see resolved[i].seedMem below), same moment session-reset already
131
+ // happens for the same reason.
132
+ for (const seg of segments) {
133
+ const flow = await findFlow(projectDir, seg.ref);
134
+ if (flow && typeof flow.blocks === "function") {
135
+ flows.push(flow);
136
+ episodeCounter += 1;
137
+ const stubActive = flow.memStub === true || process.env.WAYGRAPH_MEM_STUB === "1";
138
+ flowMeta.push({
139
+ episodeNumber: episodeCounter,
140
+ episodeTitle: flow.title || seg.ref,
141
+ expectedFailureReason: flow.expectedFailureReason,
142
+ highlightFixtures: flow.highlightFixtures,
143
+ demoPace: flow.demoPace,
144
+ highlightStyle: flow.highlightStyle,
145
+ seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref, stubActive),
146
+ });
147
+ } else {
148
+ const r = await findBlock(projectDir, seg.ref);
149
+ flows.push(wrapEngine.defineFlow([start, r.block, end]));
150
+ // A bare Block segment (no Flow, no withMemStub to opt in) only gets
151
+ // memStub via the global --mem-stub/WAYGRAPH_MEM_STUB override.
152
+ const stubActive = process.env.WAYGRAPH_MEM_STUB === "1";
153
+ flowMeta.push({
154
+ episodeNumber: undefined,
155
+ episodeTitle: undefined,
156
+ highlightFixtures: undefined,
157
+ demoPace: undefined,
158
+ highlightStyle: undefined,
159
+ seedMem: () => seedMemForBlock(mem, r, seg.json, stubActive),
160
+ });
161
+ }
162
+ }
163
+ const combinedBlocks = flattenBlockInfos(chainFlow(...flows).blocks());
164
+ let fi = 0;
165
+ // Episode lengths must follow the same flatten so remainingInFlow stays aligned.
166
+ const flowLengths = flows.map((f) => flattenBlockInfos(f.blocks()).length);
167
+ let remainingInFlow = flowLengths[0];
168
+ let isFirstOfSegment = true;
169
+ resolved = combinedBlocks.map((bi) => {
170
+ while (remainingInFlow === 0) {
171
+ fi += 1;
172
+ remainingInFlow = flowLengths[fi];
173
+ isFirstOfSegment = true;
174
+ }
175
+ const meta = flowMeta[fi];
176
+ const entry = {
177
+ block: bi.block,
178
+ exportName: bi.name,
179
+ resetSession: bi.resetSessionBefore === true,
180
+ episodeNumber: meta.episodeNumber,
181
+ episodeTitle: meta.episodeTitle,
182
+ highlightFixtures: meta.highlightFixtures,
183
+ demoPace: meta.demoPace,
184
+ highlightStyle: meta.highlightStyle,
185
+ expectedFailureReason:
186
+ meta.expectedFailureReason && remainingInFlow === 1 ? meta.expectedFailureReason : undefined,
187
+ seedMem: isFirstOfSegment ? meta.seedMem : undefined,
188
+ ...(bi.wasFastForward ? { wasFastForward: true, ffSource: bi.ffSource } : {}),
189
+ };
190
+ remainingInFlow -= 1;
191
+ isFirstOfSegment = false;
192
+ return entry;
193
+ });
194
+ chainFlows = flows;
195
+ if (!jsonReport) {
196
+ console.log(
197
+ "waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
198
+ " (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
199
+ );
200
+ }
201
+ }
202
+ const step = process.env.WAYGRAPH_STEP === "1";
203
+ // Stepping through headless defeats the point for a MANUAL demo - a
204
+ // human can't watch it. But an explicit WAYGRAPH_HEADED=0 always wins
205
+ // over that default: --auto-play-video sets STEP=1 (it still needs the
206
+ // overlay/narration machinery) AND HEADED=0 (unattended + recorded,
207
+ // nobody has to watch a live window for it to be right).
208
+ const headed =
209
+ process.env.WAYGRAPH_HEADED === "0" ? false : process.env.WAYGRAPH_HEADED === "1" || step;
210
+ // Step mode: blitz Playwright slowMo when any opaque FF **or** former-FF
211
+ // inners (--ff-disabled) are in the resolved list. Explicit WAYGRAPH_SLOWMO wins.
212
+ const hasFfBlitz = resolved.some(
213
+ (r) =>
214
+ r &&
215
+ ((r.block && r.block.fastForward === true) || r.wasFastForward === true),
216
+ );
217
+ const slowMo = process.env.WAYGRAPH_SLOWMO !== undefined
218
+ ? Number(process.env.WAYGRAPH_SLOWMO)
219
+ : step
220
+ ? hasFfBlitz
221
+ ? 0
222
+ : 350
223
+ : undefined;
224
+ if (step && hasFfBlitz && process.env.WAYGRAPH_SLOWMO === undefined && !jsonReport) {
225
+ console.error(
226
+ ffDisabled
227
+ ? "waygraph demo: --ff-disabled (expanded) -> Playwright slowMo=0 on former FF steps (blitz; same acts as opaque FF)"
228
+ : "waygraph demo: opaque FFCompose in flow -> Playwright slowMo=0 (blitz); set WAYGRAPH_SLOWMO to override",
229
+ );
230
+ }
231
+ const baseURL = process.env.WAYGRAPH_BASE_URL;
232
+ const title = process.env.WAYGRAPH_TITLE;
233
+ // Comma-separated Block names (their real .name, e.g. "login") that
234
+ // should blow past the overlay's own added dwell (ring pop, cursor
235
+ // travel, typing delay, and that block's own gates) - "I want the login
236
+ // block to be faster than the rest of the demo," Dan's own phrase.
237
+ // FFCompose blocks get the same skipTheater automatically (no need to
238
+ // list them here). Distinct from --fast (gates only).
239
+ const fastBlockNames = new Set(
240
+ (process.env.WAYGRAPH_FAST_BLOCKS || "")
241
+ .split(",")
242
+ .map((s) => s.trim())
243
+ .filter(Boolean),
244
+ );
245
+ // Off by default - a same-user chain (login -> dashboard -> ...) needs
246
+ // to STAY authenticated across its own blocks. Only a chain deliberately
247
+ // re-visiting an auth entry point (e.g. "login" appearing twice, to
248
+ // demo the same flow starting fresh each time) needs this.
249
+ const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
250
+ const videoEnv = process.env.WAYGRAPH_VIDEO;
251
+ let videoDir = null;
252
+ if (videoEnv) {
253
+ videoDir = videoEnv === "1" ? join(projectDir, ".waygraph-videos") : videoEnv;
254
+ mkdirSync(videoDir, { recursive: true });
255
+ }
256
+ /** Fixed 16:9 capture for --video (demo QA and headless run). Step+video no longer maximizes. */
257
+ const DEFAULT_VIDEO_VIEWPORT = { width: 1920, height: 1080 };
258
+ const DEFAULT_RUN_VIDEO_VIEWPORT = { width: 1280, height: 720 };
259
+ const videoViewportParsed = parseVideoViewport(process.env.WAYGRAPH_VIDEO_VIEWPORT);
260
+ const fixedVideoViewport = videoDir
261
+ ? (videoViewportParsed ?? (step ? DEFAULT_VIDEO_VIEWPORT : DEFAULT_RUN_VIDEO_VIEWPORT))
262
+ : null;
263
+ const engine = new Engine({ headless: !headed, slowMo });
264
+ let result;
265
+ // Non-interactive execution (no --step) runs the whole chain as one
266
+ // composed flow with no per-block loop of its own to defer seeding
267
+ // into - it can't support two segments needing DIFFERENT values for the
268
+ // SAME mem key (e.g. two segments both requiring LoginCreds, each with
269
+ // its own payload). Fall back to seeding every segment eagerly, upfront -
270
+ // the same limitation "chain" always had; only --step's own runStepMode
271
+ // loop actually needs (and correctly supports, via resolved[i].seedMem)
272
+ // per-segment differentiated values.
273
+ if (!step && !jsonReport) {
274
+ for (const r of resolved) {
275
+ if (r.seedMem) r.seedMem();
276
+ }
277
+ }
278
+ if (step || jsonReport || baseURL || videoDir) {
279
+ const { chromium } = await import("@playwright/test");
280
+ // --start-maximized (step mode only): viewport: null alone only made
281
+ // the PAGE content track the window - the actual browser WINDOW still
282
+ // launched at Chromium's own default size/position, which stayed put
283
+ // where it was for a previous, larger monitor and looked chopped off on
284
+ // a smaller one. Maximizing fills whatever screen it's actually on.
285
+ // Same CHROME_PATH/CHROMIUM_PATH rule as Engine.run(mem): prefer the
286
+ // system/Flatpak Chromium when set, otherwise Playwright's bundled build.
287
+ // Without this, STEP mode always launched a different-looking browser than
288
+ // headed Engine runs / waygraph-demo.mjs (which already resolve Flatpak).
289
+ const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH || undefined;
290
+ // Maximized for interactive step, and for fixed video viewport so the
291
+ // locked record size can actually fit (otherwise .webm gets grey pad).
292
+ const browser = await chromium.launch({
293
+ headless: !headed,
294
+ slowMo,
295
+ args: step || fixedVideoViewport ? ["--start-maximized"] : [],
296
+ ...(executablePath ? { executablePath } : {}),
297
+ });
298
+ const contextOpts = fixedVideoViewport
299
+ ? {
300
+ baseURL,
301
+ viewport: fixedVideoViewport,
302
+ // Lock DPR so --video-viewport 800x450 stays 800x450 in the .webm
303
+ // (HiDPI hosts otherwise scale recordVideo to e.g. 1920x1080).
304
+ deviceScaleFactor: 1,
305
+ hasTouch: true,
306
+ }
307
+ : step
308
+ ? { baseURL, viewport: null, hasTouch: true }
309
+ : { baseURL, viewport: { width: 1280, height: 720 }, hasTouch: true };
310
+ if (videoDir) {
311
+ contextOpts.recordVideo = fixedVideoViewport
312
+ ? { dir: videoDir, size: fixedVideoViewport }
313
+ : { dir: videoDir };
314
+ }
315
+ const context = await browser.newContext(contextOpts);
316
+ let pageForVideo = null;
317
+ try {
318
+ if (step) {
319
+ const page = await context.newPage();
320
+ pageForVideo = page;
321
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
322
+ // Step 1's "before" panel used to sit over a blank about:blank page
323
+ // until the human clicked Run - show the real destination first.
324
+ if (baseURL) {
325
+ await page.goto(baseURL).catch(() => {});
326
+ }
327
+ result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL);
328
+ } else if (jsonReport) {
329
+ const page = await context.newPage();
330
+ pageForVideo = page;
331
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
332
+ if (baseURL) {
333
+ await page.goto(baseURL).catch(() => {});
334
+ }
335
+ const report = await runJsonReportMode(engine, start, end, context, page, mem, resolved, baseURL);
336
+ console.log(JSON.stringify(report, null, 2));
337
+ if (!report.ok) process.exitCode = 1;
338
+ return;
339
+ } else {
340
+ const page = await context.newPage();
341
+ pageForVideo = page;
342
+ if (fixedVideoViewport) page.__wgVideoViewport = fixedVideoViewport;
343
+ if (baseURL) {
344
+ await page.goto(baseURL).catch(() => {});
345
+ }
346
+ if (chainFlows && chainFlows.length > 0) {
347
+ result = await runChainedFlows(chainFlow, chainFlows, context, mem, page);
348
+ } else {
349
+ const blocks = resolved.map((r) => r.block);
350
+ const chained = blocks.reduce((a, b) => connect(a, b));
351
+ const flow = engine.defineFlow([start, chained, end]);
352
+ result = await flow.run(context, mem, { page, closeOnFinish: false });
353
+ }
354
+ }
355
+ } finally {
356
+ await context.close();
357
+ const savedVideo =
358
+ pageForVideo?.video() !== null && pageForVideo?.video() !== undefined
359
+ ? await pageForVideo.video()?.path().catch(() => null)
360
+ : null;
361
+ if (savedVideo) {
362
+ console.log("waygraph: video saved to " + savedVideo);
363
+ }
364
+ await browser.close();
365
+ }
366
+ } else {
367
+ if (chainFlows && chainFlows.length > 0) {
368
+ try {
369
+ result = await chainFlow(...chainFlows).run(mem);
370
+ } catch (err) {
371
+ if (isExpectedChainFailure(chainFlows, err)) {
372
+ result = { __expectedFailure: true, message: err && err.message ? err.message : String(err) };
373
+ } else {
374
+ throw err;
375
+ }
376
+ }
377
+ } else {
378
+ const blocks = resolved.map((r) => r.block);
379
+ const chained = blocks.reduce((a, b) => connect(a, b));
380
+ const flow = engine.defineFlow([start, chained, end]);
381
+ const recordVideo = videoDir ? { dir: videoDir } : undefined;
382
+ result = await flow.run(mem, recordVideo ? { recordVideo } : undefined);
383
+ }
384
+ }
385
+ if (result && result.__expectedFailure) {
386
+ console.log("waygraph: chain finished (expected failure on last episode) -- " + result.message);
387
+ } else {
388
+ console.log("waygraph: chain finished -- " + JSON.stringify(result));
389
+ }
390
+ }
391
+
392
+ export function startRunner() {
393
+ main().catch((err) => {
394
+ console.error(err && err.stack ? err.stack : String(err));
395
+ process.exitCode = 1;
396
+ });
397
+ }