waygraph 0.2.1 → 0.3.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/dist/cli.js CHANGED
@@ -359,6 +359,7 @@ const RING_CSS =
359
359
  "#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
360
360
  "#wg-panel h3{margin:0 0 8px;font-size:13px;color:#c9a6ff;font-weight:700;" +
361
361
  "letter-spacing:.02em;text-transform:uppercase;}" +
362
+ "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
362
363
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
363
364
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
364
365
  "#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
@@ -366,6 +367,12 @@ const RING_CSS =
366
367
  "#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
367
368
  "#wg-modules .wg-mod-current{background:#7C3AED;color:#fff;}" +
368
369
  "#wg-modules .wg-mod-upcoming{background:transparent;color:#5a4a80;border:1px solid #3a2a60;}" +
370
+ "#wg-banner{position:fixed;z-index:2147483647;top:14px;right:14px;max-width:320px;" +
371
+ "background:rgba(20,10,40,.94);color:#fff;border-radius:12px;padding:10px 16px;" +
372
+ "font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 20px rgba(0,0,0,.3);" +
373
+ "border:1px solid rgba(124,58,237,.4);}" +
374
+ "#wg-banner .wg-banner-tag{display:block;font-size:10px;font-weight:700;color:#c9a6ff;" +
375
+ "letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;}" +
369
376
  "#wg-panel .wg-key{margin:8px 0;}" +
370
377
  "#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
371
378
  "#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
@@ -381,21 +388,59 @@ const RING_CSS =
381
388
  "background:transparent;border:1px solid #4b2a80;color:#9a7ad1;border-radius:6px;}" +
382
389
  "#wg-panel .wg-toggle button.wg-active{background:#4b2a80;color:#fff;}";
383
390
 
