waygraph 0.5.0 → 0.6.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 +519 -71
- package/dist/engine.d.ts +29 -0
- package/dist/engine.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/templates/quickstart/src/blocks/actions/add-to-cart.action.block.ts +18 -0
- package/templates/quickstart/src/blocks/actions/checkout-info.action.block.ts +19 -0
- package/templates/quickstart/src/blocks/actions/finish-order.action.block.ts +18 -0
- package/templates/quickstart/src/blocks/actions/submit-login.action.block.ts +22 -0
- package/templates/quickstart/src/blocks/nav-login.block.ts +15 -0
- package/templates/quickstart/src/flows/quickstart.flow.ts +19 -0
- package/templates/quickstart/src/states/quickstart.states.ts +11 -0
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* "project" defaults to the current directory.
|
|
13
13
|
* A project is any directory containing *.flow.ts files (typically under src/flows/).
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
16
|
-
import { resolve, relative, join, basename } from "node:path";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
|
16
|
+
import { resolve, relative, join, basename, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
17
18
|
import { spawn } from "node:child_process";
|
|
18
19
|
import { MemPage } from "./mem-page.js";
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -401,7 +402,15 @@ const RING_CSS =
|
|
|
401
402
|
"max-width:min(92vw,640px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
|
|
402
403
|
"background:rgba(20,10,40,.94);color:#fff;border-radius:14px;" +
|
|
403
404
|
"padding:16px 20px;font:14px/1.4 system-ui,sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.35);" +
|
|
404
|
-
|
|
405
|
+
// .06s was tuned back when the panel was removed and recreated on
|
|
406
|
+
// EVERY step - fast was the only way to avoid feeling laggy. Now that
|
|
407
|
+
// routine per-step updates reuse the same element (no fade at all - see
|
|
408
|
+
// the panel-reuse fix), this only ever fires for a genuinely fresh DOM
|
|
409
|
+
// (first load, or a real navigation - which is exactly what an episode
|
|
410
|
+
// boundary is). Slower and smoother reads as a real, comfortable
|
|
411
|
+
// transition instead of an instant pop - "eyes friendly," not laggy,
|
|
412
|
+
// since it no longer costs anything on the common case.
|
|
413
|
+
"opacity:0;transition:opacity .35s ease;}" +
|
|
405
414
|
"#wg-panel.wg-in{opacity:1;}" +
|
|
406
415
|
"#wg-panel .wg-auto{margin-top:10px;font:600 13px system-ui,sans-serif;color:#c9a6ff;}" +
|
|
407
416
|
"#wg-panel .wg-autoplay-row{margin-top:8px;}" +
|
|
@@ -420,6 +429,32 @@ const RING_CSS =
|
|
|
420
429
|
"#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
|
|
421
430
|
"#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
|
|
422
431
|
"#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
|
|
432
|
+
// A real tab bar for episodes - the currently-active episode reads as
|
|
433
|
+
// active (filled underline, full-brightness text), every other episode
|
|
434
|
+
// reads as inactive (dimmed, no underline) - Dan's own ask: "if episode
|
|
435
|
+
// 1 is active, the episode 2 tab is inactive." Only rendered when the
|
|
436
|
+
// chain spec actually named real Flows (chainFlow tags each via
|
|
437
|
+
// BlockInfo - same gate the "Episode N:" heading already uses); an
|
|
438
|
+
// ad hoc block chain with no episodes shows no tab bar at all.
|
|
439
|
+
"#wg-episodes{display:flex;gap:4px;margin:0 0 10px;border-bottom:1px solid #3a2a60;}" +
|
|
440
|
+
"#wg-episodes .wg-ep-tab{padding:6px 14px 8px;font:600 12px system-ui,sans-serif;" +
|
|
441
|
+
"border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;}" +
|
|
442
|
+
"#wg-episodes .wg-ep-current{color:#fff;border-bottom-color:#7C3AED;}" +
|
|
443
|
+
"#wg-episodes .wg-ep-done{color:#9a7ad1;}" +
|
|
444
|
+
"#wg-episodes .wg-ep-upcoming{color:#5a4a80;}" +
|
|
445
|
+
// A one-time, gentle signal for "you just entered this episode" - Dan:
|
|
446
|
+
// "i want a clear ui eyes friendly to remind me that i am on next
|
|
447
|
+
// episode." A slow outward glow, not an opacity blink/strobe (that's
|
|
448
|
+
// exactly the kind of flashing that already caused the original
|
|
449
|
+
// complaint) - plays once (no loop), 1.1s, only on the FIRST block of a
|
|
450
|
+
// newly-entered episode, not on every step within it.
|
|
451
|
+
"@keyframes wg-ep-enter{0%{box-shadow:0 0 0 0 rgba(124,58,237,.55);}" +
|
|
452
|
+
"100%{box-shadow:0 0 0 10px rgba(124,58,237,0);}}" +
|
|
453
|
+
"#wg-episodes .wg-ep-entered{border-radius:6px;animation:wg-ep-enter 1.1s ease-out;}" +
|
|
454
|
+
// The block breadcrumb below it is scoped to the CURRENT episode's own
|
|
455
|
+
// blocks only, not the whole chain - "in episode 2 it shows 2nd to the
|
|
456
|
+
// last... it should start from the first block" (Dan). An ad hoc block
|
|
457
|
+
// chain with no episodes still shows the whole chain here, unchanged.
|
|
423
458
|
"#wg-modules{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;}" +
|
|
424
459
|
"#wg-modules .wg-mod{padding:3px 9px;border-radius:6px;font:600 11px system-ui,sans-serif;}" +
|
|
425
460
|
"#wg-modules .wg-mod-done{background:#2a1650;color:#9a7ad1;}" +
|
|
@@ -435,12 +470,23 @@ const RING_CSS =
|
|
|
435
470
|
"#wg-banner .wg-banner-tag{display:block;font-size:10px;font-weight:700;color:#c9a6ff;" +
|
|
436
471
|
"letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;}" +
|
|
437
472
|
"#wg-panel .wg-key{margin:8px 0;}" +
|
|
473
|
+
// A live, human-readable preview of what's about to be written for this
|
|
474
|
+
// MemKey - Dan: "i want pretty to exist in memkeys too... it shows what
|
|
475
|
+
// input its gonig to be written." Same global Pretty/JSON preference the
|
|
476
|
+
// result display already has (window.__wgPretty) - hidden entirely in
|
|
477
|
+
// JSON mode, since the raw textarea already speaks for itself there.
|
|
478
|
+
"#wg-panel .wg-key-pretty{margin-top:4px;font:12px/1.5 system-ui,sans-serif;" +
|
|
479
|
+
"background:#0f0620;border-radius:6px;padding:6px 8px;}" +
|
|
480
|
+
"#wg-panel .wg-key-pretty-row{color:#f0e8ff;}" +
|
|
481
|
+
"#wg-panel .wg-key-pretty-label{color:#9a7ad1;font-weight:600;}" +
|
|
438
482
|
"#wg-panel label{display:block;font-size:12px;color:#d8c8ff;margin-bottom:3px;}" +
|
|
439
483
|
"#wg-panel textarea{width:100%;box-sizing:border-box;background:#0f0620;color:#fff;" +
|
|
440
484
|
"border:1px solid #4b2a80;border-radius:8px;padding:6px 8px;font:12px/1.3 monospace;resize:vertical;}" +
|
|
441
485
|
"#wg-panel button{margin-top:10px;background:#7C3AED;color:#fff;border:none;border-radius:8px;" +
|
|
442
486
|
"padding:8px 16px;font:600 13px system-ui,sans-serif;cursor:pointer;}" +
|
|
443
487
|
"#wg-panel button:hover{background:#6b2fd6;}" +
|
|
488
|
+
"#wg-panel button:disabled{background:#4b2a80;cursor:default;opacity:.7;}" +
|
|
489
|
+
"#wg-panel textarea:disabled{opacity:.6;}" +
|
|
444
490
|
"#wg-panel .wg-result{font:12px/1.4 monospace;background:#0f0620;border-radius:8px;padding:8px;" +
|
|
445
491
|
"margin:8px 0;white-space:pre-wrap;}" +
|
|
446
492
|
"#wg-panel .wg-result-pretty{font:600 14px/1.4 system-ui,sans-serif;}" +
|
|
@@ -452,8 +498,15 @@ const RING_CSS =
|
|
|
452
498
|
"#wg-panel .wg-error-heading{color:#ff8fa0;}" +
|
|
453
499
|
"#wg-panel .wg-error-msg{font:12px/1.5 monospace;background:#2a0f16;color:#ffc7cf;" +
|
|
454
500
|
"border-radius:8px;padding:10px;margin:0 0 12px;white-space:pre-wrap;max-height:200px;overflow-y:auto;}" +
|
|
455
|
-
"#wg-panel .wg-error-
|
|
456
|
-
"#wg-panel .wg-error-stop
|
|
501
|
+
"#wg-panel .wg-error-actions{display:flex;gap:8px;}" +
|
|
502
|
+
"#wg-panel .wg-error-stop{background:#e0475c;margin-top:0;}" +
|
|
503
|
+
"#wg-panel .wg-error-stop:hover{background:#c33a4c;}" +
|
|
504
|
+
"#wg-panel .wg-error-retry{background:#2a9d6f;margin-top:0;}" +
|
|
505
|
+
"#wg-panel .wg-error-retry:hover{background:#22855e;}" +
|
|
506
|
+
"#wg-panel.wg-expected{border:1.5px solid #e0a53e;}" +
|
|
507
|
+
"#wg-panel .wg-expected-heading{color:#ffcf7a;}" +
|
|
508
|
+
"#wg-panel .wg-expected-reason{font:600 12.5px/1.5 system-ui,sans-serif;background:#2a2410;" +
|
|
509
|
+
"color:#ffe6ae;border-radius:8px;padding:10px;margin:0 0 8px;}";
|
|
457
510
|
|
|
458
511
|
// Purple dot favicon (matches the overlay's own theme color) - the tab-bar
|
|
459
512
|
// signal that "this Chromium window is a waygraph run," even at a glance
|
|
@@ -668,15 +721,51 @@ async function renderBeforeStep(page, info) {
|
|
|
668
721
|
.catch(() => {});
|
|
669
722
|
await page
|
|
670
723
|
.evaluate((info) => {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
724
|
+
// Reused in place, not removed + recreated, every step - the old
|
|
725
|
+
// remove()-then-fade-back-in cycle was a real flash on every single
|
|
726
|
+
// transition (Dan: "the appear and disappear... hurts eyes and
|
|
727
|
+
// dizzy"). Updating one persistent element's content has nothing to
|
|
728
|
+
// flash - it only ever fades in ONCE, the first time this page
|
|
729
|
+
// genuinely has no panel yet (a real navigation wiped the whole
|
|
730
|
+
// document, or this is the very first step).
|
|
731
|
+
let panel = document.getElementById("wg-panel");
|
|
732
|
+
const isNewPanel = !panel;
|
|
733
|
+
if (!panel) {
|
|
734
|
+
panel = document.createElement("div");
|
|
735
|
+
panel.id = "wg-panel";
|
|
736
|
+
}
|
|
675
737
|
const pct = Math.round((info.index / info.total) * 100);
|
|
676
738
|
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
739
|
+
// A real tab bar, not a single text line - every episode this chain
|
|
740
|
+
// touches, the current one reading as active (Dan: "if episode 1 is
|
|
741
|
+
// active, the episode 2 tab is inactive"). Absent entirely for an ad
|
|
742
|
+
// hoc block chain with no named Flows.
|
|
743
|
+
const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
|
|
744
|
+
? "<div id=\\"wg-episodes\\">" +
|
|
745
|
+
info.allEpisodes
|
|
746
|
+
.map((e) => {
|
|
747
|
+
const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
|
|
748
|
+
: e.episodeNumber === info.episodeNumber ? "wg-ep-current"
|
|
749
|
+
: "wg-ep-upcoming";
|
|
750
|
+
// The gentle one-time glow only plays on the tab actually
|
|
751
|
+
// being entered right now, not on every render of it.
|
|
752
|
+
const enteredCls = info.justEnteredEpisode && e.episodeNumber === info.episodeNumber
|
|
753
|
+
? " wg-ep-entered"
|
|
754
|
+
: "";
|
|
755
|
+
return "<span class=\\"wg-ep-tab " + cls + enteredCls + "\\">Episode " + e.episodeNumber + ": " +
|
|
756
|
+
esc(e.episodeTitle || "") + "</span>";
|
|
757
|
+
})
|
|
758
|
+
.join("") +
|
|
759
|
+
"</div>"
|
|
760
|
+
: "";
|
|
761
|
+
// Scoped to THIS episode's own blocks (info.allNames is already the
|
|
762
|
+
// episode-local subset the caller computed) - moduleIndex is this
|
|
763
|
+
// step's position WITHIN that subset, not the whole chain's index -
|
|
764
|
+
// "in episode 2 it shows 2nd to the last... it should start from the
|
|
765
|
+
// first block" (Dan).
|
|
677
766
|
const modulesHtml = info.allNames
|
|
678
767
|
.map((name, idx) => {
|
|
679
|
-
const cls = idx < info.
|
|
768
|
+
const cls = idx < info.moduleIndex ? "wg-mod-done" : idx === info.moduleIndex ? "wg-mod-current" : "wg-mod-upcoming";
|
|
680
769
|
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
681
770
|
const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
|
|
682
771
|
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
@@ -685,13 +774,10 @@ async function renderBeforeStep(page, info) {
|
|
|
685
774
|
const narrationHtml = info.description
|
|
686
775
|
? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
|
|
687
776
|
: "";
|
|
688
|
-
const episodeHtml = info.episodeNumber
|
|
689
|
-
? "<div class=\\"wg-episode\\">Episode " + info.episodeNumber + ": " + esc(info.episodeTitle || "") + "</div>"
|
|
690
|
-
: "";
|
|
691
777
|
let html =
|
|
692
778
|
"<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
|
|
779
|
+
episodesHtml +
|
|
693
780
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
694
|
-
episodeHtml +
|
|
695
781
|
"<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
|
|
696
782
|
narrationHtml;
|
|
697
783
|
if (info.keys.length === 0) {
|
|
@@ -701,7 +787,9 @@ async function renderBeforeStep(page, info) {
|
|
|
701
787
|
html +=
|
|
702
788
|
"<div class=\\"wg-key\\"><label>" + k.name + "</label>" +
|
|
703
789
|
"<textarea data-key=\\"" + k.name + "\\" rows=\\"2\\">" +
|
|
704
|
-
k.value.replace(/</g, "<") + "</textarea
|
|
790
|
+
k.value.replace(/</g, "<") + "</textarea>" +
|
|
791
|
+
"<div class=\\"wg-key-pretty\\" data-pretty-for=\\"" + esc(k.name) + "\\"></div>" +
|
|
792
|
+
"</div>";
|
|
705
793
|
}
|
|
706
794
|
let autoNow = false;
|
|
707
795
|
try {
|
|
@@ -718,8 +806,49 @@ async function renderBeforeStep(page, info) {
|
|
|
718
806
|
"<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
|
|
719
807
|
">Auto-advancing...</div>";
|
|
720
808
|
panel.innerHTML = html;
|
|
721
|
-
|
|
722
|
-
|
|
809
|
+
if (isNewPanel) {
|
|
810
|
+
document.documentElement.appendChild(panel);
|
|
811
|
+
requestAnimationFrame(() => panel.classList.add("wg-in"));
|
|
812
|
+
}
|
|
813
|
+
// Live, human-readable preview of what a MemKey's raw JSON will
|
|
814
|
+
// actually write - object fields become "Field: value" lines
|
|
815
|
+
// (camelCase split the same way state tags already are); a
|
|
816
|
+
// non-object payload (a plain string/number/array) is shown as-is.
|
|
817
|
+
// Hidden entirely in JSON mode - shares the SAME global toggle the
|
|
818
|
+
// result display's own Pretty/JSON buttons already set, not a
|
|
819
|
+
// second, separate preference.
|
|
820
|
+
const prettyMemValue = (raw) => {
|
|
821
|
+
let parsed;
|
|
822
|
+
try {
|
|
823
|
+
parsed = JSON.parse(raw);
|
|
824
|
+
} catch {
|
|
825
|
+
return "<em>(invalid JSON)</em>";
|
|
826
|
+
}
|
|
827
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
828
|
+
return esc(JSON.stringify(parsed));
|
|
829
|
+
}
|
|
830
|
+
return Object.entries(parsed)
|
|
831
|
+
.map(([field, value]) => {
|
|
832
|
+
const label = field.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
|
833
|
+
return "<div class=\\"wg-key-pretty-row\\"><span class=\\"wg-key-pretty-label\\">" +
|
|
834
|
+
esc(label) + ":</span> " + esc(String(value)) + "</div>";
|
|
835
|
+
})
|
|
836
|
+
.join("");
|
|
837
|
+
};
|
|
838
|
+
const applyKeyPretty = () => {
|
|
839
|
+
const prettyOn = window.__wgPretty !== false;
|
|
840
|
+
panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
|
|
841
|
+
const key = ta.getAttribute("data-key");
|
|
842
|
+
const previewEl = panel.querySelector(".wg-key-pretty[data-pretty-for=\\"" + key + "\\"]");
|
|
843
|
+
if (!previewEl) return;
|
|
844
|
+
previewEl.style.display = prettyOn ? "" : "none";
|
|
845
|
+
if (prettyOn) previewEl.innerHTML = prettyMemValue(ta.value);
|
|
846
|
+
});
|
|
847
|
+
};
|
|
848
|
+
applyKeyPretty();
|
|
849
|
+
panel.querySelectorAll("textarea[data-key]").forEach((ta) => {
|
|
850
|
+
ta.addEventListener("input", applyKeyPretty);
|
|
851
|
+
});
|
|
723
852
|
const runBtn = document.getElementById("wg-run");
|
|
724
853
|
if (runBtn) {
|
|
725
854
|
runBtn.addEventListener("click", () => {
|
|
@@ -810,10 +939,13 @@ async function renderAfterStep(page, info) {
|
|
|
810
939
|
}
|
|
811
940
|
await page
|
|
812
941
|
.evaluate((info) => {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
panel
|
|
942
|
+
// Reused in place - see renderBeforeStep's own comment on this.
|
|
943
|
+
let panel = document.getElementById("wg-panel");
|
|
944
|
+
const isNewPanel = !panel;
|
|
945
|
+
if (!panel) {
|
|
946
|
+
panel = document.createElement("div");
|
|
947
|
+
panel.id = "wg-panel";
|
|
948
|
+
}
|
|
817
949
|
const pct = Math.round(((info.index + 1) / info.total) * 100);
|
|
818
950
|
const heading = info.isLast
|
|
819
951
|
? "End of chain - " + info.total + " / " + info.total + " blocks covered - " + info.blockName + " done"
|
|
@@ -822,7 +954,7 @@ async function renderAfterStep(page, info) {
|
|
|
822
954
|
const escA = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
823
955
|
const modulesHtml = info.allNames
|
|
824
956
|
.map((name, idx) => {
|
|
825
|
-
const cls = idx <= info.
|
|
957
|
+
const cls = idx <= info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
|
|
826
958
|
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
827
959
|
const titleAttr = desc ? " title=\\"" + escA(desc) + "\\"" : "";
|
|
828
960
|
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
@@ -858,18 +990,30 @@ async function renderAfterStep(page, info) {
|
|
|
858
990
|
"><button id=\\"wg-run\\">" + buttonLabel + "</button></div>" +
|
|
859
991
|
"<div id=\\"wg-gate-auto\\" class=\\"wg-auto\\"" + (autoNow ? "" : " style=\\"display:none\\"") +
|
|
860
992
|
">Auto-advancing...</div>";
|
|
861
|
-
const
|
|
862
|
-
? "<div
|
|
993
|
+
const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
|
|
994
|
+
? "<div id=\\"wg-episodes\\">" +
|
|
995
|
+
info.allEpisodes
|
|
996
|
+
.map((e) => {
|
|
997
|
+
const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
|
|
998
|
+
: e.episodeNumber === info.episodeNumber ? "wg-ep-current"
|
|
999
|
+
: "wg-ep-upcoming";
|
|
1000
|
+
return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
|
|
1001
|
+
escA(e.episodeTitle || "") + "</span>";
|
|
1002
|
+
})
|
|
1003
|
+
.join("") +
|
|
1004
|
+
"</div>"
|
|
863
1005
|
: "";
|
|
864
1006
|
panel.innerHTML =
|
|
865
1007
|
"<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
|
|
1008
|
+
episodesHtml +
|
|
866
1009
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
867
|
-
episodeHtml +
|
|
868
1010
|
"<h3>" + heading + "</h3>" +
|
|
869
1011
|
resultHtml +
|
|
870
1012
|
gateHtml;
|
|
871
|
-
|
|
872
|
-
|
|
1013
|
+
if (isNewPanel) {
|
|
1014
|
+
document.documentElement.appendChild(panel);
|
|
1015
|
+
requestAnimationFrame(() => panel.classList.add("wg-in"));
|
|
1016
|
+
}
|
|
873
1017
|
panel.querySelectorAll(".wg-toggle-btn").forEach((btn) => {
|
|
874
1018
|
btn.addEventListener("click", () => {
|
|
875
1019
|
const wantPretty = btn.getAttribute("data-mode") === "pretty";
|
|
@@ -910,30 +1054,63 @@ async function renderStepError(page, info) {
|
|
|
910
1054
|
await installOverlay(page, info.title);
|
|
911
1055
|
await page
|
|
912
1056
|
.evaluate((info) => {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
panel
|
|
917
|
-
|
|
1057
|
+
// Reused in place - see renderBeforeStep's own comment on this.
|
|
1058
|
+
let panel = document.getElementById("wg-panel");
|
|
1059
|
+
const isNewPanel = !panel;
|
|
1060
|
+
if (!panel) {
|
|
1061
|
+
panel = document.createElement("div");
|
|
1062
|
+
panel.id = "wg-panel";
|
|
1063
|
+
}
|
|
1064
|
+
const isExpected = !!info.expectedFailureReason;
|
|
1065
|
+
const errClass = isExpected ? "wg-expected" : "wg-error";
|
|
1066
|
+
panel.className = isNewPanel ? errClass : errClass + " wg-in";
|
|
918
1067
|
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
1068
|
+
const episodesHtml = info.allEpisodes && info.allEpisodes.length > 0
|
|
1069
|
+
? "<div id=\\"wg-episodes\\">" +
|
|
1070
|
+
info.allEpisodes
|
|
1071
|
+
.map((e) => {
|
|
1072
|
+
const cls = e.episodeNumber < info.episodeNumber ? "wg-ep-done"
|
|
1073
|
+
: e.episodeNumber === info.episodeNumber ? "wg-ep-current"
|
|
1074
|
+
: "wg-ep-upcoming";
|
|
1075
|
+
return "<span class=\\"wg-ep-tab " + cls + "\\">Episode " + e.episodeNumber + ": " +
|
|
1076
|
+
esc(e.episodeTitle || "") + "</span>";
|
|
1077
|
+
})
|
|
1078
|
+
.join("") +
|
|
1079
|
+
"</div>"
|
|
1080
|
+
: "";
|
|
919
1081
|
const modulesHtml = info.allNames
|
|
920
1082
|
.map((name, idx) => {
|
|
921
|
-
const cls = idx === info.
|
|
1083
|
+
const cls = idx === info.moduleIndex ? "wg-mod-current" : idx < info.moduleIndex ? "wg-mod-done" : "wg-mod-upcoming";
|
|
922
1084
|
const desc = info.allDescriptions && info.allDescriptions[idx];
|
|
923
1085
|
const titleAttr = desc ? " title=\\"" + esc(desc) + "\\"" : "";
|
|
924
1086
|
return "<span class=\\"wg-mod " + cls + "\\"" + titleAttr + ">" + name + "</span>";
|
|
925
1087
|
})
|
|
926
1088
|
.join("");
|
|
1089
|
+
const retryLabel = info.episodeNumber !== undefined ? "Retry Episode " + info.episodeNumber : "Retry";
|
|
1090
|
+
const headingText = isExpected
|
|
1091
|
+
? "Expected outcome - Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " failed as intended"
|
|
1092
|
+
: "Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + " threw";
|
|
1093
|
+
const reasonHtml = isExpected
|
|
1094
|
+
? "<div class=\\"wg-expected-reason\\">" + esc(info.expectedFailureReason) + "</div>"
|
|
1095
|
+
: "";
|
|
927
1096
|
panel.innerHTML =
|
|
1097
|
+
episodesHtml +
|
|
928
1098
|
"<div id=\\"wg-modules\\">" + modulesHtml + "</div>" +
|
|
929
|
-
"<h3 class=\\"
|
|
930
|
-
|
|
1099
|
+
"<h3 class=\\"" + (isExpected ? "wg-expected-heading" : "wg-error-heading") + "\\">" + headingText + "</h3>" +
|
|
1100
|
+
reasonHtml +
|
|
931
1101
|
"<div class=\\"wg-error-msg\\">" + esc(info.message) + "</div>" +
|
|
932
|
-
"<
|
|
933
|
-
|
|
934
|
-
|
|
1102
|
+
"<div class=\\"wg-error-actions\\">" +
|
|
1103
|
+
"<button id=\\"wg-error-retry\\" class=\\"wg-error-retry\\">" + esc(retryLabel) + "</button>" +
|
|
1104
|
+
"<button id=\\"wg-run\\" class=\\"wg-error-stop\\">Stop</button>" +
|
|
1105
|
+
"</div>";
|
|
1106
|
+
if (isNewPanel) {
|
|
1107
|
+
document.documentElement.appendChild(panel);
|
|
1108
|
+
requestAnimationFrame(() => panel.classList.add("wg-in"));
|
|
1109
|
+
}
|
|
935
1110
|
const runBtn = document.getElementById("wg-run");
|
|
936
1111
|
if (runBtn) runBtn.addEventListener("click", () => window.__wgNext({}));
|
|
1112
|
+
const retryBtn = document.getElementById("wg-error-retry");
|
|
1113
|
+
if (retryBtn) retryBtn.addEventListener("click", () => window.__wgNext({ __wgRetry: "1" }));
|
|
937
1114
|
}, info)
|
|
938
1115
|
.catch(() => {});
|
|
939
1116
|
}
|
|
@@ -1006,6 +1183,35 @@ function extractVerifyHighlights(block, resultTag) {
|
|
|
1006
1183
|
* highlight then slowly input." One-time patch (idempotent - guarded so a
|
|
1007
1184
|
* multi-step chain doesn't re-wrap an already-wrapped fill).
|
|
1008
1185
|
*/
|
|
1186
|
+
/**
|
|
1187
|
+
* Called once the gate resolves - manual click OR autoplay timeout, both
|
|
1188
|
+
* covered from here rather than duplicating this in the in-page click
|
|
1189
|
+
* handler - right before the Block's own act() actually starts. Dan: "i
|
|
1190
|
+
* want the button, step is running thing. so i wont be able to interrupt
|
|
1191
|
+
* the playwright automation" - the "Run this step" button used to sit
|
|
1192
|
+
* there still looking clickable for the entire multi-second duration a
|
|
1193
|
+
* real act()/observe() takes, inviting a confusing extra click (harmless -
|
|
1194
|
+
* __wgNext no-ops once already consumed - but looked live when it wasn't).
|
|
1195
|
+
* Disables the button and mem-key textareas, and swaps whichever gate
|
|
1196
|
+
* text was showing (manual button or "Auto-advancing...") to "Running...".
|
|
1197
|
+
*/
|
|
1198
|
+
async function markStepRunning(page) {
|
|
1199
|
+
await page
|
|
1200
|
+
.evaluate(() => {
|
|
1201
|
+
const runBtn = document.getElementById("wg-run");
|
|
1202
|
+
if (runBtn) {
|
|
1203
|
+
runBtn.disabled = true;
|
|
1204
|
+
runBtn.textContent = "Running \\u25B6";
|
|
1205
|
+
}
|
|
1206
|
+
const autoEl = document.getElementById("wg-gate-auto");
|
|
1207
|
+
if (autoEl) autoEl.textContent = "Running...";
|
|
1208
|
+
document.querySelectorAll("#wg-panel textarea[data-key]").forEach((ta) => {
|
|
1209
|
+
ta.disabled = true;
|
|
1210
|
+
});
|
|
1211
|
+
})
|
|
1212
|
+
.catch(() => {});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1009
1215
|
async function showRing(page, box, label) {
|
|
1010
1216
|
await page
|
|
1011
1217
|
.evaluate(
|
|
@@ -1204,7 +1410,16 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
|
|
|
1204
1410
|
} catch {
|
|
1205
1411
|
// best-effort - the real click below still runs either way
|
|
1206
1412
|
}
|
|
1207
|
-
if (clickPoint)
|
|
1413
|
+
if (clickPoint) {
|
|
1414
|
+
await clickPulseAt(page, clickPoint.x, clickPoint.y);
|
|
1415
|
+
// clickPulseAt only triggers the CSS animation class - it doesn't
|
|
1416
|
+
// wait for it. Without a pause here, the real click (and any
|
|
1417
|
+
// resulting navigation/DOM change) fires while the ripple is still
|
|
1418
|
+
// mid-animation, sometimes cutting it off before it's even visible.
|
|
1419
|
+
// Wait out the same .5s the "@keyframes wg-pulse" rule uses, so the
|
|
1420
|
+
// mock click visually completes before the real one fires.
|
|
1421
|
+
await new Promise((res) => setTimeout(res, pacing.fast ? 0 : 500));
|
|
1422
|
+
}
|
|
1208
1423
|
const result = await originalClick.call(this, options);
|
|
1209
1424
|
await new Promise((res) => setTimeout(res, clickPostPop()));
|
|
1210
1425
|
await hideRing(page);
|
|
@@ -1275,6 +1490,28 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing) {
|
|
|
1275
1490
|
}
|
|
1276
1491
|
}
|
|
1277
1492
|
|
|
1493
|
+
/**
|
|
1494
|
+
* Clears cookies + storage and re-navigates to a known-fresh page. Shared by
|
|
1495
|
+
* the per-block session-reset check below and by the error panel's own
|
|
1496
|
+
* "Retry Episode" button - a retry needs the exact same clean slate a normal
|
|
1497
|
+
* episode boundary gets, not just re-running the failed block against
|
|
1498
|
+
* whatever broken/half-navigated state it left the page in.
|
|
1499
|
+
*/
|
|
1500
|
+
async function resetPageState(context, page, baseURL) {
|
|
1501
|
+
await context.clearCookies().catch(() => {});
|
|
1502
|
+
await page
|
|
1503
|
+
.evaluate(() => {
|
|
1504
|
+
try {
|
|
1505
|
+
localStorage.clear();
|
|
1506
|
+
sessionStorage.clear();
|
|
1507
|
+
} catch {
|
|
1508
|
+
/* storage blocked (e.g. about:blank) - nothing to clear anyway */
|
|
1509
|
+
}
|
|
1510
|
+
})
|
|
1511
|
+
.catch(() => {});
|
|
1512
|
+
await page.goto(baseURL || "about:blank").catch(() => {});
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1278
1515
|
async function runStepMode(engine, start, end, context, page, mem, resolved, slowMo, title, fastBlockNames, clearSession, baseURL) {
|
|
1279
1516
|
// A shared, mutable pacing knob the interaction patches read live (per
|
|
1280
1517
|
// call, not once at setup) - flipped per-block below so one block (e.g.
|
|
@@ -1351,10 +1588,46 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1351
1588
|
};
|
|
1352
1589
|
const allNames = resolved.map((r) => r.block.name);
|
|
1353
1590
|
const allDescriptions = resolved.map((r) => r.block.description || "");
|
|
1591
|
+
// Deduped, in-order list of every real episode this chain touches - for
|
|
1592
|
+
// the episode tab bar. Ad hoc block segments (no named Flow) carry no
|
|
1593
|
+
// episodeNumber and never appear here, same gate the "Episode N:"
|
|
1594
|
+
// heading already used.
|
|
1595
|
+
const allEpisodes = [];
|
|
1596
|
+
for (const r of resolved) {
|
|
1597
|
+
if (r.episodeNumber && !allEpisodes.some((e) => e.episodeNumber === r.episodeNumber)) {
|
|
1598
|
+
allEpisodes.push({ episodeNumber: r.episodeNumber, episodeTitle: r.episodeTitle });
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1354
1601
|
|
|
1355
1602
|
let result;
|
|
1356
1603
|
for (let i = 0; i < resolved.length; i++) {
|
|
1357
1604
|
const r = resolved[i];
|
|
1605
|
+
// The block breadcrumb is scoped to THIS step's own episode, not the
|
|
1606
|
+
// whole chain - within episode 2, step 1 should read as "1st of 2,"
|
|
1607
|
+
// not "6th of 7." Falls back to the whole chain when this block has no
|
|
1608
|
+
// episode (an ad hoc chain with no named Flows), unchanged from before.
|
|
1609
|
+
const episodeBlockIndices = r.episodeNumber
|
|
1610
|
+
? resolved.reduce((acc, x, idx) => {
|
|
1611
|
+
if (x.episodeNumber === r.episodeNumber) acc.push(idx);
|
|
1612
|
+
return acc;
|
|
1613
|
+
}, [])
|
|
1614
|
+
: resolved.map((_x, idx) => idx);
|
|
1615
|
+
const moduleNames = episodeBlockIndices.map((idx) => allNames[idx]);
|
|
1616
|
+
const moduleDescriptions = episodeBlockIndices.map((idx) => allDescriptions[idx]);
|
|
1617
|
+
const moduleIndex = episodeBlockIndices.indexOf(i);
|
|
1618
|
+
// Where a "Retry Episode" click (see the catch block below) rewinds to -
|
|
1619
|
+
// the current episode's own first block, or the whole chain's first
|
|
1620
|
+
// block when there's no real episode (episodeBlockIndices degrades to
|
|
1621
|
+
// the full resolved array in that case, so this is just 0).
|
|
1622
|
+
const episodeStartIndex = episodeBlockIndices[0];
|
|
1623
|
+
// True only on an episode's own first block - the one moment worth a
|
|
1624
|
+
// gentle "you're here now" signal, not every step inside it.
|
|
1625
|
+
const justEnteredEpisode = moduleIndex === 0 && r.episodeNumber !== undefined;
|
|
1626
|
+
// Seed THIS segment's own json payload now, not earlier - see the long
|
|
1627
|
+
// comment where seedMem closures are built, in main()'s resolution
|
|
1628
|
+
// loop. Must run before "keys" below reads mem.get() for the panel
|
|
1629
|
+
// display, and before this segment's own first block executes.
|
|
1630
|
+
if (r.seedMem) r.seedMem();
|
|
1358
1631
|
// Reset session state before this block if EITHER: the blanket
|
|
1359
1632
|
// WAYGRAPH_CLEAR_SESSION=1 override is set (a manual, always-on-after-
|
|
1360
1633
|
// the-first-block stopgap for ad hoc block chains with no real Flow
|
|
@@ -1373,22 +1646,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1373
1646
|
// look fresh again - clearing storage alone doesn't change what's
|
|
1374
1647
|
// still rendered on screen.
|
|
1375
1648
|
if ((clearSession || r.resetSession) && i > 0) {
|
|
1376
|
-
await context
|
|
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
|
-
}
|
|
1649
|
+
await resetPageState(context, page, baseURL);
|
|
1392
1650
|
}
|
|
1393
1651
|
pacing.fast = fastBlockNames.has(r.block.name);
|
|
1394
1652
|
const requires = r.block.requires ?? [];
|
|
@@ -1407,8 +1665,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1407
1665
|
blockName: r.block.name,
|
|
1408
1666
|
description: r.block.description || "",
|
|
1409
1667
|
keys,
|
|
1410
|
-
allNames,
|
|
1411
|
-
allDescriptions,
|
|
1668
|
+
allNames: moduleNames,
|
|
1669
|
+
allDescriptions: moduleDescriptions,
|
|
1670
|
+
moduleIndex,
|
|
1671
|
+
allEpisodes,
|
|
1672
|
+
justEnteredEpisode,
|
|
1412
1673
|
title,
|
|
1413
1674
|
episodeNumber: r.episodeNumber,
|
|
1414
1675
|
episodeTitle: r.episodeTitle,
|
|
@@ -1423,6 +1684,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1423
1684
|
}
|
|
1424
1685
|
}
|
|
1425
1686
|
}
|
|
1687
|
+
await markStepRunning(page);
|
|
1426
1688
|
const stepFlow = engine.defineFlow([start, r.block, end]);
|
|
1427
1689
|
// { closeOnFinish: false } makes Flow.run return { result, page }, not
|
|
1428
1690
|
// the plain checkpoint - destructure it, don't treat the wrapper as the
|
|
@@ -1441,11 +1703,24 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1441
1703
|
total: resolved.length,
|
|
1442
1704
|
blockName: r.block.name,
|
|
1443
1705
|
message: err && err.message ? err.message : String(err),
|
|
1444
|
-
allNames,
|
|
1445
|
-
allDescriptions,
|
|
1706
|
+
allNames: moduleNames,
|
|
1707
|
+
allDescriptions: moduleDescriptions,
|
|
1708
|
+
moduleIndex,
|
|
1709
|
+
allEpisodes,
|
|
1446
1710
|
title,
|
|
1711
|
+
episodeNumber: r.episodeNumber,
|
|
1712
|
+
episodeTitle: r.episodeTitle,
|
|
1713
|
+
expectedFailureReason: r.expectedFailureReason,
|
|
1447
1714
|
});
|
|
1448
|
-
await gate();
|
|
1715
|
+
const errEdits = await gate();
|
|
1716
|
+
if (errEdits && errEdits.__wgRetry) {
|
|
1717
|
+
// Same clean slate a normal episode boundary gets - not just
|
|
1718
|
+
// re-running the failed block against whatever broken/half-
|
|
1719
|
+
// navigated state it left the page in.
|
|
1720
|
+
await resetPageState(context, page, baseURL);
|
|
1721
|
+
i = episodeStartIndex - 1;
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1449
1724
|
throw err;
|
|
1450
1725
|
}
|
|
1451
1726
|
result = stepOutcome.result;
|
|
@@ -1458,8 +1733,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
|
|
|
1458
1733
|
resultTag: JSON.stringify(result),
|
|
1459
1734
|
highlights,
|
|
1460
1735
|
isLast: i === resolved.length - 1,
|
|
1461
|
-
allNames,
|
|
1462
|
-
allDescriptions,
|
|
1736
|
+
allNames: moduleNames,
|
|
1737
|
+
allDescriptions: moduleDescriptions,
|
|
1738
|
+
moduleIndex,
|
|
1739
|
+
allEpisodes,
|
|
1463
1740
|
title,
|
|
1464
1741
|
episodeNumber: r.episodeNumber,
|
|
1465
1742
|
episodeTitle: r.episodeTitle,
|
|
@@ -1484,7 +1761,11 @@ async function main() {
|
|
|
1484
1761
|
if (bareRef) {
|
|
1485
1762
|
const flow = await findFlow(projectDir, bareRef);
|
|
1486
1763
|
if (flow && typeof flow.blocks === "function") {
|
|
1487
|
-
resolved = flow.blocks().map((bi) => ({
|
|
1764
|
+
resolved = flow.blocks().map((bi) => ({
|
|
1765
|
+
block: bi.block,
|
|
1766
|
+
exportName: bi.name,
|
|
1767
|
+
expectedFailureReason: flow.expectedFailureReason,
|
|
1768
|
+
}));
|
|
1488
1769
|
console.log(
|
|
1489
1770
|
"waygraph: running existing flow \\"" + bareRef + "\\" - " +
|
|
1490
1771
|
resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
|
|
@@ -1517,37 +1798,62 @@ async function main() {
|
|
|
1517
1798
|
// "Episode 2" labels on things that were never episodes to begin with.
|
|
1518
1799
|
const flowMeta = [];
|
|
1519
1800
|
let episodeCounter = 0;
|
|
1801
|
+
// seedMem is a closure, NOT called here - seeding every segment's own
|
|
1802
|
+
// json payload eagerly, all upfront during resolution, was a real bug:
|
|
1803
|
+
// mem is one shared object, so when two segments require the SAME key
|
|
1804
|
+
// (e.g. two segments both requiring LoginCreds, each with a DIFFERENT
|
|
1805
|
+
// payload - "login as standard_user then login as locked_out_user"),
|
|
1806
|
+
// the LAST segment resolved silently overwrote the first's value
|
|
1807
|
+
// before either segment had even started running - so the FIRST
|
|
1808
|
+
// segment's own blocks would run against the SECOND segment's
|
|
1809
|
+
// credentials. Deferred to execution time instead (runStepMode calls
|
|
1810
|
+
// this exactly once, right as each segment's first block is reached -
|
|
1811
|
+
// see resolved[i].seedMem below), same moment session-reset already
|
|
1812
|
+
// happens for the same reason.
|
|
1520
1813
|
for (const seg of segments) {
|
|
1521
1814
|
const flow = await findFlow(projectDir, seg.ref);
|
|
1522
1815
|
if (flow && typeof flow.blocks === "function") {
|
|
1523
|
-
seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref);
|
|
1524
1816
|
flows.push(flow);
|
|
1525
1817
|
episodeCounter += 1;
|
|
1526
|
-
flowMeta.push({
|
|
1818
|
+
flowMeta.push({
|
|
1819
|
+
episodeNumber: episodeCounter,
|
|
1820
|
+
episodeTitle: flow.title || seg.ref,
|
|
1821
|
+
expectedFailureReason: flow.expectedFailureReason,
|
|
1822
|
+
seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref),
|
|
1823
|
+
});
|
|
1527
1824
|
} else {
|
|
1528
1825
|
const r = await findBlock(projectDir, seg.ref);
|
|
1529
|
-
seedMemForBlock(mem, r, seg.json);
|
|
1530
1826
|
flows.push(wrapEngine.defineFlow([start, r.block, end]));
|
|
1531
|
-
flowMeta.push(
|
|
1827
|
+
flowMeta.push({
|
|
1828
|
+
episodeNumber: undefined,
|
|
1829
|
+
episodeTitle: undefined,
|
|
1830
|
+
seedMem: () => seedMemForBlock(mem, r, seg.json),
|
|
1831
|
+
});
|
|
1532
1832
|
}
|
|
1533
1833
|
}
|
|
1534
1834
|
const combinedBlocks = chainFlow(...flows).blocks();
|
|
1535
1835
|
let fi = 0;
|
|
1536
1836
|
let remainingInFlow = flows[0].blocks().length;
|
|
1837
|
+
let isFirstOfSegment = true;
|
|
1537
1838
|
resolved = combinedBlocks.map((bi) => {
|
|
1538
1839
|
while (remainingInFlow === 0) {
|
|
1539
1840
|
fi += 1;
|
|
1540
1841
|
remainingInFlow = flows[fi].blocks().length;
|
|
1842
|
+
isFirstOfSegment = true;
|
|
1541
1843
|
}
|
|
1542
|
-
remainingInFlow -= 1;
|
|
1543
1844
|
const meta = flowMeta[fi];
|
|
1544
|
-
|
|
1845
|
+
const entry = {
|
|
1545
1846
|
block: bi.block,
|
|
1546
1847
|
exportName: bi.name,
|
|
1547
1848
|
resetSession: bi.resetSessionBefore === true,
|
|
1548
|
-
episodeNumber: meta
|
|
1549
|
-
episodeTitle: meta
|
|
1849
|
+
episodeNumber: meta.episodeNumber,
|
|
1850
|
+
episodeTitle: meta.episodeTitle,
|
|
1851
|
+
expectedFailureReason: meta.expectedFailureReason,
|
|
1852
|
+
seedMem: isFirstOfSegment ? meta.seedMem : undefined,
|
|
1550
1853
|
};
|
|
1854
|
+
remainingInFlow -= 1;
|
|
1855
|
+
isFirstOfSegment = false;
|
|
1856
|
+
return entry;
|
|
1551
1857
|
});
|
|
1552
1858
|
console.log(
|
|
1553
1859
|
"waygraph: chaining " + resolved.map((r) => r.block.name).join(" -> ") +
|
|
@@ -1586,6 +1892,19 @@ async function main() {
|
|
|
1586
1892
|
const clearSession = process.env.WAYGRAPH_CLEAR_SESSION === "1";
|
|
1587
1893
|
const engine = new Engine({ headless: !headed, slowMo });
|
|
1588
1894
|
let result;
|
|
1895
|
+
// Non-interactive execution (no --step) runs the whole chain as one
|
|
1896
|
+
// composed flow with no per-block loop of its own to defer seeding
|
|
1897
|
+
// into - it can't support two segments needing DIFFERENT values for the
|
|
1898
|
+
// SAME mem key (e.g. two segments both requiring LoginCreds, each with
|
|
1899
|
+
// its own payload). Fall back to seeding every segment eagerly, upfront -
|
|
1900
|
+
// the same limitation "chain" always had; only --step's own runStepMode
|
|
1901
|
+
// loop actually needs (and correctly supports, via resolved[i].seedMem)
|
|
1902
|
+
// per-segment differentiated values.
|
|
1903
|
+
if (!step) {
|
|
1904
|
+
for (const r of resolved) {
|
|
1905
|
+
if (r.seedMem) r.seedMem();
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1589
1908
|
if (step || baseURL) {
|
|
1590
1909
|
const { chromium } = await import("playwright");
|
|
1591
1910
|
// --start-maximized (step mode only): viewport: null alone only made
|
|
@@ -1667,6 +1986,111 @@ async function runChain(projectDir, spec) {
|
|
|
1667
1986
|
rmSync(scriptPath, { force: true });
|
|
1668
1987
|
}
|
|
1669
1988
|
}
|
|
1989
|
+
// ---------------------------------------------------------------------------
|
|
1990
|
+
// `try` - a bundled, self-contained showcase. No .flow.ts to write, no
|
|
1991
|
+
// scenario to design - copies a small real Flow (nav/action Blocks, Trait
|
|
1992
|
+
// verify, narrate()) into the target project and runs it in step mode
|
|
1993
|
+
// against a public demo site, so a curious dev sees the real overlay/panel
|
|
1994
|
+
// experience in under a minute, then gets pointed at the plain waygraph
|
|
1995
|
+
// source that produced it.
|
|
1996
|
+
// ---------------------------------------------------------------------------
|
|
1997
|
+
/** This package's own installed root - dist/cli.js -> .. */
|
|
1998
|
+
function packageRoot() {
|
|
1999
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Runs a throwaway probe script rooted at `projectDir` - the exact same
|
|
2003
|
+
* directory the real demo files get copied into, so it resolves "waygraph"
|
|
2004
|
+
* and "@playwright/test" exactly as they will - and actually launches (then
|
|
2005
|
+
* immediately closes) chromium, a real check rather than a guess at whether
|
|
2006
|
+
* the browser's installed. Returns null when everything's ready, or a
|
|
2007
|
+
* friendly, actionable message naming exactly what to install when it
|
|
2008
|
+
* isn't. Never leaves the probe script behind.
|
|
2009
|
+
*/
|
|
2010
|
+
async function checkTryPrereqs(projectDir) {
|
|
2011
|
+
const tsxEsm = import.meta.resolve("tsx/esm");
|
|
2012
|
+
const probeScript = [
|
|
2013
|
+
"try {",
|
|
2014
|
+
' await import("waygraph");',
|
|
2015
|
+
"} catch {",
|
|
2016
|
+
' console.error("WAYGRAPH_TRY_MISSING_WAYGRAPH");',
|
|
2017
|
+
" process.exit(1);",
|
|
2018
|
+
"}",
|
|
2019
|
+
"let mod;",
|
|
2020
|
+
"try {",
|
|
2021
|
+
' mod = await import("@playwright/test");',
|
|
2022
|
+
"} catch {",
|
|
2023
|
+
' console.error("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST");',
|
|
2024
|
+
" process.exit(1);",
|
|
2025
|
+
"}",
|
|
2026
|
+
"try {",
|
|
2027
|
+
" const browser = await mod.chromium.launch({ headless: true });",
|
|
2028
|
+
" await browser.close();",
|
|
2029
|
+
"} catch (err) {",
|
|
2030
|
+
" if (String(err && err.message).includes(\"Executable doesn't exist\")) {",
|
|
2031
|
+
' console.error("WAYGRAPH_TRY_MISSING_BROWSER");',
|
|
2032
|
+
" process.exit(1);",
|
|
2033
|
+
" }",
|
|
2034
|
+
" console.error(String((err && err.stack) || err));",
|
|
2035
|
+
" process.exit(1);",
|
|
2036
|
+
"}",
|
|
2037
|
+
].join("\n");
|
|
2038
|
+
const probePath = join(projectDir, `.waygraph-try-probe-${process.pid}.mjs`);
|
|
2039
|
+
writeFileSync(probePath, probeScript);
|
|
2040
|
+
try {
|
|
2041
|
+
const result = await new Promise((res, rej) => {
|
|
2042
|
+
const child = spawn(process.execPath, ["--import", tsxEsm, probePath], {
|
|
2043
|
+
cwd: projectDir,
|
|
2044
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2045
|
+
env: process.env,
|
|
2046
|
+
});
|
|
2047
|
+
let stderrBuf = "";
|
|
2048
|
+
child.stderr?.on("data", (d) => { stderrBuf += d.toString(); });
|
|
2049
|
+
child.on("error", rej);
|
|
2050
|
+
child.on("exit", (code) => res({ code: code ?? 1, stderr: stderrBuf }));
|
|
2051
|
+
});
|
|
2052
|
+
if (result.code === 0)
|
|
2053
|
+
return null;
|
|
2054
|
+
const stderr = result.stderr;
|
|
2055
|
+
if (stderr.includes("WAYGRAPH_TRY_MISSING_WAYGRAPH")) {
|
|
2056
|
+
return `"waygraph" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install waygraph\n npm install -D @playwright/test\n npx playwright install chromium`;
|
|
2057
|
+
}
|
|
2058
|
+
if (stderr.includes("WAYGRAPH_TRY_MISSING_PLAYWRIGHT_TEST")) {
|
|
2059
|
+
return `"@playwright/test" isn't resolvable as a dependency from ${projectDir}.\nRun this first, inside that directory:\n npm install -D @playwright/test\n npx playwright install chromium`;
|
|
2060
|
+
}
|
|
2061
|
+
if (stderr.includes("WAYGRAPH_TRY_MISSING_BROWSER")) {
|
|
2062
|
+
return "Playwright's chromium browser isn't installed yet.\nRun this first:\n npx playwright install chromium";
|
|
2063
|
+
}
|
|
2064
|
+
return `couldn't verify prerequisites:\n${stderr.trim()}`;
|
|
2065
|
+
}
|
|
2066
|
+
finally {
|
|
2067
|
+
rmSync(probePath, { force: true });
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
async function runTry(projectDir) {
|
|
2071
|
+
const problem = await checkTryPrereqs(projectDir);
|
|
2072
|
+
if (problem) {
|
|
2073
|
+
console.error(`waygraph try: not ready yet.\n${problem}`);
|
|
2074
|
+
process.exitCode = 1;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const destDir = join(projectDir, ".waygraph-quickstart");
|
|
2078
|
+
// Regenerated fresh every run (like chain's own .waygraph-chain-<pid>.mjs)
|
|
2079
|
+
// so an updated waygraph version's demo never goes stale - not meant to be
|
|
2080
|
+
// hand-edited and kept, just a real, readable copy to point people at.
|
|
2081
|
+
cpSync(join(packageRoot(), "templates", "quickstart"), destDir, { recursive: true });
|
|
2082
|
+
process.env.WAYGRAPH_HEADED ??= "1";
|
|
2083
|
+
process.env.WAYGRAPH_STEP ??= "1";
|
|
2084
|
+
process.env.WAYGRAPH_AUTOPLAY ??= "1";
|
|
2085
|
+
process.env.WAYGRAPH_BASE_URL ??= "https://www.saucedemo.com";
|
|
2086
|
+
await runChain(destDir, "quickstartFlow");
|
|
2087
|
+
if (!process.exitCode) {
|
|
2088
|
+
console.log("\nwaygraph try: that's plain waygraph - defineNavBlock, defineBlock, Trait, connect() - nothing hidden.\n" +
|
|
2089
|
+
"See the code that just ran:\n" +
|
|
2090
|
+
" " + join(destDir, "src", "flows", "quickstart.flow.ts") + "\n" +
|
|
2091
|
+
" " + join(destDir, "src", "blocks"));
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
1670
2094
|
async function validateFlows(projectDir) {
|
|
1671
2095
|
const files = discoverFlows(projectDir);
|
|
1672
2096
|
const results = [];
|
|
@@ -1784,6 +2208,21 @@ Usage:
|
|
|
1784
2208
|
waygraph run <flow> [project] Run a named flow
|
|
1785
2209
|
waygraph chain <spec> [project] Run one or more Blocks by name, ad hoc
|
|
1786
2210
|
waygraph check [project] Warn about navigation outside a NavBlock
|
|
2211
|
+
waygraph try [project] Quickstart showcase - no setup beyond
|
|
2212
|
+
"waygraph" + "@playwright/test" installed
|
|
2213
|
+
in [project] (default: cwd) and its
|
|
2214
|
+
chromium browser downloaded. Checks both
|
|
2215
|
+
for real (a real launch+close, not a
|
|
2216
|
+
guess) and tells you exactly what to run
|
|
2217
|
+
if either's missing, instead of a raw
|
|
2218
|
+
Playwright stack trace. If ready, copies a
|
|
2219
|
+
small real Flow (nav/action Blocks, Trait
|
|
2220
|
+
verify, narrate()) into
|
|
2221
|
+
[project]/.waygraph-quickstart/ and runs
|
|
2222
|
+
it in step mode against a public demo
|
|
2223
|
+
site - then prints the absolute path to
|
|
2224
|
+
that plain waygraph source, so whoever's
|
|
2225
|
+
watching can go read exactly how it works.
|
|
1787
2226
|
|
|
1788
2227
|
"check" is a secondary sweep, complementary to the always-on TypeScript
|
|
1789
2228
|
warning every regular Block's act() already gets (a struck-through
|
|
@@ -1909,6 +2348,15 @@ async function main() {
|
|
|
1909
2348
|
await runChain(proj, spec);
|
|
1910
2349
|
break;
|
|
1911
2350
|
}
|
|
2351
|
+
case "try": {
|
|
2352
|
+
const proj = resolve(args[1] ?? process.cwd());
|
|
2353
|
+
if (!existsSync(proj)) {
|
|
2354
|
+
console.error(`waygraph: no such directory: ${proj}`);
|
|
2355
|
+
process.exit(1);
|
|
2356
|
+
}
|
|
2357
|
+
await runTry(proj);
|
|
2358
|
+
break;
|
|
2359
|
+
}
|
|
1912
2360
|
case "check": {
|
|
1913
2361
|
const proj = resolve(args[1] ?? process.cwd());
|
|
1914
2362
|
const warnings = await checkCommand(proj);
|
package/dist/engine.d.ts
CHANGED
|
@@ -170,6 +170,16 @@ export interface Flow<Out extends Checkpoint<string>> {
|
|
|
170
170
|
* something `run()` itself uses.
|
|
171
171
|
*/
|
|
172
172
|
readonly title?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Set once this Flow has been wrapped in {@link withExpectedFailure} - a
|
|
175
|
+
* display hint for a step-through overlay: this Flow is DESIGNED to throw
|
|
176
|
+
* (e.g. a blocked user's login attempt genuinely failing), not a bug in
|
|
177
|
+
* the demo itself. `run()` never reads this - the Flow still throws
|
|
178
|
+
* exactly as it would otherwise; only the overlay's error panel reads it,
|
|
179
|
+
* to show an informational "this is the point" panel instead of an
|
|
180
|
+
* alarming one indistinguishable from a real break.
|
|
181
|
+
*/
|
|
182
|
+
readonly expectedFailureReason?: string;
|
|
173
183
|
/**
|
|
174
184
|
* This Flow's constituent Blocks, in order between `start`/`end`, as plain
|
|
175
185
|
* data - for introspection/visualization tools, without running anything.
|
|
@@ -237,6 +247,25 @@ export declare function withSessionReset<Out extends Checkpoint<string>>(flow: F
|
|
|
237
247
|
* @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
|
|
238
248
|
*/
|
|
239
249
|
export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
|
|
250
|
+
/**
|
|
251
|
+
* Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
|
|
252
|
+
* login attempt genuinely failing, demonstrating the app's own real
|
|
253
|
+
* behavior rather than a bug in the demo. Non-destructive, same pattern as
|
|
254
|
+
* {@link withTitle}.
|
|
255
|
+
*
|
|
256
|
+
* By itself this changes nothing about how the Flow runs - it still throws
|
|
257
|
+
* exactly as it would otherwise. It's a hint a step-through overlay
|
|
258
|
+
* (waygraph's `chain --step`) reads when that throw happens, to show an
|
|
259
|
+
* informational "this is the point" panel instead of one indistinguishable
|
|
260
|
+
* from a genuine break - the distinction a demo author needs so a viewer
|
|
261
|
+
* (or the author, mid-build) can tell "the app just did what it's supposed
|
|
262
|
+
* to" apart from "something in this demo is actually broken."
|
|
263
|
+
* @example const viewerBlockedFlow = withExpectedFailure(
|
|
264
|
+
* engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
|
|
265
|
+
* "locked_out_user can never reach /inventory.html - this is the block working as intended.",
|
|
266
|
+
* );
|
|
267
|
+
*/
|
|
268
|
+
export declare function withExpectedFailure<Out extends Checkpoint<string>>(flow: Flow<Out>, reason: string): Flow<Out>;
|
|
240
269
|
/**
|
|
241
270
|
* Runs several complete, independent Flows one after another, reusing the
|
|
242
271
|
* same page/context/mem in order - each remains its own validated
|
package/dist/engine.js
CHANGED
|
@@ -341,6 +341,32 @@ export function withTitle(flow, title) {
|
|
|
341
341
|
modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
|
|
342
342
|
};
|
|
343
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
|
|
346
|
+
* login attempt genuinely failing, demonstrating the app's own real
|
|
347
|
+
* behavior rather than a bug in the demo. Non-destructive, same pattern as
|
|
348
|
+
* {@link withTitle}.
|
|
349
|
+
*
|
|
350
|
+
* By itself this changes nothing about how the Flow runs - it still throws
|
|
351
|
+
* exactly as it would otherwise. It's a hint a step-through overlay
|
|
352
|
+
* (waygraph's `chain --step`) reads when that throw happens, to show an
|
|
353
|
+
* informational "this is the point" panel instead of one indistinguishable
|
|
354
|
+
* from a genuine break - the distinction a demo author needs so a viewer
|
|
355
|
+
* (or the author, mid-build) can tell "the app just did what it's supposed
|
|
356
|
+
* to" apart from "something in this demo is actually broken."
|
|
357
|
+
* @example const viewerBlockedFlow = withExpectedFailure(
|
|
358
|
+
* engine.defineFlow([start, NavLoginBlock, blockedLoginCheck, end]),
|
|
359
|
+
* "locked_out_user can never reach /inventory.html - this is the block working as intended.",
|
|
360
|
+
* );
|
|
361
|
+
*/
|
|
362
|
+
export function withExpectedFailure(flow, reason) {
|
|
363
|
+
return {
|
|
364
|
+
...flow,
|
|
365
|
+
expectedFailureReason: reason,
|
|
366
|
+
withBlockVerify: (block, verify) => withExpectedFailure(flow.withBlockVerify(block, verify), reason),
|
|
367
|
+
modBlockVerify: (block, nameOrIndex, newCheck) => withExpectedFailure(flow.modBlockVerify(block, nameOrIndex, newCheck), reason),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
344
370
|
function locateInChain(flows, block) {
|
|
345
371
|
if (typeof block === "number") {
|
|
346
372
|
let remaining = block;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start } from "./type
|
|
|
2
2
|
export { connect, checkpoint } from "./types.js";
|
|
3
3
|
export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
|
|
4
4
|
export { Trait, urlMatches, textEquals, visible } from "./trait.js";
|
|
5
|
-
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
|
|
5
|
+
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
|
|
6
6
|
export type { Flow, EngineConfig, ComposedBlock, RunGraphOptions, BlockInfo, NavBlock } from "./engine.js";
|
|
7
7
|
export type { ActionPage } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { connect, checkpoint } from "./types.js";
|
|
2
2
|
export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
|
|
3
3
|
export { Trait, urlMatches, textEquals, visible } from "./trait.js";
|
|
4
|
-
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, chainFlow, } from "./engine.js";
|
|
4
|
+
export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, composeBlock, preflight, withSessionReset, withTitle, withExpectedFailure, chainFlow, } from "./engine.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waygraph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
-
"bin"
|
|
19
|
+
"bin",
|
|
20
|
+
"templates"
|
|
20
21
|
],
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineBlock, checkpoint, Trait } from "waygraph";
|
|
2
|
+
import type { LoggedIn, ItemInCart } from "../../states/quickstart.states.js";
|
|
3
|
+
|
|
4
|
+
export const AddToCartBlock = defineBlock<LoggedIn, ItemInCart>({
|
|
5
|
+
name: "add-to-cart",
|
|
6
|
+
description: "Adds the Sauce Labs Backpack to the cart and confirms the cart badge shows 1 item.",
|
|
7
|
+
instruction: {
|
|
8
|
+
async act(page) {
|
|
9
|
+
await page.locator("#add-to-cart-sauce-labs-backpack").click();
|
|
10
|
+
},
|
|
11
|
+
resolve: () => checkpoint("ItemInCart"),
|
|
12
|
+
verify: [Trait.text(".shopping_cart_badge", "1")],
|
|
13
|
+
// Author-declared highlights - independent of verify's own pass/fail
|
|
14
|
+
// check. "Remember this" moments a step-through overlay can call out
|
|
15
|
+
// even when there's nothing to assert, just something worth pointing at.
|
|
16
|
+
highlights: () => [{ selector: ".shopping_cart_badge", label: "remember this: cart now has 1 item" }],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineBlock, checkpoint, Trait } from "waygraph";
|
|
2
|
+
import type { ItemInCart, CheckoutInfoFilled } from "../../states/quickstart.states.js";
|
|
3
|
+
|
|
4
|
+
export const CheckoutInfoBlock = defineBlock<ItemInCart, CheckoutInfoFilled>({
|
|
5
|
+
name: "checkout-info",
|
|
6
|
+
description: "Goes to the cart, starts checkout, and fills in the shipping info form.",
|
|
7
|
+
instruction: {
|
|
8
|
+
async act(page) {
|
|
9
|
+
await page.locator(".shopping_cart_link").click();
|
|
10
|
+
await page.locator("#checkout").click();
|
|
11
|
+
await page.locator("#first-name").fill("Ada");
|
|
12
|
+
await page.locator("#last-name").fill("Lovelace");
|
|
13
|
+
await page.locator("#postal-code").fill("1000");
|
|
14
|
+
await page.locator("#continue").click();
|
|
15
|
+
},
|
|
16
|
+
resolve: () => checkpoint("CheckoutInfoFilled"),
|
|
17
|
+
verify: [Trait.url({ pathname: "/checkout-step-two.html" })],
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineBlock, checkpoint, Trait, narrate } from "waygraph";
|
|
2
|
+
import type { CheckoutInfoFilled, OrderComplete } from "../../states/quickstart.states.js";
|
|
3
|
+
|
|
4
|
+
export const FinishOrderBlock = defineBlock<CheckoutInfoFilled, OrderComplete>({
|
|
5
|
+
name: "finish-order",
|
|
6
|
+
description: "Places the order and confirms the 'Thank you for your order!' completion page.",
|
|
7
|
+
instruction: {
|
|
8
|
+
async act(page) {
|
|
9
|
+
// narrate() captions an explicit action for a step-through overlay,
|
|
10
|
+
// instead of it guessing a generic label from the element's own text.
|
|
11
|
+
const finishButton = page.locator("#finish");
|
|
12
|
+
await narrate(finishButton, "Placing the order", () => finishButton.click());
|
|
13
|
+
},
|
|
14
|
+
resolve: () => checkpoint("OrderComplete"),
|
|
15
|
+
verify: [Trait.text(".complete-header", "Thank you for your order!")],
|
|
16
|
+
highlights: () => [{ selector: ".complete-header", label: "remember this: order confirmed" }],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineBlock, checkpoint, Trait } from "waygraph";
|
|
2
|
+
import type { LoginPage, LoggedIn } from "../../states/quickstart.states.js";
|
|
3
|
+
|
|
4
|
+
// Credentials hardcoded on purpose, to keep this first look at waygraph
|
|
5
|
+
// self-contained - no setup step, nothing to type before you can watch it
|
|
6
|
+
// run. A real project reaches for a MemKey/keyGroup instead (see waygraph's
|
|
7
|
+
// own README, or projects_waygraph/saucedemo in this same workspace) so
|
|
8
|
+
// different scenarios (an Owner vs. a blocked Viewer, say) can hand a Flow
|
|
9
|
+
// different credentials at run time without editing the Block itself.
|
|
10
|
+
export const SubmitLoginActionBlock = defineBlock<LoginPage, LoggedIn>({
|
|
11
|
+
name: "submit-login",
|
|
12
|
+
description: "Logs a standard user into Swag Labs and confirms the inventory page loads.",
|
|
13
|
+
instruction: {
|
|
14
|
+
async act(page) {
|
|
15
|
+
await page.locator("#user-name").fill("standard_user");
|
|
16
|
+
await page.locator("#password").fill("secret_sauce");
|
|
17
|
+
await page.locator("#login-button").click();
|
|
18
|
+
},
|
|
19
|
+
resolve: () => checkpoint("LoggedIn"),
|
|
20
|
+
verify: [Trait.url({ pathname: "/inventory.html" })],
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineNavBlock, Trait } from "waygraph";
|
|
2
|
+
import type { LoginPage } from "../states/quickstart.states.js";
|
|
3
|
+
|
|
4
|
+
// Navigation lives in its own Block, built by defineNavBlock - its act() is
|
|
5
|
+
// ALWAYS exactly page.goto(url), generated for you, never hand-written. A
|
|
6
|
+
// regular defineBlock() Block that calls page.goto/reload/goBack/goForward
|
|
7
|
+
// instead gets a struck-through @deprecated warning in your editor, pointing
|
|
8
|
+
// back here - see waygraph's ActionPage type and `waygraph check`.
|
|
9
|
+
export const NavLoginBlock = defineNavBlock<LoginPage>({
|
|
10
|
+
name: "nav-login",
|
|
11
|
+
description: "Goes to the Swag Labs login page.",
|
|
12
|
+
checkpoint: "LoginPage",
|
|
13
|
+
url: "/",
|
|
14
|
+
verify: [Trait.visible("#login-button")],
|
|
15
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Engine, start, end, withTitle } from "waygraph";
|
|
2
|
+
import { NavLoginBlock } from "../blocks/nav-login.block.js";
|
|
3
|
+
import { SubmitLoginActionBlock } from "../blocks/actions/submit-login.action.block.js";
|
|
4
|
+
import { AddToCartBlock } from "../blocks/actions/add-to-cart.action.block.js";
|
|
5
|
+
import { CheckoutInfoBlock } from "../blocks/actions/checkout-info.action.block.js";
|
|
6
|
+
import { FinishOrderBlock } from "../blocks/actions/finish-order.action.block.js";
|
|
7
|
+
|
|
8
|
+
const engine = new Engine();
|
|
9
|
+
|
|
10
|
+
// This is it - a whole real waygraph Flow, five Blocks wide, generated by
|
|
11
|
+
// `waygraph try` but otherwise no different from a Flow you'd hand-write:
|
|
12
|
+
// nav-* Blocks (defineNavBlock) navigate, action-* Blocks (defineBlock) do
|
|
13
|
+
// everything else, connect() (here, defineFlow's own array form) chains
|
|
14
|
+
// them into one typed graph, each Block's own Trait verify confirms it
|
|
15
|
+
// actually landed where it says it did.
|
|
16
|
+
export const quickstartFlow = withTitle(
|
|
17
|
+
engine.defineFlow([start, NavLoginBlock, SubmitLoginActionBlock, AddToCartBlock, CheckoutInfoBlock, FinishOrderBlock, end]),
|
|
18
|
+
"waygraph quickstart - swag labs demo",
|
|
19
|
+
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Checkpoint } from "waygraph";
|
|
2
|
+
|
|
3
|
+
// One Checkpoint per real state the target app can be in - the whole shape
|
|
4
|
+
// of this quickstart's Flow, readable in one place before you ever open a
|
|
5
|
+
// Block file. `Start` is waygraph's own exported type, not redeclared here.
|
|
6
|
+
|
|
7
|
+
export type LoginPage = Checkpoint<"LoginPage">;
|
|
8
|
+
export type LoggedIn = Checkpoint<"LoggedIn">;
|
|
9
|
+
export type ItemInCart = Checkpoint<"ItemInCart">;
|
|
10
|
+
export type CheckoutInfoFilled = Checkpoint<"CheckoutInfoFilled">;
|
|
11
|
+
export type OrderComplete = Checkpoint<"OrderComplete">;
|