waygraph 0.12.13 → 0.12.14
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 +7 -1
- package/dist/cli.js +30 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/traverse-lease.d.ts +18 -0
- package/dist/traverse-lease.js +62 -0
- package/dist/traverse-run.d.ts +6 -1
- package/dist/traverse-run.js +196 -50
- package/examples/saucedemo/.waygraph-traverse/leases.json +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -226,7 +226,13 @@ FFCompose into its inner Blocks so a broken login step is visible as its own pan
|
|
|
226
226
|
Former FF inners keep blitz pacing so wall-clock stays comparable to opaque FF (same
|
|
227
227
|
real acts). `--ff-expand` is the older expand-only flag (same flatten). Saucedemo
|
|
228
228
|
`checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
|
|
229
|
-
Sign In narration.
|
|
229
|
+
Sign In narration.
|
|
230
|
+
|
|
231
|
+
`waygraph traverse` walks the Block graph (Phase B+D). `--parallel N` (default
|
|
232
|
+
`--session clone`) bootstraps one context, forks N workers with `storageState` + mem
|
|
233
|
+
snapshot, partitions edges by hash, and claims via in-process edge leases (audit under
|
|
234
|
+
`.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. See
|
|
235
|
+
`docs/proposals/traverse-ffcompose-rfc.md`.
|
|
230
236
|
`spawnTab(entry, page, mem)` drives a genuinely separate second tab through its own
|
|
231
237
|
Block/Flow, in the same browser context an existing `page` already belongs to - the
|
|
232
238
|
pattern `observe()` could already reach for (it's the only phase allowed to touch
|
package/dist/cli.js
CHANGED
|
@@ -2674,8 +2674,8 @@ async function main() {
|
|
|
2674
2674
|
if (step && hasFfBlitz && process.env.WAYGRAPH_SLOWMO === undefined && !jsonReport) {
|
|
2675
2675
|
console.error(
|
|
2676
2676
|
ffDisabled
|
|
2677
|
-
? "waygraph demo: --ff-disabled (expanded)
|
|
2678
|
-
: "waygraph demo: opaque FFCompose in flow
|
|
2677
|
+
? "waygraph demo: --ff-disabled (expanded) -> Playwright slowMo=0 on former FF steps (blitz; same acts as opaque FF)"
|
|
2678
|
+
: "waygraph demo: opaque FFCompose in flow -> Playwright slowMo=0 (blitz); set WAYGRAPH_SLOWMO to override",
|
|
2679
2679
|
);
|
|
2680
2680
|
}
|
|
2681
2681
|
const baseURL = process.env.WAYGRAPH_BASE_URL;
|
|
@@ -3716,8 +3716,10 @@ Primary (less is more):
|
|
|
3716
3716
|
|
|
3717
3717
|
Also:
|
|
3718
3718
|
waygraph list | nav | validate | check | graph | init <name>
|
|
3719
|
-
waygraph traverse [project]
|
|
3719
|
+
waygraph traverse [project] Graph crawl (Phase B+D)
|
|
3720
3720
|
--blocks <glob|/re/|sub> Phase C: filter *.block.ts discovery
|
|
3721
|
+
--parallel N Phase D: N clone workers (max 4)
|
|
3722
|
+
--session clone|inherit Phase D: default clone
|
|
3721
3723
|
--from <Checkpoint> Seed / start checkpoint
|
|
3722
3724
|
--data '{...}' Mem seed
|
|
3723
3725
|
--max-steps N Cap Block runs (default 50)
|
|
@@ -3743,6 +3745,7 @@ Examples:
|
|
|
3743
3745
|
waygraph auto --blocks LoginPage OrderComplete
|
|
3744
3746
|
waygraph auto --blocks '/mailpit/' # Phase C filtered explore
|
|
3745
3747
|
waygraph traverse --blocks '**/mailpit/**/*.block.ts' # Phase C filtered crawl
|
|
3748
|
+
waygraph traverse --parallel 2 --session clone # Phase D clone workers
|
|
3746
3749
|
waygraph run --blocks "loginFlow then add-all-to-cart" --data '{...}' --video
|
|
3747
3750
|
|
|
3748
3751
|
Aliases (compat): \`chain <spec>\` -> run --blocks; \`chain auto A B\` -> auto --blocks A B;
|
|
@@ -3837,6 +3840,8 @@ async function main() {
|
|
|
3837
3840
|
let headed = false;
|
|
3838
3841
|
let baseUrl;
|
|
3839
3842
|
let blocksFilter;
|
|
3843
|
+
let parallel;
|
|
3844
|
+
let session;
|
|
3840
3845
|
let projectDir = process.cwd();
|
|
3841
3846
|
for (let i = 0; i < rest.length; i++) {
|
|
3842
3847
|
const a = rest[i];
|
|
@@ -3862,6 +3867,20 @@ async function main() {
|
|
|
3862
3867
|
}
|
|
3863
3868
|
continue;
|
|
3864
3869
|
}
|
|
3870
|
+
if (a === "--parallel" || a.startsWith("--parallel=")) {
|
|
3871
|
+
const v = a.startsWith("--parallel=") ? a.slice("--parallel=".length) : rest[++i];
|
|
3872
|
+
parallel = Number(v);
|
|
3873
|
+
continue;
|
|
3874
|
+
}
|
|
3875
|
+
if (a === "--session" || a.startsWith("--session=")) {
|
|
3876
|
+
const v = a.startsWith("--session=") ? a.slice("--session=".length) : rest[++i];
|
|
3877
|
+
if (v !== "clone" && v !== "inherit") {
|
|
3878
|
+
console.error('waygraph traverse: --session must be "clone" or "inherit"');
|
|
3879
|
+
process.exit(1);
|
|
3880
|
+
}
|
|
3881
|
+
session = v;
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3865
3884
|
if (a === "--max-steps" || a.startsWith("--max-steps=")) {
|
|
3866
3885
|
const v = a.startsWith("--max-steps=") ? a.slice(12) : rest[++i];
|
|
3867
3886
|
maxSteps = Number(v);
|
|
@@ -3890,13 +3909,15 @@ async function main() {
|
|
|
3890
3909
|
if (a === "--help" || a === "-h") {
|
|
3891
3910
|
console.log(`waygraph traverse [project] [flags]
|
|
3892
3911
|
|
|
3893
|
-
|
|
3894
|
-
budget kill, or first broken edge.
|
|
3912
|
+
Graph crawl (RFC Phase B+D). Walks unused legal edges until a leaf,
|
|
3913
|
+
budget kill, or first broken edge. --parallel N uses session clone + edge leases.
|
|
3895
3914
|
|
|
3896
3915
|
--blocks <glob|/regex/|substr> Phase C: filter *.block.ts discovery
|
|
3916
|
+
--parallel N Phase D: N clone workers (default 1, max 4)
|
|
3917
|
+
--session clone|inherit Phase D: default clone; inherit refused if parallel>1
|
|
3897
3918
|
--from <Checkpoint> start checkpoint (optional)
|
|
3898
3919
|
--data '{...}' Mem seed JSON
|
|
3899
|
-
--max-steps N default 50
|
|
3920
|
+
--max-steps N default 50 (per worker)
|
|
3900
3921
|
--max-visits N per-node visit cap (default 2)
|
|
3901
3922
|
--max-visits-per-edge N default 1
|
|
3902
3923
|
--non-headless show browser
|
|
@@ -3904,7 +3925,7 @@ budget kill, or first broken edge.
|
|
|
3904
3925
|
|
|
3905
3926
|
Examples:
|
|
3906
3927
|
waygraph traverse --blocks '**/mailpit/**/*.block.ts'
|
|
3907
|
-
waygraph traverse --
|
|
3928
|
+
waygraph traverse --parallel 2 --session clone --max-steps 20
|
|
3908
3929
|
|
|
3909
3930
|
PASS: [Reached Leaf Node[traverse-1] at=... steps=N]
|
|
3910
3931
|
FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
|
|
@@ -3935,6 +3956,8 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
|
|
|
3935
3956
|
headed,
|
|
3936
3957
|
...(baseUrl ? { baseURL: baseUrl } : {}),
|
|
3937
3958
|
...(blocksSelect ? { blocksSelect } : {}),
|
|
3959
|
+
...(parallel !== undefined && Number.isFinite(parallel) ? { parallel } : {}),
|
|
3960
|
+
...(session ? { session } : {}),
|
|
3938
3961
|
});
|
|
3939
3962
|
process.exit(code);
|
|
3940
3963
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,3 +11,6 @@ export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./gra
|
|
|
11
11
|
export type { WaygraphGraph, WaygraphNode, WaygraphEdge, SkippedBlock, OrphanBlock } from "./graph.js";
|
|
12
12
|
export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
|
|
13
13
|
export type { BlocksSelect, BlocksSelectKind } from "./blocks-select.js";
|
|
14
|
+
export { EdgeLeaseCoordinator } from "./traverse-lease.js";
|
|
15
|
+
export { runTraverse } from "./traverse-run.js";
|
|
16
|
+
export type { TraverseOptions, TraverseSessionMode } from "./traverse-run.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,5 @@ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, mo
|
|
|
5
5
|
export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
|
|
6
6
|
export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
|
|
7
7
|
export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
|
|
8
|
+
export { EdgeLeaseCoordinator } from "./traverse-lease.js";
|
|
9
|
+
export { runTraverse } from "./traverse-run.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface EdgeLeaseRecord {
|
|
2
|
+
workerId: string;
|
|
3
|
+
claimedAt: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class EdgeLeaseCoordinator {
|
|
6
|
+
private readonly held;
|
|
7
|
+
private readonly dir;
|
|
8
|
+
constructor(opts?: {
|
|
9
|
+
projectDir?: string;
|
|
10
|
+
persist?: boolean;
|
|
11
|
+
});
|
|
12
|
+
/** True if this worker now owns `edgeKey` (or already owned it). */
|
|
13
|
+
tryClaim(edgeKey: string, workerId: string): boolean;
|
|
14
|
+
release(edgeKey: string, workerId: string): void;
|
|
15
|
+
/** Stable partition: same key always maps to the same worker index. */
|
|
16
|
+
static partitionIndex(edgeKey: string, parallel: number): number;
|
|
17
|
+
private flush;
|
|
18
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase D: edge leases so parallel traverse workers never claim the same edge.
|
|
3
|
+
* In-process Map is authoritative for `--parallel` (same Node process).
|
|
4
|
+
* Optional JSON under `.waygraph-traverse/` is an audit trail only.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
export class EdgeLeaseCoordinator {
|
|
9
|
+
held = new Map();
|
|
10
|
+
dir;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
if (opts?.persist && opts.projectDir) {
|
|
13
|
+
this.dir = join(opts.projectDir, ".waygraph-traverse");
|
|
14
|
+
if (!existsSync(this.dir))
|
|
15
|
+
mkdirSync(this.dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
this.dir = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** True if this worker now owns `edgeKey` (or already owned it). */
|
|
22
|
+
tryClaim(edgeKey, workerId) {
|
|
23
|
+
const cur = this.held.get(edgeKey);
|
|
24
|
+
if (cur && cur.workerId !== workerId)
|
|
25
|
+
return false;
|
|
26
|
+
this.held.set(edgeKey, { workerId, claimedAt: Date.now() });
|
|
27
|
+
this.flush();
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
release(edgeKey, workerId) {
|
|
31
|
+
const cur = this.held.get(edgeKey);
|
|
32
|
+
if (cur && cur.workerId === workerId) {
|
|
33
|
+
this.held.delete(edgeKey);
|
|
34
|
+
this.flush();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Stable partition: same key always maps to the same worker index. */
|
|
38
|
+
static partitionIndex(edgeKey, parallel) {
|
|
39
|
+
if (parallel <= 1)
|
|
40
|
+
return 0;
|
|
41
|
+
let h = 2166136261;
|
|
42
|
+
for (let i = 0; i < edgeKey.length; i++) {
|
|
43
|
+
h ^= edgeKey.charCodeAt(i);
|
|
44
|
+
h = Math.imul(h, 16777619);
|
|
45
|
+
}
|
|
46
|
+
return (h >>> 0) % parallel;
|
|
47
|
+
}
|
|
48
|
+
flush() {
|
|
49
|
+
if (!this.dir)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
const payload = {
|
|
53
|
+
updatedAt: new Date().toISOString(),
|
|
54
|
+
leases: Object.fromEntries(this.held),
|
|
55
|
+
};
|
|
56
|
+
writeFileSync(join(this.dir, "leases.json"), JSON.stringify(payload, null, 2));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* audit only */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/traverse-run.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type TraverseSessionMode = "clone" | "inherit";
|
|
1
2
|
export interface TraverseOptions {
|
|
2
3
|
baseURL?: string;
|
|
3
4
|
startUrl?: string;
|
|
@@ -19,8 +20,12 @@ export interface TraverseOptions {
|
|
|
19
20
|
traverseId?: string;
|
|
20
21
|
/** Phase C: glob / regex / bare `--blocks` file select. */
|
|
21
22
|
blocksSelect?: import("./blocks-select.js").BlocksSelect;
|
|
23
|
+
/** Phase D: parallel workers (default 1). Cap 4. */
|
|
24
|
+
parallel?: number;
|
|
25
|
+
/** Phase D: clone (default) or inherit. Inherit refused when parallel > 1. */
|
|
26
|
+
session?: TraverseSessionMode;
|
|
22
27
|
}
|
|
23
28
|
/**
|
|
24
|
-
* Serial graph crawl. Prints greppable PASS/FAIL lines; non-zero
|
|
29
|
+
* Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
|
|
25
30
|
*/
|
|
26
31
|
export declare function runTraverse(projectDir: string, options?: TraverseOptions): Promise<number>;
|
package/dist/traverse-run.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Phase B:
|
|
3
|
-
* Walk unused legal edges until leaf / budget / break.
|
|
2
|
+
* Phase B+D: graph traverse (serial or --parallel with --session clone).
|
|
3
|
+
* Walk unused legal edges until leaf / budget / break.
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -8,6 +8,7 @@ import { Engine, start, end, locate } from "./engine.js";
|
|
|
8
8
|
import { MemPage } from "./mem-page.js";
|
|
9
9
|
import { buildExploreContext, buildExploreMenu, } from "./auto-explore.js";
|
|
10
10
|
import { defaultMemValueForKey } from "./auto-explore-run.js";
|
|
11
|
+
import { EdgeLeaseCoordinator } from "./traverse-lease.js";
|
|
11
12
|
function resolveBaseUrl(projectDir) {
|
|
12
13
|
if (process.env.WAYGRAPH_BASE_URL)
|
|
13
14
|
return process.env.WAYGRAPH_BASE_URL;
|
|
@@ -47,6 +48,32 @@ function seedMem(library, mem, dataJson) {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
function snapshotMem(library, mem) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const entry of library.values()) {
|
|
54
|
+
for (const k of entry.block.requires ?? []) {
|
|
55
|
+
if (mem.has(k) && !(k.name in out)) {
|
|
56
|
+
try {
|
|
57
|
+
out[k.name] = mem.get(k);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* skip */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function restoreMem(library, mem, snap, dataJson) {
|
|
68
|
+
seedMem(library, mem, dataJson);
|
|
69
|
+
for (const entry of library.values()) {
|
|
70
|
+
for (const k of entry.block.requires ?? []) {
|
|
71
|
+
if (k.name in snap) {
|
|
72
|
+
mem.set(k, snap[k.name]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
50
77
|
function missingMemKeys(entry, mem) {
|
|
51
78
|
return (entry.block.requires ?? []).filter((k) => !mem.has(k));
|
|
52
79
|
}
|
|
@@ -94,56 +121,18 @@ async function runOneBlock(engine, entry, context, page, mem) {
|
|
|
94
121
|
clearTimeout(timer);
|
|
95
122
|
}
|
|
96
123
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const traverseId = options.traverseId ?? "traverse-1";
|
|
102
|
-
const maxSteps = options.maxSteps ?? 50;
|
|
103
|
-
const maxVisitsPerNode = options.maxVisitsPerNode ?? 2;
|
|
104
|
-
const maxVisitsPerEdge = options.maxVisitsPerEdge ?? 1;
|
|
105
|
-
const timeoutMs = options.timeoutMs ?? 15 * 60_000;
|
|
106
|
-
const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
|
|
107
|
-
const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
|
|
108
|
-
const startUrl = options.startUrl ?? baseURL;
|
|
109
|
-
const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
|
|
110
|
-
if (options.blocksSelect) {
|
|
111
|
-
console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} → ` +
|
|
112
|
-
`${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
|
|
113
|
-
}
|
|
114
|
-
const mem = new MemPage();
|
|
115
|
-
seedMem(library.byName, mem, options.data);
|
|
116
|
-
const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
|
|
117
|
-
const { chromium } = await import("@playwright/test");
|
|
118
|
-
const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH;
|
|
119
|
-
const launchOpts = {
|
|
120
|
-
headless: !headed,
|
|
121
|
-
};
|
|
122
|
-
if (executablePath)
|
|
123
|
-
launchOpts.executablePath = executablePath;
|
|
124
|
-
const browser = await chromium.launch(launchOpts);
|
|
125
|
-
const contextOpts = {
|
|
126
|
-
viewport: { width: 1280, height: 720 },
|
|
127
|
-
};
|
|
128
|
-
if (baseURL)
|
|
129
|
-
contextOpts.baseURL = baseURL;
|
|
130
|
-
const context = await browser.newContext(contextOpts);
|
|
131
|
-
let page = await context.newPage();
|
|
124
|
+
async function runTraverseWorker(opts) {
|
|
125
|
+
const { traverseId, workerIndex, parallel, maxSteps, maxVisitsPerNode, maxVisitsPerEdge, timeoutMs, graph, library, engine, context, mem, leases, } = opts;
|
|
126
|
+
let page = opts.page;
|
|
127
|
+
const startUrl = opts.startUrl;
|
|
132
128
|
const nodeVisits = new Map();
|
|
133
129
|
const edgeVisits = new Map();
|
|
134
130
|
const edgesHit = new Set();
|
|
135
131
|
let steps = 0;
|
|
136
132
|
const startedAt = Date.now();
|
|
137
|
-
let lastHere =
|
|
133
|
+
let lastHere = opts.from ?? null;
|
|
138
134
|
let exitCode = 0;
|
|
139
|
-
console.error(`waygraph traverse[${traverseId}]: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
|
|
140
|
-
(baseURL ? ` base=${baseURL}` : "") +
|
|
141
|
-
` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
|
|
142
135
|
try {
|
|
143
|
-
if (startUrl) {
|
|
144
|
-
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
145
|
-
await page.waitForLoadState("load").catch(() => { });
|
|
146
|
-
}
|
|
147
136
|
for (;;) {
|
|
148
137
|
if (Date.now() - startedAt > timeoutMs) {
|
|
149
138
|
console.log(`[Killed timeout[${traverseId}] node=${lastHere ?? "?"} wallMs=${timeoutMs}]`);
|
|
@@ -162,7 +151,7 @@ export async function runTraverse(projectDir, options = {}) {
|
|
|
162
151
|
}
|
|
163
152
|
}
|
|
164
153
|
const detected = await detectHere(page, library.navBlocks);
|
|
165
|
-
const here = lastHere ?? detected ??
|
|
154
|
+
const here = lastHere ?? detected ?? opts.from ?? null;
|
|
166
155
|
if (here && (nodeVisits.get(here) ?? 0) >= maxVisitsPerNode) {
|
|
167
156
|
console.log(`[Killed loop[${traverseId}] node=${here} visits=${nodeVisits.get(here)} max=${maxVisitsPerNode}]`);
|
|
168
157
|
exitCode = 1;
|
|
@@ -171,7 +160,14 @@ export async function runTraverse(projectDir, options = {}) {
|
|
|
171
160
|
const menu = await buildExploreMenu(page, graph, library, here);
|
|
172
161
|
const candidates = menu.flat.filter((edge) => {
|
|
173
162
|
const key = edgeKey(edge);
|
|
174
|
-
|
|
163
|
+
if ((edgeVisits.get(key) ?? 0) >= maxVisitsPerEdge)
|
|
164
|
+
return false;
|
|
165
|
+
if (parallel > 1) {
|
|
166
|
+
if (EdgeLeaseCoordinator.partitionIndex(key, parallel) !== workerIndex) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
175
171
|
});
|
|
176
172
|
if (candidates.length === 0) {
|
|
177
173
|
const label = here ?? detected ?? "unknown";
|
|
@@ -186,6 +182,12 @@ export async function runTraverse(projectDir, options = {}) {
|
|
|
186
182
|
return aWild - bWild;
|
|
187
183
|
return edgeKey(a).localeCompare(edgeKey(b));
|
|
188
184
|
})[0];
|
|
185
|
+
const key = edgeKey(pick);
|
|
186
|
+
if (!leases.tryClaim(key, traverseId)) {
|
|
187
|
+
// Another worker won a race (instance fan-out); treat as unavailable.
|
|
188
|
+
edgeVisits.set(key, maxVisitsPerEdge);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
189
191
|
const entry = library.byName.get(pick.block);
|
|
190
192
|
if (!entry) {
|
|
191
193
|
console.log(`[Broke at edge[${traverseId}] block=${pick.block} from=${here ?? "?"} to=${pick.to} reason=not-loaded]`);
|
|
@@ -204,7 +206,6 @@ export async function runTraverse(projectDir, options = {}) {
|
|
|
204
206
|
const observed = typeof out === "object" && out && "__state" in out
|
|
205
207
|
? String(out.__state)
|
|
206
208
|
: pick.to;
|
|
207
|
-
const key = edgeKey(pick);
|
|
208
209
|
edgeVisits.set(key, (edgeVisits.get(key) ?? 0) + 1);
|
|
209
210
|
edgesHit.add(key);
|
|
210
211
|
if (here)
|
|
@@ -224,8 +225,153 @@ export async function runTraverse(projectDir, options = {}) {
|
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
227
|
finally {
|
|
227
|
-
|
|
228
|
+
console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
|
|
228
229
|
}
|
|
229
|
-
console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
|
|
230
230
|
return exitCode;
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
|
|
234
|
+
*/
|
|
235
|
+
export async function runTraverse(projectDir, options = {}) {
|
|
236
|
+
let parallel = Math.max(1, Math.floor(options.parallel ?? 1));
|
|
237
|
+
if (parallel > 4) {
|
|
238
|
+
console.error(`waygraph traverse: --parallel ${parallel} capped to 4`);
|
|
239
|
+
parallel = 4;
|
|
240
|
+
}
|
|
241
|
+
const session = options.session ?? (parallel > 1 ? "clone" : "clone");
|
|
242
|
+
if (parallel > 1 && session === "inherit") {
|
|
243
|
+
console.error("waygraph traverse: --session inherit is refused with --parallel > 1 (races); use --session clone");
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
const maxSteps = options.maxSteps ?? 50;
|
|
247
|
+
const maxVisitsPerNode = options.maxVisitsPerNode ?? 2;
|
|
248
|
+
const maxVisitsPerEdge = options.maxVisitsPerEdge ?? 1;
|
|
249
|
+
const timeoutMs = options.timeoutMs ?? 15 * 60_000;
|
|
250
|
+
const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
|
|
251
|
+
const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
|
|
252
|
+
const startUrl = options.startUrl ?? baseURL;
|
|
253
|
+
const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
|
|
254
|
+
if (options.blocksSelect) {
|
|
255
|
+
console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} -> ` +
|
|
256
|
+
`${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
|
|
257
|
+
}
|
|
258
|
+
const leases = new EdgeLeaseCoordinator({ projectDir, persist: parallel > 1 });
|
|
259
|
+
const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
|
|
260
|
+
const { chromium } = await import("@playwright/test");
|
|
261
|
+
const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH;
|
|
262
|
+
const launchOpts = {
|
|
263
|
+
headless: !headed,
|
|
264
|
+
};
|
|
265
|
+
if (executablePath)
|
|
266
|
+
launchOpts.executablePath = executablePath;
|
|
267
|
+
const browser = await chromium.launch(launchOpts);
|
|
268
|
+
console.error(`waygraph traverse: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
|
|
269
|
+
(baseURL ? ` base=${baseURL}` : "") +
|
|
270
|
+
` parallel=${parallel} session=${session}` +
|
|
271
|
+
` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
|
|
272
|
+
try {
|
|
273
|
+
if (parallel <= 1) {
|
|
274
|
+
const contextOpts = {
|
|
275
|
+
viewport: { width: 1280, height: 720 },
|
|
276
|
+
};
|
|
277
|
+
if (baseURL)
|
|
278
|
+
contextOpts.baseURL = baseURL;
|
|
279
|
+
const context = await browser.newContext(contextOpts);
|
|
280
|
+
const page = await context.newPage();
|
|
281
|
+
const mem = new MemPage();
|
|
282
|
+
seedMem(library.byName, mem, options.data);
|
|
283
|
+
if (startUrl) {
|
|
284
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
285
|
+
await page.waitForLoadState("load").catch(() => { });
|
|
286
|
+
}
|
|
287
|
+
const code = await runTraverseWorker({
|
|
288
|
+
traverseId: options.traverseId ?? "traverse-1",
|
|
289
|
+
workerIndex: 0,
|
|
290
|
+
parallel: 1,
|
|
291
|
+
maxSteps,
|
|
292
|
+
maxVisitsPerNode,
|
|
293
|
+
maxVisitsPerEdge,
|
|
294
|
+
timeoutMs,
|
|
295
|
+
...(options.from ? { from: options.from } : {}),
|
|
296
|
+
...(startUrl ? { startUrl } : {}),
|
|
297
|
+
...(baseURL ? { baseURL } : {}),
|
|
298
|
+
...(options.data ? { data: options.data } : {}),
|
|
299
|
+
graph,
|
|
300
|
+
library,
|
|
301
|
+
engine,
|
|
302
|
+
context,
|
|
303
|
+
page,
|
|
304
|
+
mem,
|
|
305
|
+
leases,
|
|
306
|
+
});
|
|
307
|
+
await context.close().catch(() => { });
|
|
308
|
+
return code;
|
|
309
|
+
}
|
|
310
|
+
// Phase D: bootstrap one context for storageState, then N clones.
|
|
311
|
+
const bootOpts = {
|
|
312
|
+
viewport: { width: 1280, height: 720 },
|
|
313
|
+
};
|
|
314
|
+
if (baseURL)
|
|
315
|
+
bootOpts.baseURL = baseURL;
|
|
316
|
+
const bootContext = await browser.newContext(bootOpts);
|
|
317
|
+
const bootPage = await bootContext.newPage();
|
|
318
|
+
const bootMem = new MemPage();
|
|
319
|
+
seedMem(library.byName, bootMem, options.data);
|
|
320
|
+
if (startUrl) {
|
|
321
|
+
await bootPage.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
322
|
+
await bootPage.waitForLoadState("load").catch(() => { });
|
|
323
|
+
}
|
|
324
|
+
const storageState = await bootContext.storageState();
|
|
325
|
+
const memSnap = snapshotMem(library.byName, bootMem);
|
|
326
|
+
await bootContext.close().catch(() => { });
|
|
327
|
+
console.error(`waygraph traverse: forked ${parallel} clone workers (storageState + mem snapshot)`);
|
|
328
|
+
const codes = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
|
|
329
|
+
const traverseId = `traverse-${i + 1}`;
|
|
330
|
+
const ctxOpts = {
|
|
331
|
+
viewport: { width: 1280, height: 720 },
|
|
332
|
+
storageState,
|
|
333
|
+
};
|
|
334
|
+
if (baseURL)
|
|
335
|
+
ctxOpts.baseURL = baseURL;
|
|
336
|
+
const context = await browser.newContext(ctxOpts);
|
|
337
|
+
const page = await context.newPage();
|
|
338
|
+
const mem = new MemPage();
|
|
339
|
+
restoreMem(library.byName, mem, memSnap, options.data);
|
|
340
|
+
if (startUrl) {
|
|
341
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
return await runTraverseWorker({
|
|
345
|
+
traverseId,
|
|
346
|
+
workerIndex: i,
|
|
347
|
+
parallel,
|
|
348
|
+
maxSteps,
|
|
349
|
+
maxVisitsPerNode,
|
|
350
|
+
maxVisitsPerEdge,
|
|
351
|
+
timeoutMs,
|
|
352
|
+
...(options.from ? { from: options.from } : {}),
|
|
353
|
+
...(startUrl ? { startUrl } : {}),
|
|
354
|
+
...(baseURL ? { baseURL } : {}),
|
|
355
|
+
...(options.data ? { data: options.data } : {}),
|
|
356
|
+
graph,
|
|
357
|
+
library,
|
|
358
|
+
engine,
|
|
359
|
+
context,
|
|
360
|
+
page,
|
|
361
|
+
mem,
|
|
362
|
+
leases,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
await context.close().catch(() => { });
|
|
367
|
+
}
|
|
368
|
+
}));
|
|
369
|
+
const worst = codes.reduce((a, b) => Math.max(a, b), 0);
|
|
370
|
+
const pass = codes.filter((c) => c === 0).length;
|
|
371
|
+
console.error(`waygraph traverse: suite parallel=${parallel} pass=${pass}/${parallel} exit=${worst}`);
|
|
372
|
+
return worst;
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
await browser.close().catch(() => { });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"updatedAt": "2026-09-17T11:18:26.658Z",
|
|
3
|
+
"leases": {
|
|
4
|
+
"submit-login": {
|
|
5
|
+
"workerId": "traverse-2",
|
|
6
|
+
"claimedAt": 1789643826618
|
|
7
|
+
},
|
|
8
|
+
"add-all-to-cart": {
|
|
9
|
+
"workerId": "traverse-2",
|
|
10
|
+
"claimedAt": 1789643827327
|
|
11
|
+
},
|
|
12
|
+
"nav-cart": {
|
|
13
|
+
"workerId": "traverse-2",
|
|
14
|
+
"claimedAt": 1789643829570
|
|
15
|
+
},
|
|
16
|
+
"nav-continue-shopping": {
|
|
17
|
+
"workerId": "traverse-2",
|
|
18
|
+
"claimedAt": 1789643855282
|
|
19
|
+
},
|
|
20
|
+
"nav-item-detail::sauce-labs-backpack": {
|
|
21
|
+
"workerId": "traverse-2",
|
|
22
|
+
"claimedAt": 1789643857049
|
|
23
|
+
},
|
|
24
|
+
"nav-back-to-inventory": {
|
|
25
|
+
"workerId": "traverse-2",
|
|
26
|
+
"claimedAt": 1789643906658
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|