@dikshanty94/webnav 0.2.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/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- package/package.json +59 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// The replay engine: plays back a recorded StoredActionEffect[] against a live adapter,
|
|
2
|
+
// auto-paced or step-by-step, pausing for missing creds / commit confirmation / drift.
|
|
3
|
+
// Deps injected (adapter/creds/sleep) so tests drive it with fakes — mirrors live-record.ts.
|
|
4
|
+
import { mkdirSync, copyFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
7
|
+
import { resolveByFingerprint } from '../playwright/fingerprint.js';
|
|
8
|
+
import { didNavigate } from '../explorer/diff.js';
|
|
9
|
+
import { COMMIT_WORDS } from '../explorer/draft.js';
|
|
10
|
+
import { resolveEvent, descriptorName } from './live.js';
|
|
11
|
+
export class ReplayController {
|
|
12
|
+
state;
|
|
13
|
+
nextResolve = null;
|
|
14
|
+
waitResolve = null;
|
|
15
|
+
aborted = false;
|
|
16
|
+
constructor(session, labels) {
|
|
17
|
+
this.state = { session, running: true, mode: 'auto', waiting: null, done: false,
|
|
18
|
+
steps: labels.map((l) => ({ seq: l.seq, label: l.label, status: 'pending', shot: null })) };
|
|
19
|
+
}
|
|
20
|
+
control(action) {
|
|
21
|
+
if (action === 'pause') {
|
|
22
|
+
this.state.mode = 'step';
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (action === 'resume') {
|
|
26
|
+
this.state.mode = 'auto';
|
|
27
|
+
this.nextResolve?.();
|
|
28
|
+
this.nextResolve = null;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (action === 'next') {
|
|
32
|
+
this.nextResolve?.();
|
|
33
|
+
this.nextResolve = null;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (action === 'abort') {
|
|
37
|
+
this.aborted = true;
|
|
38
|
+
this.state.waiting = null;
|
|
39
|
+
this.state.waitingLabel = undefined;
|
|
40
|
+
this.nextResolve?.();
|
|
41
|
+
this.nextResolve = null;
|
|
42
|
+
this.waitResolve?.('abort');
|
|
43
|
+
this.waitResolve = null;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
supply(value, save) {
|
|
49
|
+
if (this.state.waiting !== 'value' || !this.waitResolve)
|
|
50
|
+
return false;
|
|
51
|
+
this.state.waiting = null;
|
|
52
|
+
const r = this.waitResolve;
|
|
53
|
+
this.waitResolve = null;
|
|
54
|
+
r({ value, save });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
confirm(fire) {
|
|
58
|
+
if (this.state.waiting !== 'confirm' || !this.waitResolve)
|
|
59
|
+
return false;
|
|
60
|
+
this.state.waiting = null;
|
|
61
|
+
const r = this.waitResolve;
|
|
62
|
+
this.waitResolve = null;
|
|
63
|
+
r({ fire });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
async gate(sleep, paceMs) {
|
|
67
|
+
if (this.aborted)
|
|
68
|
+
return 'abort';
|
|
69
|
+
if (this.state.mode === 'auto') {
|
|
70
|
+
await sleep(paceMs);
|
|
71
|
+
return this.aborted ? 'abort' : 'go';
|
|
72
|
+
}
|
|
73
|
+
await new Promise((r) => { this.nextResolve = r; });
|
|
74
|
+
return this.aborted ? 'abort' : 'go';
|
|
75
|
+
}
|
|
76
|
+
waitFor(kind, label) {
|
|
77
|
+
this.state.waiting = kind;
|
|
78
|
+
this.state.waitingLabel = label;
|
|
79
|
+
return new Promise((r) => { this.waitResolve = r; });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const normName = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
83
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
84
|
+
/** Best-effort screenshot copy — decoration only, never fails the step. */
|
|
85
|
+
function saveShot(shotsDir, src, seq) {
|
|
86
|
+
const name = `step-${seq}.png`;
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
89
|
+
copyFileSync(src, join(shotsDir, name));
|
|
90
|
+
return name;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function runReplay(effects, ctl, deps) {
|
|
97
|
+
const sleep = deps.sleep ?? realSleep;
|
|
98
|
+
const paceMs = deps.paceMs ?? 1500;
|
|
99
|
+
const st = ctl.state;
|
|
100
|
+
const skipRest = (from) => {
|
|
101
|
+
for (let j = from; j < st.steps.length; j++) {
|
|
102
|
+
if (st.steps[j].status === 'running' || st.steps[j].status === 'pending')
|
|
103
|
+
st.steps[j].status = 'skipped';
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
if (effects.length === 0) {
|
|
108
|
+
st.done = true;
|
|
109
|
+
st.running = false;
|
|
110
|
+
return st;
|
|
111
|
+
}
|
|
112
|
+
await deps.adapter.open(effects[0].fromUrl);
|
|
113
|
+
for (let i = 0; i < effects.length; i++) {
|
|
114
|
+
const e = effects[i];
|
|
115
|
+
const step = st.steps[i];
|
|
116
|
+
step.status = 'running';
|
|
117
|
+
const g = await ctl.gate(sleep, paceMs);
|
|
118
|
+
if (g === 'abort') {
|
|
119
|
+
skipRest(i);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (e.action === null) {
|
|
123
|
+
if (e.navigated) {
|
|
124
|
+
await deps.adapter.goto(e.toUrl);
|
|
125
|
+
step.status = 'jumped';
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
step.status = 'skipped';
|
|
129
|
+
}
|
|
130
|
+
continue; // no gate/screenshot for skipped/jumped null-action steps
|
|
131
|
+
}
|
|
132
|
+
const action = e.action;
|
|
133
|
+
let ref = action.ref ?? null;
|
|
134
|
+
let abortedHere = false;
|
|
135
|
+
if (action.elementFp) {
|
|
136
|
+
// Resolve with ONE human-assisted retry (plan semantics): the first miss pauses
|
|
137
|
+
// (step mode) so the human can fix the live page — dismiss a popup, let a render
|
|
138
|
+
// settle — and hit Next, which re-resolves THIS step. A second miss is final.
|
|
139
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
140
|
+
const nodes = parseSnapshot(await deps.adapter.snapshot());
|
|
141
|
+
ref = resolveByFingerprint(action.elementFp, nodes);
|
|
142
|
+
if (ref || attempt === 1)
|
|
143
|
+
break;
|
|
144
|
+
step.status = 'fail';
|
|
145
|
+
step.note = 'element not found — Next retries once';
|
|
146
|
+
ctl.control('pause');
|
|
147
|
+
const g2 = await ctl.gate(sleep, paceMs);
|
|
148
|
+
if (g2 === 'abort') {
|
|
149
|
+
abortedHere = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
step.status = 'running';
|
|
153
|
+
step.note = undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (abortedHere) {
|
|
157
|
+
skipRest(i);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (!ref) {
|
|
161
|
+
step.status = 'fail';
|
|
162
|
+
step.note = 'element not found';
|
|
163
|
+
ctl.control('pause');
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (action.role === 'textbox') {
|
|
167
|
+
const wantKey = normName(action.name ?? '');
|
|
168
|
+
const creds = deps.creds.get(deps.site);
|
|
169
|
+
let value;
|
|
170
|
+
for (const [k, v] of Object.entries(creds)) {
|
|
171
|
+
if (normName(k) === wantKey) {
|
|
172
|
+
value = v;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// the RECORDED value is the flow's variable — use it when no stored cred
|
|
177
|
+
// overrides (creds are the operator's variable store; secrets were never
|
|
178
|
+
// recorded, so a password still asks unless creds cover it).
|
|
179
|
+
if (value === undefined && typeof action.value === 'string')
|
|
180
|
+
value = action.value;
|
|
181
|
+
if (value === undefined) {
|
|
182
|
+
const answer = await ctl.waitFor('value', action.name ?? step.label);
|
|
183
|
+
if (answer === 'abort') {
|
|
184
|
+
skipRest(i);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
value = answer.value ?? '';
|
|
188
|
+
if (answer.save)
|
|
189
|
+
deps.creds.set(deps.site, { [wantKey]: value });
|
|
190
|
+
}
|
|
191
|
+
await deps.adapter.fill(ref, value);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const label = action.name ?? step.label;
|
|
195
|
+
if (COMMIT_WORDS.test(label)) {
|
|
196
|
+
const answer = await ctl.waitFor('confirm', label);
|
|
197
|
+
if (answer === 'abort') {
|
|
198
|
+
skipRest(i);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
if (!answer.fire) {
|
|
202
|
+
step.status = 'skipped';
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await deps.adapter.click(ref);
|
|
207
|
+
}
|
|
208
|
+
if (e.navigated) {
|
|
209
|
+
const cur = await deps.adapter.currentUrl();
|
|
210
|
+
if (didNavigate(cur, e.toUrl)) {
|
|
211
|
+
step.status = 'fail';
|
|
212
|
+
step.note = 'landed elsewhere';
|
|
213
|
+
ctl.control('pause');
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (deps.shotsDir) {
|
|
218
|
+
const src = await deps.adapter.screenshot();
|
|
219
|
+
if (src)
|
|
220
|
+
step.shot = saveShot(deps.shotsDir, src, e.seq);
|
|
221
|
+
}
|
|
222
|
+
step.status = 'ok';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
// Engine failure (browser would not open, adapter died mid-run): resolve with a
|
|
227
|
+
// terminal error state — a REJECTED replay promise killed the whole dashboard
|
|
228
|
+
// process once (unhandled rejection; live crash 2026-07-15). Never rethrow.
|
|
229
|
+
st.error = String(e.message ?? e);
|
|
230
|
+
skipRest(0);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
st.done = true;
|
|
234
|
+
st.running = false;
|
|
235
|
+
await deps.adapter.close().catch(() => { }); // close on a never-opened session throws too
|
|
236
|
+
}
|
|
237
|
+
return st;
|
|
238
|
+
}
|
|
239
|
+
// --- Ledger replay: play the RAW event stream (including what assembly dropped) ---
|
|
240
|
+
// "Exactly what was done, again": resolution is the SAME deterministic descriptor
|
|
241
|
+
// matching recording used (resolveEvent) — never a stored selector, never a guess.
|
|
242
|
+
// A capture miss replays as a visible failed step, so this runner doubles as a
|
|
243
|
+
// capture-fidelity test (spec 2026-07-16).
|
|
244
|
+
/** A ledger row as a resolvable LiveEvent: human rows ARE LiveEvents; agent rows
|
|
245
|
+
* synthesize one from {role,name} (descriptorName reads ariaLabel first). */
|
|
246
|
+
function asLiveEvent(e) {
|
|
247
|
+
const d = e.descriptor;
|
|
248
|
+
if (e.source === 'human')
|
|
249
|
+
return d;
|
|
250
|
+
return { seq: e.seq, kind: e.kind === 'type' ? 'input' : 'click', tagName: '',
|
|
251
|
+
url: String(d.url ?? ''), role: d.role ?? null,
|
|
252
|
+
ariaLabel: d.name ?? null, leafText: null, href: null,
|
|
253
|
+
placeholder: null, nameAttr: null, inputType: null };
|
|
254
|
+
}
|
|
255
|
+
/** The page URL an event acted on (agent navigate rows act FROM fromUrl). */
|
|
256
|
+
function pageUrlOf(e) {
|
|
257
|
+
const d = e.descriptor;
|
|
258
|
+
return String((e.source === 'agent' && e.kind === 'navigate' ? d.fromUrl : d.url) ?? '');
|
|
259
|
+
}
|
|
260
|
+
export async function runLedgerReplay(events, ctl, deps) {
|
|
261
|
+
const sleep = deps.sleep ?? realSleep;
|
|
262
|
+
const paceMs = deps.paceMs ?? 1500;
|
|
263
|
+
const st = ctl.state;
|
|
264
|
+
const skipRest = (from) => {
|
|
265
|
+
for (let j = from; j < st.steps.length; j++) {
|
|
266
|
+
if (st.steps[j].status === 'running' || st.steps[j].status === 'pending')
|
|
267
|
+
st.steps[j].status = 'skipped';
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
if (events.length === 0) {
|
|
272
|
+
return st;
|
|
273
|
+
}
|
|
274
|
+
const first = events[0];
|
|
275
|
+
await deps.adapter.open(first.source === 'agent' && first.kind === 'navigate'
|
|
276
|
+
? String(first.descriptor.url) : pageUrlOf(first));
|
|
277
|
+
for (let i = 0; i < events.length; i++) {
|
|
278
|
+
const e = events[i];
|
|
279
|
+
const d = e.descriptor;
|
|
280
|
+
const step = st.steps[i];
|
|
281
|
+
step.status = 'running';
|
|
282
|
+
const g = await ctl.gate(sleep, paceMs);
|
|
283
|
+
if (g === 'abort') {
|
|
284
|
+
skipRest(i);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
// expected landing = the page the NEXT event acted on (judgment-free — the
|
|
288
|
+
// ledger's own urls encode the journey)
|
|
289
|
+
const nextUrl = i + 1 < events.length ? pageUrlOf(events[i + 1]) : null;
|
|
290
|
+
if (e.source === 'agent' && e.kind === 'navigate') {
|
|
291
|
+
await deps.adapter.goto(String(d.url));
|
|
292
|
+
step.status = 'jumped';
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const lev = asLiveEvent(e);
|
|
296
|
+
// resolve with ONE human-assisted retry (same semantics as steps replay)
|
|
297
|
+
let ref = null;
|
|
298
|
+
let abortedHere = false;
|
|
299
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
300
|
+
const res = resolveEvent(lev, parseSnapshot(await deps.adapter.snapshot()));
|
|
301
|
+
ref = res && 'ref' in res ? res.ref : null; // candidates = ambiguous = no guess
|
|
302
|
+
if (ref || attempt === 1)
|
|
303
|
+
break;
|
|
304
|
+
step.status = 'fail';
|
|
305
|
+
step.note = 'element not found — Next retries once';
|
|
306
|
+
ctl.control('pause');
|
|
307
|
+
const g2 = await ctl.gate(sleep, paceMs);
|
|
308
|
+
if (g2 === 'abort') {
|
|
309
|
+
abortedHere = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
step.status = 'running';
|
|
313
|
+
step.note = undefined;
|
|
314
|
+
}
|
|
315
|
+
if (abortedHere) {
|
|
316
|
+
skipRest(i);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
if (!ref) {
|
|
320
|
+
step.status = 'fail';
|
|
321
|
+
step.note = 'element not found';
|
|
322
|
+
ctl.control('pause');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const label = descriptorName(lev) ?? step.label;
|
|
326
|
+
if (e.kind === 'input' || e.kind === 'type') {
|
|
327
|
+
let value = typeof d.value === 'string' ? d.value : undefined; // human non-secret variable
|
|
328
|
+
if (value === undefined) {
|
|
329
|
+
const wantKey = normName(label);
|
|
330
|
+
for (const [k, v] of Object.entries(deps.creds.get(deps.site))) {
|
|
331
|
+
if (normName(k) === wantKey) {
|
|
332
|
+
value = v;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (value === undefined) {
|
|
338
|
+
const answer = await ctl.waitFor('value', label);
|
|
339
|
+
if (answer === 'abort') {
|
|
340
|
+
skipRest(i);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
value = answer.value ?? '';
|
|
344
|
+
if (answer.save)
|
|
345
|
+
deps.creds.set(deps.site, { [normName(label)]: value });
|
|
346
|
+
}
|
|
347
|
+
await deps.adapter.fill(ref, value);
|
|
348
|
+
}
|
|
349
|
+
else if (e.kind === 'hover') {
|
|
350
|
+
await deps.adapter.hover(ref);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
if (COMMIT_WORDS.test(label)) {
|
|
354
|
+
const answer = await ctl.waitFor('confirm', label);
|
|
355
|
+
if (answer === 'abort') {
|
|
356
|
+
skipRest(i);
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
if (!answer.fire) {
|
|
360
|
+
step.status = 'skipped';
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
await deps.adapter.click(ref);
|
|
365
|
+
}
|
|
366
|
+
// inputs/hovers never navigate (assembleEffect's guarantee for input; hover is a
|
|
367
|
+
// same-page reveal) — a page change after one of these belongs to the FOLLOWING
|
|
368
|
+
// event, so skip the landing check here rather than false-failing this step.
|
|
369
|
+
if (nextUrl && e.kind !== 'input' && e.kind !== 'type' && e.kind !== 'hover' && didNavigate(pageUrlOf(e), nextUrl)) {
|
|
370
|
+
// bounded settle: a client-side redirect needs a beat before the url is real
|
|
371
|
+
let cur = await deps.adapter.currentUrl();
|
|
372
|
+
for (let w = 0; w < 3 && didNavigate(cur, nextUrl); w++) {
|
|
373
|
+
await sleep(700);
|
|
374
|
+
cur = await deps.adapter.currentUrl();
|
|
375
|
+
}
|
|
376
|
+
if (didNavigate(cur, nextUrl)) {
|
|
377
|
+
step.status = 'fail';
|
|
378
|
+
step.note = 'landed elsewhere';
|
|
379
|
+
ctl.control('pause');
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (deps.shotsDir) {
|
|
384
|
+
const src = await deps.adapter.screenshot();
|
|
385
|
+
if (src)
|
|
386
|
+
step.shot = saveShot(deps.shotsDir, src, e.seq);
|
|
387
|
+
}
|
|
388
|
+
step.status = 'ok';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
st.error = String(e.message ?? e); // same never-reject contract as runReplay
|
|
393
|
+
skipRest(0);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
st.done = true;
|
|
397
|
+
st.running = false;
|
|
398
|
+
await deps.adapter.close().catch(() => { });
|
|
399
|
+
}
|
|
400
|
+
return st;
|
|
401
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import type { Coverage, LandingStructure } from './coverage.js';
|
|
3
|
+
declare const run: typeof execFile.__promisify__;
|
|
4
|
+
export interface ReviewStepInfo {
|
|
5
|
+
seq: number;
|
|
6
|
+
kind: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
capturedAt: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ReviewFrame {
|
|
12
|
+
path: string;
|
|
13
|
+
atMs: number;
|
|
14
|
+
}
|
|
15
|
+
/** Parse ffmpeg showinfo stderr → the pts seconds of each selected frame (in order). */
|
|
16
|
+
export declare function parseShowinfoTimes(stderr: string): number[];
|
|
17
|
+
/** The audit task — shown to the operator in the dashboard and editable per run.
|
|
18
|
+
* This is the GOAL: compare the video (frames) against the captured steps and
|
|
19
|
+
* surface CAPTURE GAPS — visible changes with no recorded step. */
|
|
20
|
+
export declare const DEFAULT_INSTRUCTIONS = "Your job \u2014 compare what the video SHOWS against what was CAPTURED:\n1. For each frame, say what user action most likely produced that screen state.\n2. CAPTURE GAPS: visible changes in the frames with NO captured step near that\n timestamp (\u00B15s). These are recorder misses \u2014 the deliverable. Be specific:\n what happened on screen, when, and what kind of event the recorder should\n have caught (click / input / navigation / scroll / hover-menu ...).\n3. Steps with no visual correlate in any frame (possible over-capture or noise).\n4. A short verdict: is this recording complete enough to replay the user's\n journey? What single capture improvement would help most?\n\nFormat as markdown with sections: Frames, Capture gaps, Uncorrelated steps, Verdict.\nBe concrete and terse. If the evidence is thin (few frames/steps), say so honestly.";
|
|
21
|
+
export interface CaptureGap {
|
|
22
|
+
atMs?: number;
|
|
23
|
+
kind?: string;
|
|
24
|
+
whatHappened?: string;
|
|
25
|
+
shouldHaveCaptured?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Tolerant extraction of the gap JSON from a review reply (may be wrapped in
|
|
28
|
+
* prose/markdown). Returns [] on absence/parse failure (never throws). */
|
|
29
|
+
export declare function parseGaps(text: string): CaptureGap[];
|
|
30
|
+
/** The audit prompt. Pure — unit-tested; the spawn stays thin. */
|
|
31
|
+
export declare function buildReviewPrompt(session: string, steps: ReviewStepInfo[], logLines: {
|
|
32
|
+
t: number;
|
|
33
|
+
line: string;
|
|
34
|
+
}[], frames: ReviewFrame[], instructions?: string, structured?: boolean, knownDrops?: Coverage['dropped'], structure?: LandingStructure[]): string;
|
|
35
|
+
export interface ReviewDeps {
|
|
36
|
+
videosDir: string;
|
|
37
|
+
outDir: string;
|
|
38
|
+
steps: ReviewStepInfo[];
|
|
39
|
+
logs: {
|
|
40
|
+
t: number;
|
|
41
|
+
line: string;
|
|
42
|
+
}[];
|
|
43
|
+
log: (line: string) => void;
|
|
44
|
+
claudeModel?: string;
|
|
45
|
+
instructions?: string;
|
|
46
|
+
maxFrames?: number;
|
|
47
|
+
structured?: boolean;
|
|
48
|
+
knownDrops?: Coverage['dropped'];
|
|
49
|
+
coverage?: Coverage;
|
|
50
|
+
structure?: LandingStructure[];
|
|
51
|
+
exec?: typeof run;
|
|
52
|
+
}
|
|
53
|
+
/** Extract scene-change frames from one take. Returns frames with ABSOLUTE wall-clock
|
|
54
|
+
* times. The ts in take-<ts>.webm is the STOP/SAVE time (Date.now() at videoStop),
|
|
55
|
+
* NOT the start — so the true start is reconstructed as stop − ffprobe duration,
|
|
56
|
+
* and each frame's time is that true start + its pts offset. */
|
|
57
|
+
/** The frame-selection expression. Web-UI tuned (live finding: a 10-min browsing
|
|
58
|
+
* take yielded 4 frames at scene>0.08 — movie-cut thresholds miss typing/menus/
|
|
59
|
+
* scroll): >2% change counts, a min-gap rate-limits bursts (a playing video would
|
|
60
|
+
* otherwise eat the whole frame budget in seconds), and a HEARTBEAT guarantees
|
|
61
|
+
* one frame per interval across the entire timeline even when nothing trips the
|
|
62
|
+
* threshold — full coverage for the gap audit. Pure: unit-tested. */
|
|
63
|
+
export declare function frameSelectExpr(durationS: number, maxFrames: number): {
|
|
64
|
+
expr: string;
|
|
65
|
+
minGapS: number;
|
|
66
|
+
heartbeatS: number;
|
|
67
|
+
};
|
|
68
|
+
export declare function extractFrames(takePath: string, takeStopMs: number, framesDir: string, maxFrames: number, exec: typeof run): Promise<ReviewFrame[]>;
|
|
69
|
+
export declare function runSessionReview(session: string, deps: ReviewDeps): Promise<string | {
|
|
70
|
+
report: string;
|
|
71
|
+
gaps: CaptureGap[];
|
|
72
|
+
}>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Session review: audit what the recorder CAPTURED against what the video SHOWS.
|
|
2
|
+
// Pipeline: ffmpeg scene-detection extracts a compact FRAME STRIP from each video
|
|
3
|
+
// take (Claude cannot ingest video — stills at change-moments are the consumable
|
|
4
|
+
// form, and better for gap-finding since each frame carries a timestamp to match
|
|
5
|
+
// against step times) → a headless `claude -p` (Sonnet, per user decision; this is
|
|
6
|
+
// a judgment task OUTSIDE webnav's zero-LLM runtime — it audits the tool, it is
|
|
7
|
+
// not part of navigation) reads steps+logs+frames and reports capture gaps.
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
/** Parse ffmpeg showinfo stderr → the pts seconds of each selected frame (in order). */
|
|
14
|
+
export function parseShowinfoTimes(stderr) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const m of stderr.matchAll(/pts_time:([\d.]+)/g))
|
|
17
|
+
out.push(Number(m[1]));
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** The audit task — shown to the operator in the dashboard and editable per run.
|
|
21
|
+
* This is the GOAL: compare the video (frames) against the captured steps and
|
|
22
|
+
* surface CAPTURE GAPS — visible changes with no recorded step. */
|
|
23
|
+
export const DEFAULT_INSTRUCTIONS = `Your job — compare what the video SHOWS against what was CAPTURED:
|
|
24
|
+
1. For each frame, say what user action most likely produced that screen state.
|
|
25
|
+
2. CAPTURE GAPS: visible changes in the frames with NO captured step near that
|
|
26
|
+
timestamp (±5s). These are recorder misses — the deliverable. Be specific:
|
|
27
|
+
what happened on screen, when, and what kind of event the recorder should
|
|
28
|
+
have caught (click / input / navigation / scroll / hover-menu ...).
|
|
29
|
+
3. Steps with no visual correlate in any frame (possible over-capture or noise).
|
|
30
|
+
4. A short verdict: is this recording complete enough to replay the user's
|
|
31
|
+
journey? What single capture improvement would help most?
|
|
32
|
+
|
|
33
|
+
Format as markdown with sections: Frames, Capture gaps, Uncorrelated steps, Verdict.
|
|
34
|
+
Be concrete and terse. If the evidence is thin (few frames/steps), say so honestly.`;
|
|
35
|
+
/** Append the structured-output instruction: return a JSON gap list the capture
|
|
36
|
+
* loop can parse to judge convergence (zero gaps = complete). */
|
|
37
|
+
const STRUCTURED_TAIL = `
|
|
38
|
+
|
|
39
|
+
OUTPUT FORMAT (STRICT): after any brief reasoning, end your reply with ONE JSON
|
|
40
|
+
object on its own, exactly:
|
|
41
|
+
{"gaps":[{"atMs":<frame time ms>,"kind":"click|input|navigation|scroll|hover-menu|other","whatHappened":"...","shouldHaveCaptured":"..."}],"verdict":"..."}
|
|
42
|
+
A gap = a visible change in the frames with NO captured step within ±5s. If capture
|
|
43
|
+
is complete, return {"gaps":[],"verdict":"complete"}. Emit NOTHING after the JSON.`;
|
|
44
|
+
/** Tolerant extraction of the gap JSON from a review reply (may be wrapped in
|
|
45
|
+
* prose/markdown). Returns [] on absence/parse failure (never throws). */
|
|
46
|
+
export function parseGaps(text) {
|
|
47
|
+
// last {...} block that parses and has a `gaps` array wins (the model ends with it)
|
|
48
|
+
const matches = text.match(/\{[\s\S]*\}/g);
|
|
49
|
+
if (!matches)
|
|
50
|
+
return [];
|
|
51
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
52
|
+
try {
|
|
53
|
+
const o = JSON.parse(matches[i]);
|
|
54
|
+
if (Array.isArray(o.gaps))
|
|
55
|
+
return o.gaps;
|
|
56
|
+
}
|
|
57
|
+
catch { /* try the next candidate */ }
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
/** The audit prompt. Pure — unit-tested; the spawn stays thin. */
|
|
62
|
+
export function buildReviewPrompt(session, steps, logLines, frames, instructions, structured, knownDrops, structure) {
|
|
63
|
+
const t = (ms) => new Date(ms).toLocaleTimeString();
|
|
64
|
+
const stepTxt = steps.length
|
|
65
|
+
? steps.map((s) => `- [${t(s.capturedAt)}] seq ${s.seq} ${s.kind}: ${s.label}${s.value !== undefined ? ` = "${s.value}"` : ''}`).join('\n')
|
|
66
|
+
: '(no steps captured)';
|
|
67
|
+
const logTxt = logLines.map((l) => `- [${t(l.t)}] ${l.line}`).join('\n') || '(no logs)';
|
|
68
|
+
const frameTxt = frames.map((f, i) => `- frame ${i + 1} at ${t(f.atMs)}: ${f.path}`).join('\n') || '(no frames extracted)';
|
|
69
|
+
const dropTxt = knownDrops?.length
|
|
70
|
+
? `\nASSEMBLY DROPS (already known — measured deterministically; do NOT re-report these as gaps):\n${knownDrops.map((d) => `- seq ${d.seq} ${d.kind}: ${d.label ?? '(unlabeled)'} — ${d.reason}`).join('\n')}\n`
|
|
71
|
+
: '';
|
|
72
|
+
const structureTxt = structure?.length
|
|
73
|
+
? `\nLANDING STRUCTURE (named vs NAMELESS interactive controls per page — many nameless controls = a sensor gap; compare against what the frames show):\n${structure.map((s) => `- ${s.url} — named: ${s.named}, nameless: ${s.nameless}`).join('\n')}\n`
|
|
74
|
+
: '';
|
|
75
|
+
return `You are auditing a browser-session RECORDER for capture gaps. A human browsed a website
|
|
76
|
+
while our tool recorded their actions as "steps". We also have a screen video of the same
|
|
77
|
+
session, from which scene-change frames were extracted (one image per visible change,
|
|
78
|
+
each with its wall-clock timestamp).
|
|
79
|
+
|
|
80
|
+
Session: ${session}
|
|
81
|
+
|
|
82
|
+
CAPTURED STEPS (what our recorder saved):
|
|
83
|
+
${stepTxt}
|
|
84
|
+
|
|
85
|
+
RECORDER LOGS (including honest skips):
|
|
86
|
+
${logTxt}
|
|
87
|
+
|
|
88
|
+
VIDEO FRAMES (visible changes; READ each image file with the Read tool):
|
|
89
|
+
${frameTxt}
|
|
90
|
+
${dropTxt}${structureTxt}
|
|
91
|
+
${instructions ?? DEFAULT_INSTRUCTIONS}${structured ? STRUCTURED_TAIL : ''}`;
|
|
92
|
+
}
|
|
93
|
+
/** Extract scene-change frames from one take. Returns frames with ABSOLUTE wall-clock
|
|
94
|
+
* times. The ts in take-<ts>.webm is the STOP/SAVE time (Date.now() at videoStop),
|
|
95
|
+
* NOT the start — so the true start is reconstructed as stop − ffprobe duration,
|
|
96
|
+
* and each frame's time is that true start + its pts offset. */
|
|
97
|
+
/** The frame-selection expression. Web-UI tuned (live finding: a 10-min browsing
|
|
98
|
+
* take yielded 4 frames at scene>0.08 — movie-cut thresholds miss typing/menus/
|
|
99
|
+
* scroll): >2% change counts, a min-gap rate-limits bursts (a playing video would
|
|
100
|
+
* otherwise eat the whole frame budget in seconds), and a HEARTBEAT guarantees
|
|
101
|
+
* one frame per interval across the entire timeline even when nothing trips the
|
|
102
|
+
* threshold — full coverage for the gap audit. Pure: unit-tested. */
|
|
103
|
+
export function frameSelectExpr(durationS, maxFrames) {
|
|
104
|
+
// budget ~25% of frames for scene-changes so bursts can't cut off tail coverage
|
|
105
|
+
const heartbeatS = Math.max(5, Math.ceil(durationS / Math.max(1, Math.floor(maxFrames * 0.75))));
|
|
106
|
+
const minGapS = Math.max(2, Math.floor(heartbeatS / 3));
|
|
107
|
+
const expr = `isnan(prev_selected_t)+(gt(scene\,0.02)+gte(t-prev_selected_t\,${heartbeatS}))*gte(t-prev_selected_t\,${minGapS})`;
|
|
108
|
+
return { expr, minGapS, heartbeatS };
|
|
109
|
+
}
|
|
110
|
+
export async function extractFrames(takePath, takeStopMs, framesDir, maxFrames, exec) {
|
|
111
|
+
mkdirSync(framesDir, { recursive: true });
|
|
112
|
+
let durationS = 0;
|
|
113
|
+
try {
|
|
114
|
+
const pr = await exec('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', takePath]);
|
|
115
|
+
durationS = Number(String(pr.stdout).trim()) || 0;
|
|
116
|
+
}
|
|
117
|
+
catch { /* unknown duration → trueStartMs falls back to takeStopMs (uncorrected, no worse than before) */ }
|
|
118
|
+
const trueStartMs = takeStopMs - Math.round(durationS * 1000);
|
|
119
|
+
const { expr } = frameSelectExpr(durationS, maxFrames);
|
|
120
|
+
const vf = `select='${expr}',showinfo,scale=800:-2`;
|
|
121
|
+
let stderr = '';
|
|
122
|
+
try {
|
|
123
|
+
const r = await exec('ffmpeg', ['-y', '-i', takePath, '-vf', vf, '-fps_mode', 'vfr',
|
|
124
|
+
'-frames:v', String(maxFrames), join(framesDir, 'f-%03d.png')], { maxBuffer: 32 * 1024 * 1024 });
|
|
125
|
+
stderr = r.stderr ?? '';
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// ffmpeg exits non-zero on some streams even after writing frames — keep what landed
|
|
129
|
+
stderr = e.stderr ?? '';
|
|
130
|
+
}
|
|
131
|
+
const times = parseShowinfoTimes(stderr);
|
|
132
|
+
const files = readdirSync(framesDir).filter((f) => f.endsWith('.png')).sort();
|
|
133
|
+
return files.map((f, i) => ({ path: join(framesDir, f), atMs: trueStartMs + Math.round((times[i] ?? 0) * 1000) }));
|
|
134
|
+
}
|
|
135
|
+
export async function runSessionReview(session, deps) {
|
|
136
|
+
const exec = deps.exec ?? run;
|
|
137
|
+
const maxFrames = deps.maxFrames ?? 20;
|
|
138
|
+
deps.log(`review: extracting frames for ${session}…`);
|
|
139
|
+
let takes = [];
|
|
140
|
+
try {
|
|
141
|
+
takes = readdirSync(deps.videosDir).filter((f) => f.endsWith('.webm')).sort();
|
|
142
|
+
}
|
|
143
|
+
catch { /* no videos */ }
|
|
144
|
+
const frames = [];
|
|
145
|
+
for (const take of takes) {
|
|
146
|
+
const stopMs = Number((/take-(\d+)\.webm/.exec(take) ?? [])[1] ?? 0);
|
|
147
|
+
const dir = join(deps.outDir, 'frames-' + take.replace(/\.webm$/, ''));
|
|
148
|
+
const got = await extractFrames(join(deps.videosDir, take), stopMs, dir, maxFrames, exec);
|
|
149
|
+
frames.push(...got);
|
|
150
|
+
deps.log(`review: ${got.length} change-frames from ${take}`);
|
|
151
|
+
}
|
|
152
|
+
const prompt = buildReviewPrompt(session, deps.steps, deps.logs, frames, deps.instructions, deps.structured, deps.knownDrops, deps.structure);
|
|
153
|
+
deps.log(`review: asking Claude (${deps.claudeModel ?? 'sonnet'}) — ${frames.length} frames, ${deps.steps.length} steps…`);
|
|
154
|
+
let report;
|
|
155
|
+
try {
|
|
156
|
+
const { stdout } = await exec('claude', ['-p', prompt, '--model', deps.claudeModel ?? 'sonnet', '--allowedTools', 'Read'], { maxBuffer: 32 * 1024 * 1024, timeout: 5 * 60_000 });
|
|
157
|
+
report = stdout.trim() || '(claude returned no output)';
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
report = 'REVIEW FAILED: ' + String(e.message ?? e);
|
|
161
|
+
}
|
|
162
|
+
mkdirSync(deps.outDir, { recursive: true });
|
|
163
|
+
writeFileSync(join(deps.outDir, 'review.md'), report);
|
|
164
|
+
deps.log('review: done — report saved');
|
|
165
|
+
if (deps.structured) {
|
|
166
|
+
const gaps = parseGaps(report);
|
|
167
|
+
writeFileSync(join(deps.outDir, 'review.json'), JSON.stringify({ gaps, coverage: deps.coverage ?? null, structure: deps.structure ?? null }, null, 2));
|
|
168
|
+
return { report, gaps };
|
|
169
|
+
}
|
|
170
|
+
return report;
|
|
171
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/recorder/snapshot-dom.ts
|
|
2
|
+
// Serialize an accessibility tree to the playwright-parity snapshot text that
|
|
3
|
+
// src/playwright/snapshot.ts::parseSnapshot reads. Pure + deterministic; the
|
|
4
|
+
// in-repo test oracle for the extension's browser-side twin.
|
|
5
|
+
// TWIN of webnav-extension/serialize.ts::serialize — MUST stay in sync (same
|
|
6
|
+
// ~15-line algorithm, duplicated deliberately; see the ponytail note there).
|
|
7
|
+
// Assign a sequential synthetic ref (e1, e2, …) to EVERY node in pre-order, so
|
|
8
|
+
// even nameless icon nodes carry a bracketed attr and survive parseSnapshot
|
|
9
|
+
// (which drops a line that has neither a quoted name nor a [attr]).
|
|
10
|
+
export function serializeSnapshot(root) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
let counter = 0;
|
|
13
|
+
const walk = (node, depth) => {
|
|
14
|
+
const ref = `e${++counter}`;
|
|
15
|
+
const indent = ' '.repeat(depth);
|
|
16
|
+
const namePart = node.name !== null ? ` "${node.name}"` : '';
|
|
17
|
+
lines.push(`${indent}${node.role}${namePart} [ref=${ref}]`);
|
|
18
|
+
if (node.url)
|
|
19
|
+
lines.push(`${indent} /url: ${node.url}`);
|
|
20
|
+
for (const child of node.children ?? [])
|
|
21
|
+
walk(child, depth + 1);
|
|
22
|
+
};
|
|
23
|
+
walk(root, 0);
|
|
24
|
+
return lines.join('\n');
|
|
25
|
+
}
|