384
- async function installOverlay(page) {
391
+ // Purple dot favicon (matches the overlay's own theme color) - the tab-bar
392
+ // signal that "this Chromium window is a waygraph run," even at a glance
393
+ // across a taskbar/alt-tab, not just something visible inside the page.
394
+ const WAYGRAPH_FAVICON =
395
+ "data:image/svg+xml," +
396
+ encodeURIComponent(
397
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
398
+ "<circle cx='16' cy='16' r='14' fill='#7C3AED'/></svg>",
399
+ );
400
+
401
+ async function installOverlay(page, title) {
385
402
  await page.addStyleTag({ content: RING_CSS }).catch(() => {});
386
403
  await page
387
- .evaluate(() => {
388
- if (!document.getElementById("wg-ring")) {
389
- const ring = document.createElement("div");
390
- ring.id = "wg-ring";
391
- document.documentElement.appendChild(ring);
392
- }
393
- })
404
+ .evaluate(
405
+ ({ title, favicon }) => {
406
+ if (!document.getElementById("wg-ring")) {
407
+ const ring = document.createElement("div");
408
+ ring.id = "wg-ring";
409
+ document.documentElement.appendChild(ring);
410
+ }
411
+ if (title && !document.getElementById("wg-banner")) {
412
+ const banner = document.createElement("div");
413
+ banner.id = "wg-banner";
414
+ const tag = document.createElement("span");
415
+ tag.className = "wg-banner-tag";
416
+ tag.textContent = "waygraph demo";
417
+ const text = document.createElement("span");
418
+ text.textContent = title;
419
+ banner.appendChild(tag);
420
+ banner.appendChild(text);
421
+ document.documentElement.appendChild(banner);
422
+ }
423
+ // Tab title/favicon: a real navigation resets document.title and any
424
+ // <link rel="icon"> the new document brings, so re-check (not
425
+ // re-append) on every call instead of a one-time flag.
426
+ if (!document.title.startsWith("[waygraph] ")) {
427
+ document.title = "[waygraph] " + document.title;
428
+ }
429
+ let iconLink = document.querySelector("link[rel~='icon']");
430
+ if (!iconLink) {
431
+ iconLink = document.createElement("link");
432
+ iconLink.rel = "icon";
433
+ document.head.appendChild(iconLink);
434
+ }
435
+ if (iconLink.href !== favicon) iconLink.href = favicon;
436
+ },
437
+ { title, favicon: WAYGRAPH_FAVICON },
438
+ )
394
439
  .catch(() => {});
395
440
  }
396
441
 
397
442
  async function renderBeforeStep(page, info) {
398
- await installOverlay(page);
443
+ await installOverlay(page, info.title);
399
444
  // A ring left highlighting the PREVIOUS step's element (and its live
400
445
  // resize/scroll tracker) shouldn't linger once a new step's own panel is
401
446
  // up - only relevant when a Block's act() doesn't navigate away, since a
@@ -418,16 +463,23 @@ async function renderBeforeStep(page, info) {
418
463
  const panel = document.createElement("div");
419
464
  panel.id = "wg-panel";
420
465
  const pct = Math.round((info.index / info.total) * 100);
466
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
421
467
  const modulesHtml = info.allNames
422
468
  .map((name, idx) => {
423
469
  const cls = idx < info.index ? "wg-mod-done" : idx === info.index ? "wg-mod-current" : "wg-mod-upcoming";
424
- return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
470
+ const desc = info.allDescriptions && info.allDescriptions[idx];
471
+ const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
472
+ return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
425
473
  })
426
474
  .join("");
475
+ const narrationHtml = info.description
476
+ ? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
477
+ : "";
427
478
  let html =
428
479
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
429
480
  "<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
430
- "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>";
481
+ "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
482
+ narrationHtml;
431
483
  if (info.keys.length === 0) {
432
484
  html += "<div class=\\"wg-key\\">(no MemKeys required)</div>";
433
485
  }
@@ -458,46 +510,66 @@ async function renderBeforeStep(page, info) {
458
510
  }
459
511
 
460
512
  async function renderAfterStep(page, info) {
461
- await installOverlay(page);
462
- // Tracks the highlighted element live (recomputes on resize/scroll)
463
- // instead of a one-time Node-side boundingBox() snapshot - a resized
464
- // window used to leave the ring frozen at its stale old position.
465
- // Only the first recovered selector is drawn (one ring); best-effort - a
466
- // selector that never resolves just leaves the ring hidden.
513
+ await installOverlay(page, info.title);
514
+ // Clear any tracker from a previous highlight before cycling through this
515
+ // step's own.
467
516
  await page
468
- .evaluate((highlights) => {
517
+ .evaluate(() => {
469
518
  if (window.__wgRingTrack) {
470
519
  window.removeEventListener("resize", window.__wgRingTrack);
471
520
  window.removeEventListener("scroll", window.__wgRingTrack, true);
472
521
  window.__wgRingTrack = null;
473
522
  }
474
- const ring = document.getElementById("wg-ring");
475
- if (!ring) return;
476
- const h = highlights[0];
477
- if (!h) {
478
- ring.style.opacity = "0";
479
- return;
480
- }
481
- const reposition = () => {
482
- const el = document.querySelector(h.selector);
483
- if (!el) {
484
- ring.style.opacity = "0";
485
- return;
486
- }
487
- const box = el.getBoundingClientRect();
488
- ring.style.left = box.x - 6 + "px";
489
- ring.style.top = box.y - 6 + "px";
490
- ring.style.width = box.width + 12 + "px";
491
- ring.style.height = box.height + 12 + "px";
492
- ring.setAttribute("data-label", h.label);
493
- ring.style.opacity = "1";
494
- };
495
- reposition();
496
- window.__wgRingTrack = reposition;
497
- window.addEventListener("resize", reposition);
498
- window.addEventListener("scroll", reposition, true);
499
- }, info.highlights)
523
+ })
500
524
  .catch(() => {});
525
+ const highlights = info.highlights || [];
526
+ // Cycle through EVERY declared/recovered highlight in order, each shown
527
+ // long enough to actually register - "it highlights something [...] then
528
+ // it highlights something [else] and next," not just the first one.
529
+ for (let i = 0; i < highlights.length; i++) {
530
+ const h = highlights[i];
531
+ try {
532
+ const box = await page.locator(h.selector).first().boundingBox();
533
+ if (box) {
534
+ await showRing(page, box, h.label);
535
+ await new Promise((res) => setTimeout(res, i === highlights.length - 1 ? 200 : 900));
536
+ }
537
+ } catch {
538
+ // best-effort - a selector that doesn't resolve just gets skipped
539
+ }
540
+ }
541
+ if (highlights.length > 0) {
542
+ // The LAST highlight is the one that stays lit while the human reads
543
+ // the after-step panel - give THAT one live resize/scroll tracking,
544
+ // same as before.
545
+ const last = highlights[highlights.length - 1];
546
+ await page
547
+ .evaluate((h) => {
548
+ const ring = document.getElementById("wg-ring");
549
+ if (!ring) return;
550
+ const reposition = () => {
551
+ const el = document.querySelector(h.selector);
552
+ if (!el) {
553
+ ring.style.opacity = "0";
554
+ return;
555
+ }
556
+ const box = el.getBoundingClientRect();
557
+ ring.style.left = box.x - 6 + "px";
558
+ ring.style.top = box.y - 6 + "px";
559
+ ring.style.width = box.width + 12 + "px";
560
+ ring.style.height = box.height + 12 + "px";
561
+ ring.setAttribute("data-label", h.label);
562
+ ring.style.opacity = "1";
563
+ };
564
+ reposition();
565
+ window.__wgRingTrack = reposition;
566
+ window.addEventListener("resize", reposition);
567
+ window.addEventListener("scroll", reposition, true);
568
+ }, last)
569
+ .catch(() => {});
570
+ } else {
571
+ await hideRing(page);
572
+ }
501
573
  await page
502
574
  .evaluate((info) => {
503
575
  const old = document.getElementById("wg-panel");
@@ -509,10 +581,13 @@ async function renderAfterStep(page, info) {
509
581
  ? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
510
582
  : "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " done";
511
583
  const buttonLabel = info.isLast ? "Finish" : "Next \\u25B6";
584
+ const escA = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
512
585
  const modulesHtml = info.allNames
513
586
  .map((name, idx) => {
514
587
  const cls = idx <= info.index ? "wg-mod-done" : "wg-mod-upcoming";
515
- return "<span class=\\"wg-mod " + cls + "\\">" + name + "</span>";
588
+ const desc = info.allDescriptions && info.allDescriptions[idx];
589
+ const titleAttr = desc ? " title=\\"" + escA(desc) + "\\"" : "";
590
+ return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
516
591
  })
517
592
  .join("");
518
593
  // QA-friendly by default ("LoginSuccess" -> "Login Success") - raw
@@ -558,14 +633,37 @@ async function renderAfterStep(page, info) {
558
633
  .catch(() => {});
559
634
  }
560
635
 
636
+ /**
637
+ * A Block's own explicit instruction.highlights, if it declared any -
638
+ * arbitrary, author-controlled highlight points, independent of verify
639
+ * ("remember this ID" isn't a pass/fail check). Takes priority over
640
+ * anything recovered from verify traits below.
641
+ */
642
+ function resolveDeclaredHighlights(block, resultTag) {
643
+ let highlights = block.instruction && block.instruction.highlights;
644
+ if (typeof highlights === "function") {
645
+ try {
646
+ highlights = highlights({ __state: resultTag });
647
+ } catch {
648
+ highlights = [];
649
+ }
650
+ }
651
+ if (!Array.isArray(highlights)) return [];
652
+ return highlights.filter((h) => h && typeof h.selector === "string" && typeof h.label === "string");
653
+ }
654
+
561
655
  /**
562
656
  * Recovers a DOM selector from a built-in Trait's own .name string -
563
657
  * visible(sel) / text-equals(sel, "...") - since Trait.check itself is
564
658
  * an opaque closure with no selector field of its own. Best-effort only: a
565
659
  * hand-written bespoke Trait, or url-matches(...) (no DOM target), yields
566
660
  * nothing to highlight, which is fine - the panel still shows the result.
661
+ * Only used as a FALLBACK when the Block declared no explicit
662
+ * instruction.highlights of its own - see resolveDeclaredHighlights.
567
663
  */
568
664
  function extractVerifyHighlights(block, resultTag) {
665
+ const declared = resolveDeclaredHighlights(block, resultTag);
666
+ if (declared.length > 0) return declared;
569
667
  let verify = block.instruction && block.instruction.verify;
570
668
  if (typeof verify === "function") {
571
669
  try {
@@ -747,14 +845,14 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
747
845
  // helpers some Blocks use for their own pacing are NOT Playwright calls
748
846
  // at all and can't be touched this way - a real, disclosed limit, not
749
847
  // silently ignored.
750
- // Conservative on purpose: this exact codebase has documented real
751
- // flakiness around slow-but-legitimate hydration waits (see
752
- // login.block.ts's own waitForActionable comment). A too-aggressive cap
753
- // would trade "one annoying 5s dead wait" for "logins that sometimes
754
- // fail outright" - worse. 3000ms still meaningfully shortens the
755
- // "waiting for something that will never happen" case without giving a
756
- // real, slow-but-genuine wait much less room than before.
757
- const WAIT_CAP_MS = 3000;
848
+ // 3000ms was the first, deliberately conservative pass (this codebase
849
+ // has documented real flakiness around slow-but-legitimate hydration
850
+ // waits, so a too-aggressive cap risks trading "one annoying dead wait"
851
+ // for "logins that sometimes fail outright"). Verified empirically
852
+ // (3 consecutive real login runs, no failures) that those legitimate
853
+ // waits actually resolve in well under a second in practice - the cap
854
+ // was never close to touching them - so it's safe to tighten further.
855
+ const WAIT_CAP_MS = 1500;
758
856
  if (!proto.__wgWaitForPatched) {
759
857
  proto.__wgWaitForPatched = true;
760
858
  const originalWaitFor = proto.waitFor;
@@ -773,7 +871,7 @@ function instrumentInteractionHighlighting(page, mem, slowMo) {
773
871
  }
774
872
  }
775
873
 
776
- async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo) {
874
+ async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title) {
777
875
  instrumentInteractionHighlighting(page, mem, slowMo);
778
876
  // Some real Blocks (e.g. zsign-all's login.block.ts) call
779
877
  // page.setViewportSize({ width: 1280, height: 720 }) inside their own
@@ -803,6 +901,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
803
901
  const autoplayMs = process.env.WAYGRAPH_AUTOPLAY_MS ? Number(process.env.WAYGRAPH_AUTOPLAY_MS) : 1800;
804
902
  const gate = () => (autoplay ? new Promise((res) => setTimeout(() => res({}), autoplayMs)) : waitForNext());
805
903
  const allNames = resolved.map((r) => r.block.name);
904
+ const allDescriptions = resolved.map((r) => r.block.description || "");
806
905
 
807
906
  let result;
808
907
  for (let i = 0; i < resolved.length; i++) {
@@ -817,7 +916,17 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
817
916
  }
818
917
  return { name: k.name, value };
819
918
  });
820
- await renderBeforeStep(page, { index: i, total: resolved.length, blockName: r.block.name, keys, allNames, autoplay });
919
+ await renderBeforeStep(page, {
920
+ index: i,
921
+ total: resolved.length,
922
+ blockName: r.block.name,
923
+ description: r.block.description || "",
924
+ keys,
925
+ allNames,
926
+ allDescriptions,
927
+ autoplay,
928
+ title,
929
+ });
821
930
  const edits = await gate();
822
931
  for (const k of requires) {
823
932
  if (edits[k.name] !== undefined) {
@@ -845,7 +954,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
845
954
  highlights,
846
955
  isLast: i === resolved.length - 1,
847
956
  allNames,
957
+ allDescriptions,
848
958
  autoplay,
959
+ title,
849
960
  });
850
961
  await gate();
851
962
  }
@@ -901,6 +1012,7 @@ async function main() {
901
1012
  ? 350
902
1013
  : undefined;
903
1014
  const baseURL = process.env.WAYGRAPH_BASE_URL;
1015
+ const title = process.env.WAYGRAPH_TITLE;
904
1016
  const engine = new Engine({ headless: !headed, slowMo });
905
1017
  let result;
906
1018
  if (step || baseURL) {
@@ -926,7 +1038,7 @@ async function main() {
926
1038
  if (baseURL) {
927
1039
  await page.goto(baseURL).catch(() => {});
928
1040
  }
929
- result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo);
1041
+ result = await runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title);
930
1042
  } else {
931
1043
  const blocks = resolved.map((r) => r.block);
932
1044
  const chained = blocks.reduce((a, b) => connect(a, b));
@@ -1061,6 +1173,11 @@ Usage:
1061
1173
  just confirmed, then "Next" before moving on.
1062
1174
  Implies headed - stepping through headless defeats
1063
1175
  the point.
1176
+ WAYGRAPH_AUTOPLAY=1 step mode only - hands-off, advances on a
1177
+ timer instead of waiting for clicks
1178
+ WAYGRAPH_AUTOPLAY_MS=ms delay between auto-advances (default 1800)
1179
+ WAYGRAPH_TITLE="..." step mode only - a persistent top-right
1180
+ banner naming what this whole run is about
1064
1181
 
1065
1182
  "project" defaults to the current directory.
1066
1183
  `);
package/dist/engine.d.ts CHANGED
@@ -299,6 +299,7 @@ export declare function branch<In extends Checkpoint<string>, Out extends Checkp
299
299
  */
300
300
  export declare function defineBlock<In extends Checkpoint<string>, Out extends Checkpoint<string>>(base: {
301
301
  name: string;
302
+ description?: Block<In, Out>["description"];
302
303
  instruction: Instruction<In, Out, any>;
303
304
  next?: Block<In, Out>["next"];
304
305
  requires?: Block<In, Out>["requires"];
package/dist/engine.js CHANGED
@@ -284,6 +284,7 @@ function stripMethods(block) {
284
284
  return {
285
285
  name: block.name,
286
286
  instruction: block.instruction,
287
+ ...(block.description ? { description: block.description } : {}),
287
288
  ...(block.next ? { next: block.next } : {}),
288
289
  ...(block.requires ? { requires: block.requires } : {}),
289
290
  ...(block.routes ? { routes: block.routes } : {}),
@@ -302,6 +303,7 @@ export function defineBlock(base) {
302
303
  const plain = {
303
304
  name: base.name,
304
305
  instruction: base.instruction,
306
+ ...(base.description ? { description: base.description } : {}),
305
307
  ...(base.next ? { next: base.next } : {}),
306
308
  ...(base.requires ? { requires: base.requires } : {}),
307
309
  ...(base.routes ? { routes: base.routes } : {}),
package/dist/types.d.ts CHANGED
@@ -51,6 +51,24 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
51
51
  * {@link Block.modVerify} for changing this from outside the Block's own file.
52
52
  */
53
53
  verify?: Trait[] | ((out: Out) => Trait[]);
54
+ /**
55
+ * Optional. Arbitrary highlight points for step-mode tooling to show, in
56
+ * order, after this Block resolves - independent of `verify`, for
57
+ * calling out something worth a human's attention regardless of whether
58
+ * it's also being asserted ("remember this signature ID" isn't a
59
+ * pass/fail check). A function form picks different highlights per
60
+ * resolved `Out`, same as `verify`. Purely additive UI narration - never
61
+ * consulted by `runGraph`/`connect`/anything that decides behavior.
62
+ * @example highlights: (out) => [{ selector: "#signature-id", label: "remember this ID" }]
63
+ */
64
+ highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
65
+ }
66
+ /** One arbitrary highlight point - see {@link Instruction.highlights}. */
67
+ export interface WaygraphHighlight {
68
+ /** CSS selector for the element to highlight. */
69
+ selector: string;
70
+ /** Caption shown next to the highlight ring. */
71
+ label: string;
54
72
  }
55
73
  /**
56
74
  * A Block wraps one instruction. Its Observed type is an internal detail, erased here.
@@ -71,6 +89,16 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
71
89
  export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<string>> {
72
90
  /** A short, stable label - shows up in every error this Block can produce ("trait X failed after Y"). */
73
91
  name: string;
92
+ /**
93
+ * A human-readable sentence describing what this Block actually tests or
94
+ * does - "the outsider case: a user outside a request's audience is
95
+ * denied its detail page," not a restatement of the code. Purely
96
+ * additive, optional narration for a human watching a run (step-mode
97
+ * tooling shows it before the Block runs) - never read by
98
+ * `runGraph`/`connect`/anything that decides behavior. A Block with none
99
+ * just shows its `name` instead.
100
+ */
101
+ description?: string;
74
102
  /** The act/observe/resolve/verify pipeline itself. See {@link Instruction}. */
75
103
  instruction: Instruction<In, Out, any>;
76
104
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "main": "./dist/index.js",