waygraph 0.3.2 → 0.5.0
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/README.md +19 -0
- package/dist/cli.js +721 -79
- package/dist/engine.d.ts +138 -3
- package/dist/engine.js +312 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/types.d.ts +23 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -294,27 +294,26 @@ function parseChainSpec(spec) {
|
|
|
294
294
|
});
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
function
|
|
298
|
-
const requires = resolved.block.requires ?? [];
|
|
297
|
+
function seedMemFromRequires(mem, requires, json, label) {
|
|
299
298
|
if (requires.length === 0) {
|
|
300
299
|
if (json !== undefined) {
|
|
301
300
|
throw new Error(
|
|
302
|
-
"waygraph chain: \\"" +
|
|
301
|
+
"waygraph chain: \\"" + label + "\\" takes no input (empty requires) but got a payload: " + json,
|
|
303
302
|
);
|
|
304
303
|
}
|
|
305
304
|
return;
|
|
306
305
|
}
|
|
307
306
|
if (json === undefined) {
|
|
308
307
|
throw new Error(
|
|
309
|
-
"waygraph chain: \\"" +
|
|
310
|
-
" - give a JSON payload, e.g. " +
|
|
308
|
+
"waygraph chain: \\"" + label + "\\" requires " + requires.map((k) => k.name).join(", ") +
|
|
309
|
+
" - give a JSON payload, e.g. " + label + "({...})",
|
|
311
310
|
);
|
|
312
311
|
}
|
|
313
312
|
let parsed;
|
|
314
313
|
try {
|
|
315
314
|
parsed = JSON.parse(json);
|
|
316
315
|
} catch (err) {
|
|
317
|
-
throw new Error("waygraph chain: \\"" +
|
|
316
|
+
throw new Error("waygraph chain: \\"" + label + "\\" payload is not valid JSON - " + String(err));
|
|
318
317
|
}
|
|
319
318
|
if (requires.length === 1) {
|
|
320
319
|
mem.set(requires[0], parsed);
|
|
@@ -322,18 +321,39 @@ function seedMemForBlock(mem, resolved, json) {
|
|
|
322
321
|
}
|
|
323
322
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
324
323
|
throw new Error(
|
|
325
|
-
"waygraph chain: \\"" +
|
|
324
|
+
"waygraph chain: \\"" + label + "\\" requires " + requires.length + " keys (" +
|
|
326
325
|
requires.map((k) => k.name).join(", ") + ") - payload must be an object keyed by each key's name",
|
|
327
326
|
);
|
|
328
327
|
}
|
|
329
328
|
for (const k of requires) {
|
|
330
329
|
if (!(k.name in parsed)) {
|
|
331
|
-
throw new Error("waygraph chain: \\"" +
|
|
330
|
+
throw new Error("waygraph chain: \\"" + label + "\\" payload is missing required key \\"" + k.name + "\\"");
|
|
332
331
|
}
|
|
333
332
|
mem.set(k, parsed[k.name]);
|
|
334
333
|
}
|
|
335
334
|
}
|
|
336
335
|
|
|
336
|
+
function seedMemForBlock(mem, resolved, json) {
|
|
337
|
+
seedMemFromRequires(mem, resolved.block.requires ?? [], json, resolved.exportName);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Same idea as seedMemForBlock, but for a whole Flow segment
|
|
342
|
+
* ("loginFlow({...}) then ...") - a Flow has no single .requires of its
|
|
343
|
+
* own, so this unions every constituent Block's requires (deduped by key
|
|
344
|
+
* name) and seeds them all from one JSON payload, same keyed-by-name shape
|
|
345
|
+
* as a multi-key Block payload already uses.
|
|
346
|
+
*/
|
|
347
|
+
function seedMemForFlow(mem, flowBlocks, json, label) {
|
|
348
|
+
const seen = new Map();
|
|
349
|
+
for (const bi of flowBlocks) {
|
|
350
|
+
for (const k of bi.block.requires ?? []) {
|
|
351
|
+
if (!seen.has(k.name)) seen.set(k.name, k);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
seedMemFromRequires(mem, Array.from(seen.values()), json, label);
|
|
355
|
+
}
|
|
356
|
+
|
|
337
357
|
// ---------------------------------------------------------------------------
|
|
338
358
|
// WAYGRAPH_STEP=1 - human-verification overlay: one Block at a time, a
|
|
339
359
|
// browser-injected panel showing/editing that Block's MemKeys, a "Run this
|
|
@@ -347,18 +367,56 @@ const RING_CSS =
|
|
|
347
367
|
"#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
|
|
348
368
|
"border:2.5px solid #7C3AED;border-radius:10px;" +
|
|
349
369
|
"box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
|
|
350
|
-
|
|
370
|
+
// A real element, not a ::after pseudo-element - a pseudo-element's
|
|
371
|
+
// position is CSS-relative to the ring's own box (left:0 always meant
|
|
372
|
+
// "the ring's own left edge"), so it had no way to be clamped back onto
|
|
373
|
+
// screen when that box sat near a viewport edge - the label's text just
|
|
374
|
+
// ran off, invisibly, with no overflow guard at all. A real sibling can
|
|
375
|
+
// be measured (its actual rendered width) and repositioned in JS -
|
|
376
|
+
// pushed back onto screen, same width, never shrunk. See
|
|
377
|
+
// window.__wgPositionRing in installOverlay.
|
|
378
|
+
"#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
|
|
351
379
|
"white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
|
|
352
|
-
"font:600 12px/1.2 system-ui,sans-serif;}" +
|
|
380
|
+
"font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease;}" +
|
|
381
|
+
// Mouse cursor icon that travels to a target before it's acted on, plus a
|
|
382
|
+
// quick expanding ripple at the moment of a click - same idea as
|
|
383
|
+
// help-center-clip-engine's #clip-cursor/#clip-ring (video-pipeline). The
|
|
384
|
+
// shape itself is an inline SVG set as innerHTML in installOverlay below
|
|
385
|
+
// (dark fill + white stroke, same as the clip-engine's own cursor) - a
|
|
386
|
+
// plain solid-white CSS clip-path (the first attempt here) had no outline
|
|
387
|
+
// at all and all but disappeared against this app's light background.
|
|
388
|
+
// Travel duration is JS-driven per call via --wg-cursor-ms, same reason
|
|
389
|
+
// the clip engine's own comment gives: a hardcoded CSS duration would
|
|
390
|
+
// make the speed param a no-op.
|
|
391
|
+
"#wg-cursor{position:fixed;z-index:2147483647;width:24px;height:24px;pointer-events:none;" +
|
|
392
|
+
"left:0;top:0;opacity:0;margin:0;" +
|
|
393
|
+
"transition:transform var(--wg-cursor-ms,600ms) cubic-bezier(.22,1,.36,1),opacity .2s ease;" +
|
|
394
|
+
"filter:drop-shadow(0 2px 4px rgba(12,12,26,.4));}" +
|
|
395
|
+
"#wg-click-pulse{position:fixed;z-index:2147483647;width:14px;height:14px;" +
|
|
396
|
+
"margin-left:-7px;margin-top:-7px;border-radius:50%;pointer-events:none;opacity:0;" +
|
|
397
|
+
"border:2px solid #7C3AED;background:rgba(124,58,237,.25);}" +
|
|
398
|
+
"#wg-click-pulse.wg-pulse{animation:wg-pulse .5s ease-out;}" +
|
|
399
|
+
"@keyframes wg-pulse{0%{opacity:.9;transform:scale(.4);}100%{opacity:0;transform:scale(2.4);}}" +
|
|
353
400
|
"#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
|
|
354
401
|
"max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
|
|
355
402
|
"background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
|
|
356
403
|
"padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
|
|
357
|
-
"opacity:0;transition:opacity .
|
|
404
|
+
"opacity:0;transition:opacity .06s ease;}" +
|
|
358
405
|
"#wg-panel.wg-in{opacity:1;}" +
|
|
359
406
|
"#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
|
|
407
|
+
"#wg-panel .wg-autoplay-row{margin-top:8px;}" +
|
|
408
|
+
"#wg-panel .wg-autoplay-row label{display:inline-flex;align-items:center;gap:6px;" +
|
|
409
|
+
"font:12px system-ui,sans-serif;color:#b8a0e0;cursor:pointer;user-select:none;}" +
|
|
410
|
+
"#wg-panel .wg-autoplay-row input{margin:0;cursor:pointer;}" +
|
|
360
411
|
"#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
|
|
361
412
|
"letter-spacing:.02em;text-transform:uppercase;}" +
|
|
413
|
+
// Sits ABOVE the per-Block "Step i/N" heading - the episode/scenario
|
|
414
|
+
// this Block belongs to, not another block-level label. Only rendered
|
|
415
|
+
// when the chain spec actually named a real Flow (chainFlow tags it via
|
|
416
|
+
// BlockInfo.resetSessionBefore's sibling metadata) - a plain ad hoc
|
|
417
|
+
// block chain shows no episode heading at all.
|
|
418
|
+
"#wg-panel .wg-episode{margin:0 0 6px;font:700 15px system-ui,sans-serif;color:#fff;" +
|
|
419
|
+
"padding-bottom:6px;border-bottom:1px solid rgba(124,58,237,.35);}" +
|
|
362
420
|
"#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
|
|
363
421
|
"#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
|
|
364
422
|
"#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
|
|
@@ -389,7 +447,13 @@ const RING_CSS =
|
|
|
389
447
|
"#wg-panel .wg-toggle{display:flex;gap:4px;margin:0 0 4px;}" +
|
|
390
448
|
"#wg-panel .wg-toggle button{margin:0;padding:3px 10px;font:600 11px system-ui,sans-serif;" +
|
|
391
449
|
"background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
|
|
392
|
-
"#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}"
|
|
450
|
+
"#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}" +
|
|
451
|
+
"#wg-panel.wg-error{border:1.5px solid #e0475c;}" +
|
|
452
|
+
"#wg-panel .wg-error-heading{color:#ff8fa0;}" +
|
|
453
|
+
"#wg-panel .wg-error-msg{font:12px/1.5 monospace;background:#2a0f16;color:#ffc7cf;" +
|
|
454
|
+
"border-radius:8px;padding:10px;margin:0 0 12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;}" +
|
|
455
|
+
"#wg-panel .wg-error-stop{background:#e0475c;}" +
|
|
456
|
+
"#wg-panel .wg-error-stop:hover{background:#c33a4c;}";
|
|
393
457
|
|
|
394
458
|
// Purple dot favicon (matches the overlay's own theme color) - the tab-bar
|
|
395
459
|
// signal that "this Chromium window is a waygraph run," even at a glance
|
|
@@ -408,14 +472,128 @@ async function installOverlay(page, title) {
|
|
|
408
472
|
// navigation that rebuilds the banner keeps the human's last pick).
|
|
409
473
|
const envPos = (process.env.WAYGRAPH_TITLE_POS || "left").toLowerCase();
|
|
410
474
|
const bannerPos = envPos === "center" || envPos === "right" ? envPos : "left";
|
|
475
|
+
const envAutoplay = process.env.WAYGRAPH_AUTOPLAY === "1";
|
|
411
476
|
await page
|
|
412
477
|
.evaluate(
|
|
413
|
-
({ title, favicon, bannerPos }) => {
|
|
478
|
+
({ title, favicon, bannerPos, envAutoplay }) => {
|
|
479
|
+
// Seed the live autoplay toggle from the env default on first ever
|
|
480
|
+
// load only - a real navigation re-runs this, and re-stamping here
|
|
481
|
+
// would silently undo a human's mid-run checkbox click.
|
|
482
|
+
try {
|
|
483
|
+
if (localStorage.getItem("wg-autoplay") === null) {
|
|
484
|
+
localStorage.setItem("wg-autoplay", envAutoplay ? "1" : "0");
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
/* private mode / blocked storage - falls back to manual gating */
|
|
488
|
+
}
|
|
414
489
|
if (!document.getElementById("wg-ring")) {
|
|
415
490
|
const ring = document.createElement("div");
|
|
416
491
|
ring.id = "wg-ring";
|
|
417
492
|
document.documentElement.appendChild(ring);
|
|
418
493
|
}
|
|
494
|
+
if (!document.getElementById("wg-ring-label")) {
|
|
495
|
+
const ringLabel = document.createElement("div");
|
|
496
|
+
ringLabel.id = "wg-ring-label";
|
|
497
|
+
document.documentElement.appendChild(ringLabel);
|
|
498
|
+
}
|
|
499
|
+
if (!document.getElementById("wg-cursor")) {
|
|
500
|
+
const cursor = document.createElement("div");
|
|
501
|
+
cursor.id = "wg-cursor";
|
|
502
|
+
// Dark fill + white stroke, same as help-center-clip-engine's own
|
|
503
|
+
// #clip-cursor - visible against any page background, light or
|
|
504
|
+
// dark, unlike a plain solid-white shape.
|
|
505
|
+
cursor.innerHTML =
|
|
506
|
+
"<svg viewBox='0 0 32 32' width='24' height='24'>" +
|
|
507
|
+
"<path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' stroke-linejoin='round' " +
|
|
508
|
+
"d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
|
|
509
|
+
document.documentElement.appendChild(cursor);
|
|
510
|
+
}
|
|
511
|
+
if (!document.getElementById("wg-click-pulse")) {
|
|
512
|
+
const pulse = document.createElement("div");
|
|
513
|
+
pulse.id = "wg-click-pulse";
|
|
514
|
+
document.documentElement.appendChild(pulse);
|
|
515
|
+
}
|
|
516
|
+
window.__wgMoveCursorTo = (x, y, ms, instant) => {
|
|
517
|
+
const cursor = document.getElementById("wg-cursor");
|
|
518
|
+
if (!cursor) return;
|
|
519
|
+
cursor.style.setProperty("--wg-cursor-ms", (ms || 600) + "ms");
|
|
520
|
+
if (instant) {
|
|
521
|
+
const prev = cursor.style.transition;
|
|
522
|
+
cursor.style.transition = "none";
|
|
523
|
+
cursor.style.transform = "translate(" + x + "px," + y + "px)";
|
|
524
|
+
void cursor.offsetWidth;
|
|
525
|
+
cursor.style.transition = prev || "";
|
|
526
|
+
} else {
|
|
527
|
+
cursor.style.transform = "translate(" + x + "px," + y + "px)";
|
|
528
|
+
}
|
|
529
|
+
cursor.style.opacity = "1";
|
|
530
|
+
};
|
|
531
|
+
window.__wgHideCursor = () => {
|
|
532
|
+
const cursor = document.getElementById("wg-cursor");
|
|
533
|
+
if (cursor) cursor.style.opacity = "0";
|
|
534
|
+
};
|
|
535
|
+
window.__wgClickPulse = (x, y) => {
|
|
536
|
+
const pulse = document.getElementById("wg-click-pulse");
|
|
537
|
+
if (!pulse) return;
|
|
538
|
+
pulse.style.left = x + "px";
|
|
539
|
+
pulse.style.top = y + "px";
|
|
540
|
+
pulse.classList.remove("wg-pulse");
|
|
541
|
+
void pulse.offsetWidth;
|
|
542
|
+
pulse.classList.add("wg-pulse");
|
|
543
|
+
};
|
|
544
|
+
// Clamps the ring AND its label to stay fully on-screen, same
|
|
545
|
+
// width/height always - only the position moves. A target near a
|
|
546
|
+
// viewport edge (real case: "login success" highlight landing top
|
|
547
|
+
// right) used to just run the label text off-screen with no guard
|
|
548
|
+
// at all - same mistake as the banner had before its own
|
|
549
|
+
// left/center/right positions existed, not repeating it here by
|
|
550
|
+
// shrinking anything, only repositioning.
|
|
551
|
+
window.__wgPositionRing = (box, label) => {
|
|
552
|
+
const ring = document.getElementById("wg-ring");
|
|
553
|
+
const ringLabel = document.getElementById("wg-ring-label");
|
|
554
|
+
if (!ring || !ringLabel) return;
|
|
555
|
+
const margin = 6;
|
|
556
|
+
const vw = window.innerWidth;
|
|
557
|
+
const vh = window.innerHeight;
|
|
558
|
+
let left = box.x - 6;
|
|
559
|
+
let top = box.y - 6;
|
|
560
|
+
const width = box.width + 12;
|
|
561
|
+
const height = box.height + 12;
|
|
562
|
+
if (left < margin) left = margin;
|
|
563
|
+
if (top < margin) top = margin;
|
|
564
|
+
if (left + width > vw - margin) left = Math.max(margin, vw - margin - width);
|
|
565
|
+
if (top + height > vh - margin) top = Math.max(margin, vh - margin - height);
|
|
566
|
+
ring.style.left = left + "px";
|
|
567
|
+
ring.style.top = top + "px";
|
|
568
|
+
ring.style.width = width + "px";
|
|
569
|
+
ring.style.height = height + "px";
|
|
570
|
+
ring.style.opacity = "1";
|
|
571
|
+
ringLabel.textContent = label;
|
|
572
|
+
ringLabel.style.opacity = "1";
|
|
573
|
+
// Measure the label's own natural width/height (its real
|
|
574
|
+
// rendered size, unchanged) before deciding where it fits.
|
|
575
|
+
const lw = ringLabel.offsetWidth;
|
|
576
|
+
const lh = ringLabel.offsetHeight;
|
|
577
|
+
let labelLeft = left;
|
|
578
|
+
let labelTop = top + height + 8;
|
|
579
|
+
if (labelTop + lh > vh - margin) labelTop = top - lh - 8; // flip above if it'd overflow the bottom
|
|
580
|
+
if (labelTop < margin) labelTop = margin;
|
|
581
|
+
if (labelLeft + lw > vw - margin) labelLeft = Math.max(margin, vw - margin - lw);
|
|
582
|
+
if (labelLeft < margin) labelLeft = margin;
|
|
583
|
+
ringLabel.style.left = labelLeft + "px";
|
|
584
|
+
ringLabel.style.top = labelTop + "px";
|
|
585
|
+
};
|
|
586
|
+
window.__wgHideRing = () => {
|
|
587
|
+
// While a narrate() call owns the ring (mid multi-step action),
|
|
588
|
+
// the auto-highlight click/fill patches' own end-of-step hide is
|
|
589
|
+
// a no-op - narrate() itself clears it once the WHOLE wrapped
|
|
590
|
+
// action finishes, not just its first sub-step.
|
|
591
|
+
if (window.__wgNarrateOwnsRing) return;
|
|
592
|
+
const ring = document.getElementById("wg-ring");
|
|
593
|
+
const ringLabel = document.getElementById("wg-ring-label");
|
|
594
|
+
if (ring) ring.style.opacity = "0";
|
|
595
|
+
if (ringLabel) ringLabel.style.opacity = "0";
|
|
596
|
+
};
|
|
419
597
|
const POSITIONS = ["left", "center", "right"];
|
|
420
598
|
const applyPos = (el, pos) => {
|
|
421
599
|
el.dataset.pos = pos;
|
|
@@ -466,7 +644,7 @@ async function installOverlay(page, title) {
|
|
|
466
644
|
}
|
|
467
645
|
if (iconLink.href !== favicon) iconLink.href = favicon;
|
|
468
646
|
},
|
|
469
|
-
{ title, favicon: WAYGRAPH_FAVICON, bannerPos },
|
|
647
|
+
{ title, favicon: WAYGRAPH_FAVICON, bannerPos, envAutoplay },
|
|
470
648
|
)
|
|
471
649
|
.catch(() => {});
|
|
472
650
|
}
|
|
@@ -484,8 +662,8 @@ async function renderBeforeStep(page, info) {
|
|
|
484
662
|
window.removeEventListener("scroll", window.__wgRingTrack, true);
|
|
485
663
|
window.__wgRingTrack = null;
|
|
486
664
|
}
|
|
487
|
-
|
|
488
|
-
if (
|
|
665
|
+
if (window.__wgHideRing) window.__wgHideRing();
|
|
666
|
+
if (window.__wgHideCursor) window.__wgHideCursor();
|
|
489
667
|
})
|
|
490
668
|
.catch(() => {});
|
|
491
669
|
await page
|
|
@@ -507,9 +685,13 @@ async function renderBeforeStep(page, info) {
|
|
|
507
685
|
const narrationHtml = info.description
|
|
508
686
|
? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
|
|
509
687
|
: "";
|
|
688
|
+
const episodeHtml = info.episodeNumber
|
|
689
|
+
? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + esc(info.episodeTitle || "") + "</div>"
|
|
690
|
+
: "";
|
|
510
691
|
let html =
|
|
511
692
|
"<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
|
|
512
693
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
694
|
+
episodeHtml +
|
|
513
695
|
"<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
|
|
514
696
|
narrationHtml;
|
|
515
697
|
if (info.keys.length === 0) {
|
|
@@ -521,9 +703,20 @@ async function renderBeforeStep(page, info) {
|
|
|
521
703
|
"<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
|
|
522
704
|
k.value.replace(/</g, "<") + "</textarea></div>";
|
|
523
705
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
706
|
+
let autoNow = false;
|
|
707
|
+
try {
|
|
708
|
+
autoNow = localStorage.getItem("wg-autoplay") === "1";
|
|
709
|
+
} catch {
|
|
710
|
+
/* private mode / blocked storage - defaults to manual */
|
|
711
|
+
}
|
|
712
|
+
html +=
|
|
713
|
+
"<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
|
|
714
|
+
(autoNow ? " checked" : "") +
|
|
715
|
+
"> Auto-advance</label></div>" +
|
|
716
|
+
"<div id=\\"wg-gate-manual\\"" + (autoNow ? " style=\\"display:none\\"" : "") +
|
|
717
|
+
"><button id=\\"wg-run\\">Run this step \\u25B6</button></div>" +
|
|
718
|
+
"<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
|
|
719
|
+
">Auto-advancing...</div>";
|
|
527
720
|
panel.innerHTML = html;
|
|
528
721
|
document.documentElement.appendChild(panel);
|
|
529
722
|
requestAnimationFrame(() => panel.classList.add("wg-in"));
|
|
@@ -537,6 +730,26 @@ async function renderBeforeStep(page, info) {
|
|
|
537
730
|
window.__wgNext(edits);
|
|
538
731
|
});
|
|
539
732
|
}
|
|
733
|
+
// Live toggle - flips localStorage immediately so an in-flight gate()
|
|
734
|
+
// poll (on the Node side) picks it up within its next poll slice,
|
|
735
|
+
// without needing this whole panel to re-render. A manual click
|
|
736
|
+
// still always wins over an active autoplay wait, whichever the
|
|
737
|
+
// checkbox says - this is the "hybrid" control: autoplay is a
|
|
738
|
+
// default, not a lockout.
|
|
739
|
+
const cb = document.getElementById("wg-autoplay-cb");
|
|
740
|
+
if (cb) {
|
|
741
|
+
cb.addEventListener("change", () => {
|
|
742
|
+
try {
|
|
743
|
+
localStorage.setItem("wg-autoplay", cb.checked ? "1" : "0");
|
|
744
|
+
} catch {
|
|
745
|
+
/* private mode / blocked storage - toggle still works this page */
|
|
746
|
+
}
|
|
747
|
+
const manual = document.getElementById("wg-gate-manual");
|
|
748
|
+
const auto = document.getElementById("wg-gate-auto");
|
|
749
|
+
if (manual) manual.style.display = cb.checked ? "none" : "";
|
|
750
|
+
if (auto) auto.style.display = cb.checked ? "" : "none";
|
|
751
|
+
});
|
|
752
|
+
}
|
|
540
753
|
}, info)
|
|
541
754
|
.catch(() => {});
|
|
542
755
|
}
|
|
@@ -577,21 +790,14 @@ async function renderAfterStep(page, info) {
|
|
|
577
790
|
const last = highlights[highlights.length - 1];
|
|
578
791
|
await page
|
|
579
792
|
.evaluate((h) => {
|
|
580
|
-
|
|
581
|
-
if (!ring) return;
|
|
793
|
+
if (!window.__wgPositionRing) return;
|
|
582
794
|
const reposition = () => {
|
|
583
795
|
const el = document.querySelector(h.selector);
|
|
584
796
|
if (!el) {
|
|
585
|
-
|
|
797
|
+
if (window.__wgHideRing) window.__wgHideRing();
|
|
586
798
|
return;
|
|
587
799
|
}
|
|
588
|
-
|
|
589
|
-
ring.style.left = box.x - 6 + "px";
|
|
590
|
-
ring.style.top = box.y - 6 + "px";
|
|
591
|
-
ring.style.width = box.width + 12 + "px";
|
|
592
|
-
ring.style.height = box.height + 12 + "px";
|
|
593
|
-
ring.setAttribute("data-label", h.label);
|
|
594
|
-
ring.style.opacity = "1";
|
|
800
|
+
window.__wgPositionRing(el.getBoundingClientRect(), h.label);
|
|
595
801
|
};
|
|
596
802
|
reposition();
|
|
597
803
|
window.__wgRingTrack = reposition;
|
|
@@ -638,12 +844,27 @@ async function renderAfterStep(page, info) {
|
|
|
638
844
|
prettyText + "</div>" +
|
|
639
845
|
"<div class=\\"wg-result wg-result-json\\" style=\\"display:" + (pretty ? "none" : "block") + "\\">" +
|
|
640
846
|
info.resultTag + "</div>";
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
847
|
+
let autoNow = false;
|
|
848
|
+
try {
|
|
849
|
+
autoNow = localStorage.getItem("wg-autoplay") === "1";
|
|
850
|
+
} catch {
|
|
851
|
+
/* private mode / blocked storage - defaults to manual */
|
|
852
|
+
}
|
|
853
|
+
const gateHtml =
|
|
854
|
+
"<div class=\\"wg-autoplay-row\\"><label><input type=\\"checkbox\\" id=\\"wg-autoplay-cb\\"" +
|
|
855
|
+
(autoNow ? " checked" : "") +
|
|
856
|
+
"> Auto-advance</label></div>" +
|
|
857
|
+
"<div id=\\"wg-gate-manual\\"" + (autoNow ? " style=\\"display:none\\"" : "") +
|
|
858
|
+
"><button id=\\"wg-run\\">" + buttonLabel + "</button></div>" +
|
|
859
|
+
"<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
|
|
860
|
+
">Auto-advancing...</div>";
|
|
861
|
+
const episodeHtml = info.episodeNumber
|
|
862
|
+
? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + escA(info.episodeTitle || "") + "</div>"
|
|
863
|
+
: "";
|
|
644
864
|
panel.innerHTML =
|
|
645
865
|
"<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
|
|
646
866
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
867
|
+
episodeHtml +
|
|
647
868
|
"<h3>" + heading + "</h3>" +
|
|
648
869
|
resultHtml +
|
|
649
870
|
gateHtml;
|
|
@@ -661,6 +882,58 @@ async function renderAfterStep(page, info) {
|
|
|
661
882
|
});
|
|
662
883
|
const runBtn = document.getElementById("wg-run");
|
|
663
884
|
if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
|
|
885
|
+
const cb = document.getElementById("wg-autoplay-cb");
|
|
886
|
+
if (cb) {
|
|
887
|
+
cb.addEventListener("change", () => {
|
|
888
|
+
try {
|
|
889
|
+
localStorage.setItem("wg-autoplay", cb.checked ? "1" : "0");
|
|
890
|
+
} catch {
|
|
891
|
+
/* private mode / blocked storage - toggle still works this page */
|
|
892
|
+
}
|
|
893
|
+
const manual = document.getElementById("wg-gate-manual");
|
|
894
|
+
const auto = document.getElementById("wg-gate-auto");
|
|
895
|
+
if (manual) manual.style.display = cb.checked ? "none" : "";
|
|
896
|
+
if (auto) auto.style.display = cb.checked ? "" : "none";
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}, info)
|
|
900
|
+
.catch(() => {});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* A Block threw - act()/observe() rejected, or Flow.run's own verify Trait
|
|
905
|
+
* check failed. Shown instead of letting it crash the whole Node process
|
|
906
|
+
* silently from a human's point of view (the browser closes right after
|
|
907
|
+
* regardless - main()'s own try/finally - but not before this is visible).
|
|
908
|
+
*/
|
|
909
|
+
async function renderStepError(page, info) {
|
|
910
|
+
await installOverlay(page, info.title);
|
|
911
|
+
await page
|
|
912
|
+
.evaluate((info) => {
|
|
913
|
+
const old = document.getElementById("wg-panel");
|
|
914
|
+
if (old) old.remove();
|
|
915
|
+
const panel = document.createElement("div");
|
|
916
|
+
panel.id = "wg-panel";
|
|
917
|
+
panel.className = "wg-error";
|
|
918
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
919
|
+
const modulesHtml = info.allNames
|
|
920
|
+
.map((name, idx) => {
|
|
921
|
+
const cls = idx === info.index ? "wg-mod-current" : idx < info.index ? "wg-mod-done" : "wg-mod-upcoming";
|
|
922
|
+
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
923
|
+
const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
|
|
924
|
+
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
925
|
+
})
|
|
926
|
+
.join("");
|
|
927
|
+
panel.innerHTML =
|
|
928
|
+
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
929
|
+
"<h3 class=\\"wg-error-heading\\">Step " + (info.index + 1) + " / " + info.total + " - " +
|
|
930
|
+
info.blockName + " threw</h3>" +
|
|
931
|
+
"<div class=\\"wg-error-msg\\">" + esc(info.message) + "</div>" +
|
|
932
|
+
"<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>";
|
|
933
|
+
document.documentElement.appendChild(panel);
|
|
934
|
+
requestAnimationFrame(() => panel.classList.add("wg-in"));
|
|
935
|
+
const runBtn = document.getElementById("wg-run");
|
|
936
|
+
if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
|
|
664
937
|
}, info)
|
|
665
938
|
.catch(() => {});
|
|
666
939
|
}
|
|
@@ -737,14 +1010,7 @@ async function showRing(page, box, label) {
|
|
|
737
1010
|
await page
|
|
738
1011
|
.evaluate(
|
|
739
1012
|
({ box, label }) => {
|
|
740
|
-
|
|
741
|
-
if (!ring) return;
|
|
742
|
-
ring.style.left = box.x - 6 + "px";
|
|
743
|
-
ring.style.top = box.y - 6 + "px";
|
|
744
|
-
ring.style.width = box.width + 12 + "px";
|
|
745
|
-
ring.style.height = box.height + 12 + "px";
|
|
746
|
-
ring.setAttribute("data-label", label);
|
|
747
|
-
ring.style.opacity = "1";
|
|
1013
|
+
if (window.__wgPositionRing) window.__wgPositionRing(box, label);
|
|
748
1014
|
},
|
|
749
1015
|
{ box, label },
|
|
750
1016
|
)
|
|
@@ -754,13 +1020,73 @@ async function showRing(page, box, label) {
|
|
|
754
1020
|
async function hideRing(page) {
|
|
755
1021
|
await page
|
|
756
1022
|
.evaluate(() => {
|
|
757
|
-
|
|
758
|
-
if (ring) ring.style.opacity = "0";
|
|
1023
|
+
if (window.__wgHideRing) window.__wgHideRing();
|
|
759
1024
|
})
|
|
760
1025
|
.catch(() => {});
|
|
761
1026
|
}
|
|
762
1027
|
|
|
763
|
-
|
|
1028
|
+
/**
|
|
1029
|
+
* Removes every overlay element (panel, ring, ring-label, cursor,
|
|
1030
|
+
* click-pulse, banner) and stops the live resize/scroll ring tracker, once
|
|
1031
|
+
* the whole chain is genuinely done - "I want to see the same page just
|
|
1032
|
+
* like the demo opened for the first time," not the last step's panel and
|
|
1033
|
+
* highlight ring stuck over the real app forever. installOverlay only ever
|
|
1034
|
+
* ADDS these elements back on demand (idempotent, per-page-load) - nothing
|
|
1035
|
+
* re-creates them once torn down here unless another Block/fill/click runs.
|
|
1036
|
+
*/
|
|
1037
|
+
async function teardownOverlay(page) {
|
|
1038
|
+
await page
|
|
1039
|
+
.evaluate(() => {
|
|
1040
|
+
if (window.__wgRingTrack) {
|
|
1041
|
+
window.removeEventListener("resize", window.__wgRingTrack);
|
|
1042
|
+
window.removeEventListener("scroll", window.__wgRingTrack, true);
|
|
1043
|
+
window.__wgRingTrack = null;
|
|
1044
|
+
}
|
|
1045
|
+
window.__wgNarrateOwnsRing = false;
|
|
1046
|
+
for (const id of ["wg-panel", "wg-ring", "wg-ring-label", "wg-cursor", "wg-click-pulse", "wg-banner"]) {
|
|
1047
|
+
const el = document.getElementById(id);
|
|
1048
|
+
if (el) el.remove();
|
|
1049
|
+
}
|
|
1050
|
+
})
|
|
1051
|
+
.catch(() => {});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function moveCursorTo(page, box, ms) {
|
|
1055
|
+
const x = box.x + box.width / 2;
|
|
1056
|
+
const y = box.y + box.height / 2;
|
|
1057
|
+
await page
|
|
1058
|
+
.evaluate(({ x, y, ms }) => {
|
|
1059
|
+
if (window.__wgMoveCursorTo) window.__wgMoveCursorTo(x, y, ms);
|
|
1060
|
+
}, { x, y, ms })
|
|
1061
|
+
.catch(() => {});
|
|
1062
|
+
return { x, y };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function clickPulseAt(page, x, y) {
|
|
1066
|
+
await page
|
|
1067
|
+
.evaluate(({ x, y }) => {
|
|
1068
|
+
if (window.__wgClickPulse) window.__wgClickPulse(x, y);
|
|
1069
|
+
}, { x, y })
|
|
1070
|
+
.catch(() => {});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* True if the engine's own narrate() (waygraph's public export, for a Block
|
|
1075
|
+
* author explicitly captioning one action) JUST positioned the ring for
|
|
1076
|
+
* THIS action, moments ago - the automatic per-fill/per-click narration
|
|
1077
|
+
* below should not immediately overwrite an author's own explicit caption
|
|
1078
|
+
* with its generic guess.
|
|
1079
|
+
*/
|
|
1080
|
+
async function wasJustNarrated(page) {
|
|
1081
|
+
return page
|
|
1082
|
+
.evaluate(() => {
|
|
1083
|
+
const w = window;
|
|
1084
|
+
return typeof w.__wgLastNarrate === "number" && Date.now() - w.__wgLastNarrate < 500;
|
|
1085
|
+
})
|
|
1086
|
+
.catch(() => false);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
|
|
764
1090
|
// Playwright's own slowMo ALREADY pauses after every single low-level
|
|
765
1091
|
// action it dispatches - and pressSequentially() fires one such action
|
|
766
1092
|
// PER CHARACTER. Also giving pressSequentially its own fixed delay
|
|
@@ -768,13 +1094,20 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
768
1094
|
// character) - an ordinary 22-character email alone stretched past 8
|
|
769
1095
|
// seconds. When slowMo is already doing the pacing, add none of our own;
|
|
770
1096
|
// only fall back to a small typing delay when slowMo is off entirely.
|
|
771
|
-
|
|
1097
|
+
//
|
|
1098
|
+
// Each is a function, not a plain const, and reads pacing.fast fresh
|
|
1099
|
+
// on every call - pacing is a shared mutable object runStepMode flips
|
|
1100
|
+
// per-block (WAYGRAPH_FAST_BLOCKS), and the Locator.fill/click patches
|
|
1101
|
+
// below are installed once on the shared prototype but invoked once per
|
|
1102
|
+
// real interaction, long after this closure was created.
|
|
1103
|
+
const typeDelay = () => (pacing.fast ? 0 : slowMo ? 0 : 30);
|
|
772
1104
|
// Click is a single action, not per-character, so it doesn't compound
|
|
773
1105
|
// the same way - but slowMo still adds its own pause around the actual
|
|
774
1106
|
// click, so trim our own explicit "pop" pauses when it's already active
|
|
775
1107
|
// rather than stacking a full 1.2s on top of that.
|
|
776
|
-
const clickPrePop = slowMo ? 300 : 700;
|
|
777
|
-
const clickPostPop = slowMo ? 200 : 500;
|
|
1108
|
+
const clickPrePop = () => (pacing.fast ? 0 : slowMo ? 300 : 700);
|
|
1109
|
+
const clickPostPop = () => (pacing.fast ? 0 : slowMo ? 200 : 500);
|
|
1110
|
+
const cursorMs = (full) => (pacing.fast ? Math.min(120, full) : full);
|
|
778
1111
|
// Locator.fill()/click() only ever see a raw call, no context of where
|
|
779
1112
|
// the value came from. Patching mem.get() to remember the most recently
|
|
780
1113
|
// read key's name (Blocks read-then-immediately-fill, e.g. const { email
|
|
@@ -799,15 +1132,20 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
799
1132
|
try {
|
|
800
1133
|
await installOverlay(page);
|
|
801
1134
|
const box = await this.boundingBox();
|
|
802
|
-
if (box) {
|
|
1135
|
+
if (box && !(await wasJustNarrated(page))) {
|
|
803
1136
|
// Only trust the "last mem.get()" as THIS fill's source if it
|
|
804
1137
|
// happened recently - a stale read from several actions ago is
|
|
805
1138
|
// more likely unrelated than actually describing this field.
|
|
806
1139
|
const label = memTrack.lastKeyName && Date.now() - memTrack.at < 3000
|
|
807
1140
|
? "from mem: " + memTrack.lastKeyName
|
|
808
1141
|
: "writing from mem";
|
|
1142
|
+
await moveCursorTo(page, box, cursorMs(500));
|
|
809
1143
|
await showRing(page, box, label);
|
|
810
|
-
await new Promise((res) => setTimeout(res, 200));
|
|
1144
|
+
await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 200));
|
|
1145
|
+
} else if (box) {
|
|
1146
|
+
// Ring/caption already handled by narrate() - still move the
|
|
1147
|
+
// cursor there, just skip re-showing the ring with a generic label.
|
|
1148
|
+
await moveCursorTo(page, box, cursorMs(200));
|
|
811
1149
|
}
|
|
812
1150
|
} catch {
|
|
813
1151
|
// best-effort - element not visible/attached yet is not this
|
|
@@ -820,7 +1158,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
820
1158
|
// that call would resolve back to THIS same patched function and
|
|
821
1159
|
// recurse forever. Call the real original fill directly instead.
|
|
822
1160
|
await originalFill.call(this, "", { timeout: options && options.timeout });
|
|
823
|
-
result = await this.pressSequentially(String(value), { delay: typeDelay, timeout: options && options.timeout });
|
|
1161
|
+
result = await this.pressSequentially(String(value), { delay: typeDelay(), timeout: options && options.timeout });
|
|
824
1162
|
} catch {
|
|
825
1163
|
// pressSequentially unsupported on this element (e.g. a
|
|
826
1164
|
// contenteditable div, or a locator .fill() genuinely needs to
|
|
@@ -839,10 +1177,12 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
839
1177
|
proto.__wgClickPatched = true;
|
|
840
1178
|
const originalClick = proto.click;
|
|
841
1179
|
proto.click = async function (options) {
|
|
1180
|
+
let clickPoint = null;
|
|
842
1181
|
try {
|
|
843
1182
|
await installOverlay(page);
|
|
844
1183
|
const box = await this.boundingBox();
|
|
845
|
-
|
|
1184
|
+
const narrated = box ? await wasJustNarrated(page) : false;
|
|
1185
|
+
if (box && !narrated) {
|
|
846
1186
|
let label = "click";
|
|
847
1187
|
try {
|
|
848
1188
|
const text = (await this.textContent())?.trim();
|
|
@@ -850,17 +1190,23 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
850
1190
|
} catch {
|
|
851
1191
|
// element has no simple text (an icon button, say) - generic label is fine
|
|
852
1192
|
}
|
|
1193
|
+
clickPoint = await moveCursorTo(page, box, cursorMs(600));
|
|
853
1194
|
await showRing(page, box, label);
|
|
854
1195
|
// "pop for a few seconds" - Dan's own phrase, matching the
|
|
855
1196
|
// zsign demo-engine's ring-before-click pattern in
|
|
856
1197
|
// services/help-center-clip-engine's video-pipeline.
|
|
857
|
-
await new Promise((res) => setTimeout(res, clickPrePop));
|
|
1198
|
+
await new Promise((res) => setTimeout(res, clickPrePop()));
|
|
1199
|
+
} else if (box) {
|
|
1200
|
+
// Ring/caption already handled by narrate() - still move the
|
|
1201
|
+
// cursor + pulse the click point, just skip re-showing the ring.
|
|
1202
|
+
clickPoint = await moveCursorTo(page, box, cursorMs(200));
|
|
858
1203
|
}
|
|
859
1204
|
} catch {
|
|
860
1205
|
// best-effort - the real click below still runs either way
|
|
861
1206
|
}
|
|
1207
|
+
if (clickPoint) await clickPulseAt(page, clickPoint.x, clickPoint.y);
|
|
862
1208
|
const result = await originalClick.call(this, options);
|
|
863
|
-
await new Promise((res) => setTimeout(res, clickPostPop));
|
|
1209
|
+
await new Promise((res) => setTimeout(res, clickPostPop()));
|
|
864
1210
|
await hideRing(page);
|
|
865
1211
|
return result;
|
|
866
1212
|
};
|
|
@@ -877,19 +1223,42 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
877
1223
|
// helpers some Blocks use for their own pacing are NOT Playwright calls
|
|
878
1224
|
// at all and can't be touched this way - a real, disclosed limit, not
|
|
879
1225
|
// silently ignored.
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
// waits
|
|
886
|
-
//
|
|
887
|
-
|
|
1226
|
+
// Pushed down to 700ms earlier this session based on login.block.ts
|
|
1227
|
+
// alone - every waitFor() there is a "give up gracefully" pattern
|
|
1228
|
+
// (wrapped in .catch()/Promise.race, a timeout IS the expected negative
|
|
1229
|
+
// result). That's a different kind of wait than a REQUIRED
|
|
1230
|
+
// synchronization point with no catch - e.g. overview-metrics.block.ts
|
|
1231
|
+
// waits (uncaught) for a "Loading pending actions..." placeholder to
|
|
1232
|
+
// actually appear before it's safe to check for an error, a real
|
|
1233
|
+
// hydration signal the block's own comment calls "deterministic,"
|
|
1234
|
+
// not a probe. A flat 700ms cap broke that block outright on this
|
|
1235
|
+
// shared, loaded box (a real 3-block chain run threw instead of
|
|
1236
|
+
// catching). Bumping the SAME flat cap to 4000ms to cover it just
|
|
1237
|
+
// reintroduced most of the original torture on login.block.ts's own
|
|
1238
|
+
// 5000ms unverified-email check - 4000ms is barely better than the real
|
|
1239
|
+
// 5000ms it's capping.
|
|
1240
|
+
//
|
|
1241
|
+
// A single flat number can't serve both: it has no way to tell "safe to
|
|
1242
|
+
// cut short" from "must actually happen" apart from the outside. But
|
|
1243
|
+
// each call site's OWN declared timeout is already a real signal of
|
|
1244
|
+
// which kind it is - a Block author who wrote timeout: 5000 for a quick
|
|
1245
|
+
// conditional check and one who wrote timeout: 15000 for a real
|
|
1246
|
+
// hydration wait weren't picking the same number by accident. Scale the
|
|
1247
|
+
// cap off that instead of a single constant: 30% of whatever was
|
|
1248
|
+
// declared, never below a 700ms floor (the value already proven safe
|
|
1249
|
+
// for the fast/common case), and never above what was declared in the
|
|
1250
|
+
// first place. login's 5000ms check -> 1500ms (still 3.5s faster than
|
|
1251
|
+
// uncapped). overview-metrics' 15000ms wait -> 4500ms (more headroom
|
|
1252
|
+
// than the flat 4000ms fix, not less).
|
|
1253
|
+
const WAIT_CAP_FLOOR_MS = 700;
|
|
1254
|
+
const WAIT_CAP_FRACTION = 0.3;
|
|
1255
|
+
const scaledWaitCap = (declared) => Math.max(WAIT_CAP_FLOOR_MS, declared * WAIT_CAP_FRACTION);
|
|
888
1256
|
if (!proto.__wgWaitForPatched) {
|
|
889
1257
|
proto.__wgWaitForPatched = true;
|
|
890
1258
|
const originalWaitFor = proto.waitFor;
|
|
891
1259
|
proto.waitFor = function (options) {
|
|
892
|
-
const
|
|
1260
|
+
const declared = (options && options.timeout) || 30000;
|
|
1261
|
+
const capped = { ...(options || {}), timeout: Math.min(declared, scaledWaitCap(declared)) };
|
|
893
1262
|
return originalWaitFor.call(this, capped);
|
|
894
1263
|
};
|
|
895
1264
|
}
|
|
@@ -898,13 +1267,23 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
|
|
|
898
1267
|
pageProto.__wgWaitForTimeoutPatched = true;
|
|
899
1268
|
const originalWaitForTimeout = pageProto.waitForTimeout;
|
|
900
1269
|
pageProto.waitForTimeout = function (ms) {
|
|
901
|
-
|
|
1270
|
+
// A plain sleep (not a "wait for condition X") never has a hidden
|
|
1271
|
+
// "must actually happen" requirement attached - always safe to cut
|
|
1272
|
+
// to the floor, no scaling needed.
|
|
1273
|
+
return originalWaitForTimeout.call(this, Math.min(ms, WAIT_CAP_FLOOR_MS));
|
|
902
1274
|
};
|
|
903
1275
|
}
|
|
904
1276
|
}
|
|
905
1277
|
|
|
906
|
-
async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title) {
|
|
907
|
-
|
|
1278
|
+
async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
|
|
1279
|
+
// A shared, mutable pacing knob the interaction patches read live (per
|
|
1280
|
+
// call, not once at setup) - flipped per-block below so one block (e.g.
|
|
1281
|
+
// WAYGRAPH_FAST_BLOCKS=login) can run through with none of the overlay's
|
|
1282
|
+
// own added dwell while the rest of the chain keeps the full theatrical
|
|
1283
|
+
// pace. Playwright's own slowMo is process-wide and untouched by this -
|
|
1284
|
+
// only OUR added pauses (ring pop, cursor travel, typing delay) shrink.
|
|
1285
|
+
const pacing = { fast: false };
|
|
1286
|
+
instrumentInteractionHighlighting(page, mem, slowMo, pacing);
|
|
908
1287
|
// Some real Blocks (e.g. zsign-all's login.block.ts) call
|
|
909
1288
|
// page.setViewportSize({ width: 1280, height: 720 }) inside their own
|
|
910
1289
|
// act() - a hardcoded override for THEIR OWN testing consistency, with no
|
|
@@ -926,18 +1305,92 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
926
1305
|
}
|
|
927
1306
|
});
|
|
928
1307
|
const waitForNext = () => new Promise((res) => (resolveNext = res));
|
|
929
|
-
// WAYGRAPH_AUTOPLAY=1
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
1308
|
+
// WAYGRAPH_AUTOPLAY=1 only sets the STARTING checkbox state now - the
|
|
1309
|
+
// panel's own "Auto-advance" checkbox can flip it live, mid-run, and a
|
|
1310
|
+
// manual click always wins over an in-flight autoplay wait regardless of
|
|
1311
|
+
// which way the checkbox is set. That's the hybrid Dan asked for: some
|
|
1312
|
+
// steps auto-advance, some get a manual click, toggled as the demo goes,
|
|
1313
|
+
// not fixed for the whole run from a single env var.
|
|
933
1314
|
const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
|
|
934
|
-
const
|
|
1315
|
+
const currentAutoplay = () =>
|
|
1316
|
+
page
|
|
1317
|
+
.evaluate(() => {
|
|
1318
|
+
try {
|
|
1319
|
+
return localStorage.getItem("wg-autoplay") === "1";
|
|
1320
|
+
} catch {
|
|
1321
|
+
return false;
|
|
1322
|
+
}
|
|
1323
|
+
})
|
|
1324
|
+
.catch(() => false);
|
|
1325
|
+
const gate = async () => {
|
|
1326
|
+
const next = waitForNext();
|
|
1327
|
+
// WAYGRAPH_FAST_BLOCKS names a block that should blow past its own
|
|
1328
|
+
// gates too, not just skip its interaction dwell - otherwise autoplay
|
|
1329
|
+
// still stalls the full autoplayMs admiring a step that intentionally
|
|
1330
|
+
// ran too fast to watch.
|
|
1331
|
+
const ms = pacing.fast ? Math.min(400, autoplayMs) : autoplayMs;
|
|
1332
|
+
let elapsed = 0;
|
|
1333
|
+
for (;;) {
|
|
1334
|
+
// Re-read the checkbox EVERY loop tick, not once up front - a human
|
|
1335
|
+
// starting a step in manual mode and then checking "Auto-advance"
|
|
1336
|
+
// mid-wait must actually start counting down from that moment, not
|
|
1337
|
+
// get stuck on whatever mode was live when gate() was first called.
|
|
1338
|
+
const auto = await currentAutoplay();
|
|
1339
|
+
const slice = auto
|
|
1340
|
+
? Math.min(250, Math.max(50, ms - elapsed))
|
|
1341
|
+
: 250; // manual: just a cheap poll tick, waiting on either a click or the box getting checked
|
|
1342
|
+
const winner = await Promise.race([
|
|
1343
|
+
next.then((edits) => ({ clicked: true, edits })),
|
|
1344
|
+
new Promise((res) => setTimeout(() => res({ clicked: false }), slice)),
|
|
1345
|
+
]);
|
|
1346
|
+
if (winner.clicked) return winner.edits;
|
|
1347
|
+
if (!auto) continue;
|
|
1348
|
+
elapsed += slice;
|
|
1349
|
+
if (elapsed >= ms) return {};
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
935
1352
|
const allNames = resolved.map((r) => r.block.name);
|
|
936
1353
|
const allDescriptions = resolved.map((r) => r.block.description || "");
|
|
937
1354
|
|
|
938
1355
|
let result;
|
|
939
1356
|
for (let i = 0; i < resolved.length; i++) {
|
|
940
1357
|
const r = resolved[i];
|
|
1358
|
+
// Reset session state before this block if EITHER: the blanket
|
|
1359
|
+
// WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
|
|
1360
|
+
// the-first-block stopgap for ad hoc block chains with no real Flow
|
|
1361
|
+
// object), OR this specific block is a Flow's own declared entry point
|
|
1362
|
+
// (defineFlow(...) wrapped in withSessionReset) reached via a
|
|
1363
|
+
// multi-flow chain spec - the proper, opt-in-per-Flow mechanism. Never
|
|
1364
|
+
// resets before the very first block - there's no prior state yet.
|
|
1365
|
+
//
|
|
1366
|
+
// Done HERE, before this step's own "before" panel is even shown - not
|
|
1367
|
+
// after the human clicks "Run this step" - so the panel for the new
|
|
1368
|
+
// episode appears over an already-fresh page, not the PREVIOUS
|
|
1369
|
+
// episode's stale final page (e.g. still showing /dashboard) with the
|
|
1370
|
+
// new episode's panel merely floating on top of it ("overlapping",
|
|
1371
|
+
// Dan's own word for this). The explicit re-navigate (not just
|
|
1372
|
+
// clearing cookies/storage) is what actually makes the page itself
|
|
1373
|
+
// look fresh again - clearing storage alone doesn't change what's
|
|
1374
|
+
// still rendered on screen.
|
|
1375
|
+
if ((clearSession || r.resetSession) && i > 0) {
|
|
1376
|
+
await context.clearCookies().catch(() => {});
|
|
1377
|
+
await page
|
|
1378
|
+
.evaluate(() => {
|
|
1379
|
+
try {
|
|
1380
|
+
localStorage.clear();
|
|
1381
|
+
sessionStorage.clear();
|
|
1382
|
+
} catch {
|
|
1383
|
+
/* storage blocked (e.g. about:blank) - nothing to clear anyway */
|
|
1384
|
+
}
|
|
1385
|
+
})
|
|
1386
|
+
.catch(() => {});
|
|
1387
|
+
if (baseURL) {
|
|
1388
|
+
await page.goto(baseURL).catch(() => {});
|
|
1389
|
+
} else {
|
|
1390
|
+
await page.goto("about:blank").catch(() => {});
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
pacing.fast = fastBlockNames.has(r.block.name);
|
|
941
1394
|
const requires = r.block.requires ?? [];
|
|
942
1395
|
const keys = requires.map((k) => {
|
|
943
1396
|
let value = "<not yet set>";
|
|
@@ -956,8 +1409,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
956
1409
|
keys,
|
|
957
1410
|
allNames,
|
|
958
1411
|
allDescriptions,
|
|
959
|
-
autoplay,
|
|
960
1412
|
title,
|
|
1413
|
+
episodeNumber: r.episodeNumber,
|
|
1414
|
+
episodeTitle: r.episodeTitle,
|
|
961
1415
|
});
|
|
962
1416
|
const edits = await gate();
|
|
963
1417
|
for (const k of requires) {
|
|
@@ -974,7 +1428,26 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
974
1428
|
// the plain checkpoint - destructure it, don't treat the wrapper as the
|
|
975
1429
|
// checkpoint itself (caught via the standalone verify script: this used
|
|
976
1430
|
// to serialize the whole { result, page } object into the panel/log).
|
|
977
|
-
|
|
1431
|
+
let stepOutcome;
|
|
1432
|
+
try {
|
|
1433
|
+
stepOutcome = await stepFlow.run(context, mem, { page, closeOnFinish: false });
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
// A thrown act()/observe()/a failed verify Trait used to just crash
|
|
1436
|
+
// the whole Node process with a raw stack trace - the browser closes
|
|
1437
|
+
// (main()'s own try/finally) before a human watching ever sees WHY.
|
|
1438
|
+
// Show it on-screen and let a real click acknowledge it first.
|
|
1439
|
+
await renderStepError(page, {
|
|
1440
|
+
index: i,
|
|
1441
|
+
total: resolved.length,
|
|
1442
|
+
blockName: r.block.name,
|
|
1443
|
+
message: err && err.message ? err.message : String(err),
|
|
1444
|
+
allNames,
|
|
1445
|
+
allDescriptions,
|
|
1446
|
+
title,
|
|
1447
|
+
});
|
|
1448
|
+
await gate();
|
|
1449
|
+
throw err;
|
|
1450
|
+
}
|
|
978
1451
|
result = stepOutcome.result;
|
|
979
1452
|
const highlights = extractVerifyHighlights(r.block, result.__state);
|
|
980
1453
|
await renderAfterStep(page, {
|
|
@@ -987,18 +1460,20 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
987
1460
|
isLast: i === resolved.length - 1,
|
|
988
1461
|
allNames,
|
|
989
1462
|
allDescriptions,
|
|
990
|
-
autoplay,
|
|
991
1463
|
title,
|
|
1464
|
+
episodeNumber: r.episodeNumber,
|
|
1465
|
+
episodeTitle: r.episodeTitle,
|
|
992
1466
|
});
|
|
993
1467
|
await gate();
|
|
994
1468
|
}
|
|
1469
|
+
await teardownOverlay(page);
|
|
995
1470
|
return result;
|
|
996
1471
|
}
|
|
997
1472
|
|
|
998
1473
|
async function main() {
|
|
999
1474
|
const projectDir = process.argv[2];
|
|
1000
1475
|
const spec = process.argv[3];
|
|
1001
|
-
const { connect, MemPage, Engine, start, end } = await import("waygraph");
|
|
1476
|
+
const { connect, MemPage, Engine, start, end, chainFlow } = await import("waygraph");
|
|
1002
1477
|
const mem = new MemPage();
|
|
1003
1478
|
let resolved = [];
|
|
1004
1479
|
// A bare identifier (no "(", no "then") might name an existing Flow
|
|
@@ -1022,11 +1497,58 @@ async function main() {
|
|
|
1022
1497
|
if (segments.length === 0) {
|
|
1023
1498
|
throw new Error("waygraph chain: empty spec - give at least one block name");
|
|
1024
1499
|
}
|
|
1500
|
+
// Each "then"-separated segment can ALSO name an existing Flow (a
|
|
1501
|
+
// Flow's own requires, unioned across its Blocks, take the same
|
|
1502
|
+
// JSON-payload shape a single Block's requires would), not just a
|
|
1503
|
+
// Block. "loginFlow then dashboardFlow" chains two whole Flows one
|
|
1504
|
+
// after another - a bare Block segment gets wrapped as its own
|
|
1505
|
+
// trivial one-Block Flow so the whole spec reduces to one thing:
|
|
1506
|
+
// chainFlow(...) does the actual sequencing (session-reset boundaries
|
|
1507
|
+
// included) that used to be hand-rolled here - this is a thin wrapper
|
|
1508
|
+
// over the real engine primitive, not a second copy of its logic.
|
|
1509
|
+
const wrapEngine = new Engine();
|
|
1510
|
+
const flows = [];
|
|
1511
|
+
// Parallel to flows - only a REAL named Flow (found via findFlow)
|
|
1512
|
+
// counts as an "episode" a human would want labeled; a bare Block
|
|
1513
|
+
// segment gets wrapped as its own trivial one-Block Flow so it can
|
|
1514
|
+
// still flow through chainFlow's sequencing uniformly, but it's not
|
|
1515
|
+
// an authored scenario and gets no episode number - keeps the plain
|
|
1516
|
+
// "quick ad hoc block chain" case free of meaningless "Episode 1"/
|
|
1517
|
+
// "Episode 2" labels on things that were never episodes to begin with.
|
|
1518
|
+
const flowMeta = [];
|
|
1519
|
+
let episodeCounter = 0;
|
|
1025
1520
|
for (const seg of segments) {
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1521
|
+
const flow = await findFlow(projectDir, seg.ref);
|
|
1522
|
+
if (flow && typeof flow.blocks === "function") {
|
|
1523
|
+
seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref);
|
|
1524
|
+
flows.push(flow);
|
|
1525
|
+
episodeCounter += 1;
|
|
1526
|
+
flowMeta.push({ episodeNumber: episodeCounter, episodeTitle: flow.title || seg.ref });
|
|
1527
|
+
} else {
|
|
1528
|
+
const r = await findBlock(projectDir, seg.ref);
|
|
1529
|
+
seedMemForBlock(mem, r, seg.json);
|
|
1530
|
+
flows.push(wrapEngine.defineFlow([start, r.block, end]));
|
|
1531
|
+
flowMeta.push(null);
|
|
1532
|
+
}
|
|
1029
1533
|
}
|
|
1534
|
+
const combinedBlocks = chainFlow(...flows).blocks();
|
|
1535
|
+
let fi = 0;
|
|
1536
|
+
let remainingInFlow = flows[0].blocks().length;
|
|
1537
|
+
resolved = combinedBlocks.map((bi) => {
|
|
1538
|
+
while (remainingInFlow === 0) {
|
|
1539
|
+
fi += 1;
|
|
1540
|
+
remainingInFlow = flows[fi].blocks().length;
|
|
1541
|
+
}
|
|
1542
|
+
remainingInFlow -= 1;
|
|
1543
|
+
const meta = flowMeta[fi];
|
|
1544
|
+
return {
|
|
1545
|
+
block: bi.block,
|
|
1546
|
+
exportName: bi.name,
|
|
1547
|
+
resetSession: bi.resetSessionBefore === true,
|
|
1548
|
+
episodeNumber: meta ? meta.episodeNumber : undefined,
|
|
1549
|
+
episodeTitle: meta ? meta.episodeTitle : undefined,
|
|
1550
|
+
};
|
|
1551
|
+
});
|
|
1030
1552
|
console.log(
|
|
1031
1553
|
"waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
|
|
1032
1554
|
" (" + resolved.length + " block" + (resolved.length === 1 ? "" : "s") + ")",
|
|
@@ -1045,6 +1567,23 @@ async function main() {
|
|
|
1045
1567
|
: undefined;
|
|
1046
1568
|
const baseURL = process.env.WAYGRAPH_BASE_URL;
|
|
1047
1569
|
const title = process.env.WAYGRAPH_TITLE;
|
|
1570
|
+
// Comma-separated Block names (their real .name, e.g. "login") that
|
|
1571
|
+
// should blow past the overlay's own added dwell (ring pop, cursor
|
|
1572
|
+
// travel, typing delay, and that block's own gates) - "I want the login
|
|
1573
|
+
// block to be faster than the rest of the demo," Dan's own phrase.
|
|
1574
|
+
// Playwright's real slowMo is untouched; this only trims what waygraph
|
|
1575
|
+
// itself adds on top of it.
|
|
1576
|
+
const fastBlockNames = new Set(
|
|
1577
|
+
(process.env.WAYGRAPH_FAST_BLOCKS || "")
|
|
1578
|
+
.split(",")
|
|
1579
|
+
.map((s) => s.trim())
|
|
1580
|
+
.filter(Boolean),
|
|
1581
|
+
);
|
|
1582
|
+
// Off by default - a same-user chain (login -> dashboard -> ...) needs
|
|
1583
|
+
// to STAY authenticated across its own blocks. Only a chain deliberately
|
|
1584
|
+
// re-visiting an auth entry point (e.g. "login" appearing twice, to
|
|
1585
|
+
// demo the same flow starting fresh each time) needs this.
|
|
1586
|
+
const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
|
|
1048
1587
|
const engine = new Engine({ headless: !headed, slowMo });
|
|
1049
1588
|
let result;
|
|
1050
1589
|
if (step || baseURL) {
|
|
@@ -1076,7 +1615,7 @@ async function main() {
|
|
|
1076
1615
|
if (baseURL) {
|
|
1077
1616
|
await page.goto(baseURL).catch(() => {});
|
|
1078
1617
|
}
|
|
1079
|
-
result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title);
|
|
1618
|
+
result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL);
|
|
1080
1619
|
} else {
|
|
1081
1620
|
const blocks = resolved.map((r) => r.block);
|
|
1082
1621
|
const chained = blocks.reduce((a, b) => connect(a, b));
|
|
@@ -1148,6 +1687,64 @@ async function validateFlows(projectDir) {
|
|
|
1148
1687
|
return results;
|
|
1149
1688
|
}
|
|
1150
1689
|
// ---------------------------------------------------------------------------
|
|
1690
|
+
// `check` - secondary/complementary to the ActionPage @deprecated warning
|
|
1691
|
+
// (src/types.ts). That's the primary, always-on signal an editor shows the
|
|
1692
|
+
// instant a regular Block's act() calls a navigation method; this command
|
|
1693
|
+
// sweeps a whole project in one shot for contexts with no editor watching
|
|
1694
|
+
// (CI, an autonomous agent writing Block files without a language server).
|
|
1695
|
+
// See openspec/changes/nav-block-and-check/ for the full rationale.
|
|
1696
|
+
// ---------------------------------------------------------------------------
|
|
1697
|
+
function discoverBlocks(projectDir) {
|
|
1698
|
+
return walkDir(projectDir, /\.block\.ts$/);
|
|
1699
|
+
}
|
|
1700
|
+
function isBlockLike(val) {
|
|
1701
|
+
if (val === null || typeof val !== "object")
|
|
1702
|
+
return false;
|
|
1703
|
+
const obj = val;
|
|
1704
|
+
if (typeof obj.name !== "string")
|
|
1705
|
+
return false;
|
|
1706
|
+
const instruction = obj.instruction;
|
|
1707
|
+
return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
|
|
1708
|
+
}
|
|
1709
|
+
/** Set by `defineNavBlock` (non-enumerable) - see src/engine.ts. */
|
|
1710
|
+
function isNavBlockMarked(val) {
|
|
1711
|
+
return val.__waygraphKind === "nav";
|
|
1712
|
+
}
|
|
1713
|
+
const NAV_METHOD_CALLS = ["page.goto(", "page.reload(", "page.goBack(", "page.goForward("];
|
|
1714
|
+
/**
|
|
1715
|
+
* Walks every `*.block.ts` file under `projectDir`, imports each to find its
|
|
1716
|
+
* exported Blocks, and for any Block NOT built via `defineNavBlock`, scans
|
|
1717
|
+
* that file's own source text for a navigation call. Warning only - never
|
|
1718
|
+
* throws, never changes the caller's exit code on its own account (a Block
|
|
1719
|
+
* file that fails to import for unrelated reasons is silently skipped here;
|
|
1720
|
+
* `validate` is the command that reports import failures).
|
|
1721
|
+
*/
|
|
1722
|
+
async function checkCommand(projectDir) {
|
|
1723
|
+
const files = discoverBlocks(projectDir);
|
|
1724
|
+
const warnings = [];
|
|
1725
|
+
for (const file of files) {
|
|
1726
|
+
let mod;
|
|
1727
|
+
try {
|
|
1728
|
+
mod = await importModule(file);
|
|
1729
|
+
}
|
|
1730
|
+
catch {
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
let src;
|
|
1734
|
+
for (const [exportName, exported] of Object.entries(mod)) {
|
|
1735
|
+
if (!isBlockLike(exported))
|
|
1736
|
+
continue;
|
|
1737
|
+
if (isNavBlockMarked(exported))
|
|
1738
|
+
continue;
|
|
1739
|
+
src ??= readFileSync(file, "utf-8");
|
|
1740
|
+
if (NAV_METHOD_CALLS.some((needle) => src.includes(needle))) {
|
|
1741
|
+
warnings.push({ file, blockName: exported.name, exportName });
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
return warnings;
|
|
1746
|
+
}
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1151
1748
|
// `run`
|
|
1152
1749
|
// ---------------------------------------------------------------------------
|
|
1153
1750
|
async function runFlow(projectDir, flowName) {
|
|
@@ -1186,6 +1783,13 @@ Usage:
|
|
|
1186
1783
|
waygraph validate [project] Import and validate all flows
|
|
1187
1784
|
waygraph run <flow> [project] Run a named flow
|
|
1188
1785
|
waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
|
|
1786
|
+
waygraph check [project] Warn about navigation outside a NavBlock
|
|
1787
|
+
|
|
1788
|
+
"check" is a secondary sweep, complementary to the always-on TypeScript
|
|
1789
|
+
warning every regular Block's act() already gets (a struck-through
|
|
1790
|
+
page.goto/reload/goBack/goForward, pointing at defineNavBlock) - useful
|
|
1791
|
+
where nothing is watching in an editor (CI, generated code). Warning
|
|
1792
|
+
only, never fails - see openspec/changes/nav-block-and-check/.
|
|
1189
1793
|
|
|
1190
1794
|
"chain" needs no .flow.ts file - reference Blocks (by their own
|
|
1191
1795
|
runtime .name, or their export identifier) straight from the command
|
|
@@ -1211,9 +1815,30 @@ Usage:
|
|
|
1211
1815
|
just confirmed, then "Next" before moving on.
|
|
1212
1816
|
Implies headed - stepping through headless defeats
|
|
1213
1817
|
the point.
|
|
1214
|
-
WAYGRAPH_AUTOPLAY=1 step mode only -
|
|
1215
|
-
|
|
1818
|
+
WAYGRAPH_AUTOPLAY=1 step mode only - starting state for the panel's
|
|
1819
|
+
own "Auto-advance" checkbox (advances on a timer
|
|
1820
|
+
instead of waiting for clicks). The checkbox can
|
|
1821
|
+
be flipped live mid-run - some steps auto, some
|
|
1822
|
+
manual - and a manual click always wins over an
|
|
1823
|
+
in-flight autoplay wait either way.
|
|
1216
1824
|
WAYGRAPH_AUTOPLAY_MS=ms delay between auto-advances (default 1800)
|
|
1825
|
+
WAYGRAPH_FAST_BLOCKS=name1,name2
|
|
1826
|
+
step mode only - these Blocks' own .name (e.g.
|
|
1827
|
+
"login") blow past the overlay's added dwell -
|
|
1828
|
+
ring pop, cursor travel, typing delay, and that
|
|
1829
|
+
block's own gates - while the rest of the chain
|
|
1830
|
+
keeps the full pace. Playwright's own slowMo is
|
|
1831
|
+
untouched.
|
|
1832
|
+
WAYGRAPH_CLEAR_SESSION=1
|
|
1833
|
+
step mode only - clears cookies + storage before
|
|
1834
|
+
every block AFTER the first. Off by default - a
|
|
1835
|
+
normal chain (login -> dashboard) needs to STAY
|
|
1836
|
+
authenticated across its own blocks. Only turn
|
|
1837
|
+
this on for a chain that deliberately re-visits
|
|
1838
|
+
an auth entry point as a fresh visitor each time
|
|
1839
|
+
(e.g. "login" appearing more than once) - without
|
|
1840
|
+
it the second visit is still authenticated, so
|
|
1841
|
+
the app redirects away before the form renders.
|
|
1217
1842
|
WAYGRAPH_TITLE="..." step mode only - a persistent top banner
|
|
1218
1843
|
naming what this whole run is about (default
|
|
1219
1844
|
top-left; click the banner to cycle left /
|
|
@@ -1284,6 +1909,23 @@ async function main() {
|
|
|
1284
1909
|
await runChain(proj, spec);
|
|
1285
1910
|
break;
|
|
1286
1911
|
}
|
|
1912
|
+
case "check": {
|
|
1913
|
+
const proj = resolve(args[1] ?? process.cwd());
|
|
1914
|
+
const warnings = await checkCommand(proj);
|
|
1915
|
+
if (warnings.length === 0) {
|
|
1916
|
+
console.log(`waygraph check: no navigation found outside NavBlocks under ${proj}`);
|
|
1917
|
+
}
|
|
1918
|
+
else {
|
|
1919
|
+
for (const w of warnings) {
|
|
1920
|
+
const rel = relative(proj, w.file);
|
|
1921
|
+
console.warn(`waygraph check: ${rel} (${w.blockName}) calls page.goto/reload/goBack/goForward outside a NavBlock - consider defineNavBlock instead`);
|
|
1922
|
+
}
|
|
1923
|
+
console.log(`waygraph check: ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`);
|
|
1924
|
+
}
|
|
1925
|
+
// Warning only - never fails the command, matching the spec's
|
|
1926
|
+
// "never a build failure" requirement.
|
|
1927
|
+
break;
|
|
1928
|
+
}
|
|
1287
1929
|
default:
|
|
1288
1930
|
console.error(`waygraph: unknown command "${command}"`);
|
|
1289
1931
|
process.exit(1);
|