@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,100 @@
|
|
|
1
|
+
// X2 — hover / right-click reveal probe (spec 2026-07-16 §2). A hover mega-menu or a
|
|
2
|
+
// context menu appears in NO settled snapshot and declares no trigger, so the map
|
|
3
|
+
// honestly omits a site's primary nav. This opt-in pass, over the CURRENT page of a
|
|
4
|
+
// live recording session, hovers (or right-clicks) each STRUCTURAL candidate, diffs
|
|
5
|
+
// the reveal, and records an ActionEffect the draft already turns into a reveal
|
|
6
|
+
// affordance (draftFromEffects is diff-driven — the `hover`/`rightClick` markers only
|
|
7
|
+
// name the kind; the reveal decision is behavioral).
|
|
8
|
+
//
|
|
9
|
+
// REVEAL ONLY: the probe never clicks anything INSIDE a revealed menu (commit rule #2).
|
|
10
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
11
|
+
import { diffSnapshots } from '../explorer/diff.js';
|
|
12
|
+
import { settleSnapshot } from '../router/browse.js';
|
|
13
|
+
// Landmark roles whose named interactive descendants are primary-nav triggers worth probing.
|
|
14
|
+
const LANDMARK_ROLES = new Set(['banner', 'navigation']);
|
|
15
|
+
// Roles that are a real interactive control (so "named interactive inside a landmark" is honest).
|
|
16
|
+
const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'combobox', 'checkbox', 'radio', 'switch']);
|
|
17
|
+
const HASPOPUP_RE = /\[aria-haspopup(?:=|\])/;
|
|
18
|
+
// Nearest-lower-depth ancestor walk (the insideOverlay idiom in infer.ts): is node `idx`
|
|
19
|
+
// inside a banner/navigation landmark subtree? Depth = leading-space count, so an ancestor
|
|
20
|
+
// is any earlier node at a strictly smaller depth.
|
|
21
|
+
function insideLandmark(nodes, idx) {
|
|
22
|
+
let cur = nodes[idx].depth;
|
|
23
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
24
|
+
if (nodes[i].depth < cur) {
|
|
25
|
+
if (LANDMARK_ROLES.has(nodes[i].role))
|
|
26
|
+
return true;
|
|
27
|
+
cur = nodes[i].depth;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** STRUCTURAL, judgment-free candidate selection: nodes with aria-haspopup, menuitems, and
|
|
33
|
+
* named interactive nodes inside a banner/navigation landmark. Requires a ref (can only hover
|
|
34
|
+
* a resolvable element), deduped by ref, capped (default 12). Zero cost on a page with none. */
|
|
35
|
+
export function hoverCandidates(nodes, limit = 12) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
39
|
+
const n = nodes[i];
|
|
40
|
+
if (!n.ref || seen.has(n.ref))
|
|
41
|
+
continue;
|
|
42
|
+
const isCandidate = HASPOPUP_RE.test(n.raw) // declares a popup
|
|
43
|
+
|| n.role === 'menuitem' // a menu entry (may open a submenu)
|
|
44
|
+
|| (!!n.name && INTERACTIVE_ROLES.has(n.role) && insideLandmark(nodes, i)); // primary-nav trigger
|
|
45
|
+
if (!isCandidate)
|
|
46
|
+
continue;
|
|
47
|
+
seen.add(n.ref);
|
|
48
|
+
out.push(n);
|
|
49
|
+
if (out.length >= limit)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Probe the CURRENT page: per candidate, take a baseline snapshot, hover (or right-click),
|
|
55
|
+
* settle, diff; a non-empty ADDED diff records a reveal effect + a ledger event. Between
|
|
56
|
+
* candidates we press Escape to dismiss the just-opened overlay so reveals don't stack
|
|
57
|
+
* (Escape is the honest neutralizer — a re-hover of some "neutral" node is itself a guess
|
|
58
|
+
* at what's neutral; Escape closes menus/popovers without moving the pointer anywhere real). */
|
|
59
|
+
export async function runHoverProbe(deps) {
|
|
60
|
+
const { adapter, store, sessionId, rightClick, log } = deps;
|
|
61
|
+
const baseline = await adapter.snapshot();
|
|
62
|
+
const url = await adapter.currentUrl();
|
|
63
|
+
const candidates = hoverCandidates(parseSnapshot(baseline), deps.limit);
|
|
64
|
+
const kind = rightClick ? 'right-click' : 'hover';
|
|
65
|
+
log(`${kind}-probe: ${candidates.length} candidate(s) on ${url}`);
|
|
66
|
+
let revealed = 0;
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
const ref = c.ref;
|
|
69
|
+
// Ledger the intent BEFORE acting (same discipline as the agent-session hover branch):
|
|
70
|
+
// a probe that fails to reveal still leaves an honest trace of what we tried.
|
|
71
|
+
const led = store.appendEvent(sessionId, {
|
|
72
|
+
source: 'agent', kind,
|
|
73
|
+
descriptor: { cmd: kind, ref, role: c.role, name: c.name, url },
|
|
74
|
+
});
|
|
75
|
+
const from = await adapter.snapshot(); // re-baseline per candidate (a prior Escape may have changed the page)
|
|
76
|
+
if (rightClick)
|
|
77
|
+
await adapter.rightClick(ref);
|
|
78
|
+
else
|
|
79
|
+
await adapter.hover(ref);
|
|
80
|
+
const to = await settleSnapshot(() => adapter.snapshot());
|
|
81
|
+
const diff = diffSnapshots(parseSnapshot(from), parseSnapshot(to));
|
|
82
|
+
if (diff.added.length > 0) {
|
|
83
|
+
const action = { role: c.role, name: c.name, ref, ...(rightClick ? { rightClick: true } : { hover: true }) };
|
|
84
|
+
const seq = store.appendActionEffect(sessionId, {
|
|
85
|
+
fromUrl: url, fromSnapshot: from, action, toUrl: url, toSnapshot: to, navigated: false, diff,
|
|
86
|
+
});
|
|
87
|
+
if (led != null && seq != null)
|
|
88
|
+
store.stampEvent(sessionId, led, 'step:' + seq);
|
|
89
|
+
revealed++;
|
|
90
|
+
log(` ${kind} ${c.name ?? ref}: +${diff.added.length} node(s) → reveal`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// honest no-op: a candidate that reveals nothing records no affordance.
|
|
94
|
+
if (led != null)
|
|
95
|
+
store.stampEvent(sessionId, led, 'dropped:no-reveal');
|
|
96
|
+
}
|
|
97
|
+
await adapter.press('Escape').catch(() => { }); // neutralize so the next candidate's diff is clean
|
|
98
|
+
}
|
|
99
|
+
return { probed: candidates.length, revealed };
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { type SnapNode } from '../playwright/snapshot.js';
|
|
3
|
+
import { type AXNode } from '../playwright/ax-adapter.js';
|
|
4
|
+
import type { ActionEffect } from '../mapstore/record.js';
|
|
5
|
+
import { RecordStore } from '../mapstore/record.js';
|
|
6
|
+
export interface RawStep {
|
|
7
|
+
fromUrl: string;
|
|
8
|
+
fromSnapshot: string;
|
|
9
|
+
toUrl: string;
|
|
10
|
+
toSnapshot: string;
|
|
11
|
+
ref: string | null;
|
|
12
|
+
navigated?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface IngestBody {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
steps: RawStep[];
|
|
17
|
+
}
|
|
18
|
+
export interface RawAXStep {
|
|
19
|
+
fromUrl: string;
|
|
20
|
+
fromAX: AXNode[];
|
|
21
|
+
toUrl: string;
|
|
22
|
+
toAX: AXNode[];
|
|
23
|
+
clickedRef?: string | null;
|
|
24
|
+
tMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface IngestAXBody {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
steps: RawAXStep[];
|
|
29
|
+
}
|
|
30
|
+
/** Shared reconstruction core: fingerprint the clicked node + diff the landing,
|
|
31
|
+
* independent of what produced the SnapNode[] (playwright YAML or adapted AX).
|
|
32
|
+
* NOTE: do not add exact-string URL equality here — CDP gives browser-resolved
|
|
33
|
+
* absolute URLs, playwright gives raw hrefs; didNavigate already compares
|
|
34
|
+
* host+pathname only, which both producers satisfy. */
|
|
35
|
+
export declare function reconstructEffectFromNodes(fromNodes: SnapNode[], toNodes: SnapNode[], fromUrl: string, toUrl: string, clickedRef: string | null): ActionEffect;
|
|
36
|
+
export declare function reconstructEffect(step: RawStep): ActionEffect;
|
|
37
|
+
export declare function ingest(body: IngestBody, store: RecordStore): number;
|
|
38
|
+
export declare function ingestAX(body: IngestAXBody, store: RecordStore): number;
|
|
39
|
+
export declare function serveIngest(port: number, store: RecordStore): http.Server;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Server-side ingest: turn raw recorded browser steps into stored ActionEffects.
|
|
2
|
+
// The extension stays dumb — fingerprint + diff are reconstructed here from the
|
|
3
|
+
// snapshots, reusing tested code (recoverFingerprint, diffSnapshots).
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
6
|
+
import { recoverFingerprint } from '../playwright/fingerprint.js';
|
|
7
|
+
import { diffSnapshots, didNavigate } from '../explorer/diff.js';
|
|
8
|
+
import { adaptAXTree } from '../playwright/ax-adapter.js';
|
|
9
|
+
/** SnapNode[] → playwright-ish snapshot text, using each node's own `.raw` line
|
|
10
|
+
* re-indented by `.depth`. This is the inverse of parseSnapshot, so AX-sourced
|
|
11
|
+
* effects store the same text format DOM-walk effects do (draft.ts/coverage.ts
|
|
12
|
+
* re-parse fromSnapshot/toSnapshot downstream regardless of producer). */
|
|
13
|
+
function serializeNodes(nodes) {
|
|
14
|
+
return nodes.map((n) => ' '.repeat(n.depth) + n.raw).join('\n');
|
|
15
|
+
}
|
|
16
|
+
/** Shared reconstruction core: fingerprint the clicked node + diff the landing,
|
|
17
|
+
* independent of what produced the SnapNode[] (playwright YAML or adapted AX).
|
|
18
|
+
* NOTE: do not add exact-string URL equality here — CDP gives browser-resolved
|
|
19
|
+
* absolute URLs, playwright gives raw hrefs; didNavigate already compares
|
|
20
|
+
* host+pathname only, which both producers satisfy. */
|
|
21
|
+
export function reconstructEffectFromNodes(fromNodes, toNodes, fromUrl, toUrl, clickedRef) {
|
|
22
|
+
let action = null;
|
|
23
|
+
if (clickedRef) {
|
|
24
|
+
const node = fromNodes.find((n) => n.ref === clickedRef) ?? null;
|
|
25
|
+
action = {
|
|
26
|
+
role: node?.role ?? '', name: node?.name ?? null, ref: clickedRef,
|
|
27
|
+
elementFp: recoverFingerprint(fromNodes, clickedRef),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
fromUrl, fromSnapshot: serializeNodes(fromNodes),
|
|
32
|
+
action,
|
|
33
|
+
toUrl, toSnapshot: serializeNodes(toNodes),
|
|
34
|
+
navigated: didNavigate(fromUrl, toUrl),
|
|
35
|
+
diff: diffSnapshots(fromNodes, toNodes),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function reconstructEffect(step) {
|
|
39
|
+
const fromNodes = parseSnapshot(step.fromSnapshot);
|
|
40
|
+
const toNodes = parseSnapshot(step.toSnapshot);
|
|
41
|
+
const fx = reconstructEffectFromNodes(fromNodes, toNodes, step.fromUrl, step.toUrl, step.ref);
|
|
42
|
+
// parseSnapshot's own text is already canonical — keep the original bytes rather
|
|
43
|
+
// than the re-serialized (equivalent but not byte-identical) form.
|
|
44
|
+
return { ...fx, fromSnapshot: step.fromSnapshot, toSnapshot: step.toSnapshot };
|
|
45
|
+
}
|
|
46
|
+
export function ingest(body, store) {
|
|
47
|
+
store.clearSession(body.sessionId); // re-ingest into the same session replaces, never appends duplicates
|
|
48
|
+
store.start(body.sessionId);
|
|
49
|
+
let n = 0;
|
|
50
|
+
for (const step of body.steps) {
|
|
51
|
+
store.appendActionEffect(body.sessionId, reconstructEffect(step));
|
|
52
|
+
n++;
|
|
53
|
+
}
|
|
54
|
+
store.stop(body.sessionId);
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
export function ingestAX(body, store) {
|
|
58
|
+
store.clearSession(body.sessionId);
|
|
59
|
+
store.start(body.sessionId);
|
|
60
|
+
let n = 0;
|
|
61
|
+
for (const step of body.steps) {
|
|
62
|
+
const fromNodes = adaptAXTree(step.fromAX);
|
|
63
|
+
const toNodes = adaptAXTree(step.toAX);
|
|
64
|
+
const fx = reconstructEffectFromNodes(fromNodes, toNodes, step.fromUrl, step.toUrl, step.clickedRef ?? null);
|
|
65
|
+
// Per-step wall-clock time (from the extension). Without it every step got the single
|
|
66
|
+
// flush-time Date.now(), so the dashboard showed them all at the same second.
|
|
67
|
+
const tMs = step.tMs;
|
|
68
|
+
// Ledger event so the Raw pane isn't empty for extension runs: each recorded step IS
|
|
69
|
+
// one event, captured as a step (1:1, no drops in the AX path). kind = navigate when the
|
|
70
|
+
// step changed page, else action; descriptor carries the acted element's identity.
|
|
71
|
+
const kind = fx.navigated ? 'navigate' : 'action';
|
|
72
|
+
const descriptor = fx.action
|
|
73
|
+
? { role: fx.action.role, name: fx.action.name, url: fx.toUrl }
|
|
74
|
+
: { url: fx.toUrl };
|
|
75
|
+
const evSeq = store.appendEvent(body.sessionId, { source: 'agent', kind, descriptor, t: tMs }, tMs);
|
|
76
|
+
const stepSeq = store.appendActionEffect(body.sessionId, fx, tMs);
|
|
77
|
+
if (evSeq != null && stepSeq != null)
|
|
78
|
+
store.stampEvent(body.sessionId, evSeq, 'step:' + stepSeq);
|
|
79
|
+
n++;
|
|
80
|
+
}
|
|
81
|
+
store.stop(body.sessionId);
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
// Localhost-only receiver: the webnav-extension Chrome extension POSTs recorded
|
|
85
|
+
// steps here; they land in webnav.db as ActionEffects via `ingest`, identical
|
|
86
|
+
// to agent-recorded ones. No auth — localhost-only, no secrets in transit
|
|
87
|
+
// beyond the map itself.
|
|
88
|
+
// note: this is still the live `webnav dev ingest` verb (src/cli.ts), a separate
|
|
89
|
+
// surface from agent-serve's own /ingest-ax mount. Its only in-extension caller was
|
|
90
|
+
// the Phase-1 popup, removed 2026-07-19 (see webnav-extension/README.md) — the
|
|
91
|
+
// wildcard CORS here is unrelated to that removal and is left as-is (out of scope).
|
|
92
|
+
export function serveIngest(port, store) {
|
|
93
|
+
const server = http.createServer((req, res) => {
|
|
94
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
95
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
96
|
+
if (req.method === 'OPTIONS') {
|
|
97
|
+
res.writeHead(204).end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (req.method !== 'POST' || (req.url !== '/ingest' && req.url !== '/ingest-ax')) {
|
|
101
|
+
res.writeHead(404).end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const isAX = req.url === '/ingest-ax';
|
|
105
|
+
let raw = '';
|
|
106
|
+
// ponytail: 50MB cap — bounds an unbounded body on this long-running receiver
|
|
107
|
+
// (precedent: dashboard/server.ts). Generous because a session carries full
|
|
108
|
+
// per-step page snapshots; raise if real recordings exceed it.
|
|
109
|
+
req.on('data', (c) => { raw += c; if (raw.length > 50_000_000)
|
|
110
|
+
req.destroy(); });
|
|
111
|
+
req.on('end', () => {
|
|
112
|
+
try {
|
|
113
|
+
if (isAX) {
|
|
114
|
+
const body = JSON.parse(raw);
|
|
115
|
+
if (!body.sessionId || !Array.isArray(body.steps))
|
|
116
|
+
throw new Error('sessionId and steps[] required');
|
|
117
|
+
const appended = ingestAX(body, store);
|
|
118
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ ok: true, appended }));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const body = JSON.parse(raw);
|
|
123
|
+
if (!body.sessionId || !Array.isArray(body.steps))
|
|
124
|
+
throw new Error('sessionId and steps[] required');
|
|
125
|
+
const appended = ingest(body, store);
|
|
126
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify({ ok: true, appended }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
server.listen(port, '127.0.0.1');
|
|
137
|
+
return server;
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ActionEffect } from '../mapstore/record.js';
|
|
2
|
+
export interface LiveRecordDeps {
|
|
3
|
+
adapter: {
|
|
4
|
+
evalJs(f: string, ref?: string): Promise<string>;
|
|
5
|
+
snapshot(): Promise<string>;
|
|
6
|
+
currentUrl(): Promise<string>;
|
|
7
|
+
close(): Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
store: {
|
|
10
|
+
isActive(s: string): boolean;
|
|
11
|
+
appendActionEffect(s: string, fx: ActionEffect, nowMs?: number): number | null;
|
|
12
|
+
appendEvent(s: string, ev: {
|
|
13
|
+
t?: number;
|
|
14
|
+
source: 'human' | 'agent';
|
|
15
|
+
kind: string;
|
|
16
|
+
descriptor: Record<string, unknown>;
|
|
17
|
+
}): number | null;
|
|
18
|
+
stampEvent(s: string, seq: number, disposition: string): void;
|
|
19
|
+
start(s: string): unknown;
|
|
20
|
+
stop(s: string): void;
|
|
21
|
+
};
|
|
22
|
+
sessionId: string;
|
|
23
|
+
intervalMs: number;
|
|
24
|
+
log: (line: string) => void;
|
|
25
|
+
isStopped: () => boolean;
|
|
26
|
+
sleep?: (ms: number) => Promise<void>;
|
|
27
|
+
tickExtras?: {
|
|
28
|
+
port?: number;
|
|
29
|
+
session?: string;
|
|
30
|
+
};
|
|
31
|
+
onEvent?: (type: 'step' | 'sessions') => void;
|
|
32
|
+
onToggle?: (recording: boolean) => void;
|
|
33
|
+
onEnd?: (reason: 'closed' | 'stopped') => void;
|
|
34
|
+
onBeforeClose?: () => Promise<void> | void;
|
|
35
|
+
browserAlive?: () => boolean;
|
|
36
|
+
armed?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function runLiveRecord(deps: LiveRecordDeps): Promise<{
|
|
39
|
+
appended: number;
|
|
40
|
+
ticks: number;
|
|
41
|
+
}>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// The live-record poll loop: drain the injected listener's queue, keep a rolling
|
|
2
|
+
// buffer of REAL a11y snapshots, pair events to ticks, append ActionEffects.
|
|
3
|
+
// Deps injected so tests drive it with a scripted fake adapter.
|
|
4
|
+
//
|
|
5
|
+
// TICK ECONOMY (live finding: every playwright-cli call is a child-process spawn,
|
|
6
|
+
// ~300-500ms each, SERIALIZED through the daemon — several per tick made the overlay
|
|
7
|
+
// lag seconds behind): a tick is currentUrl + ONE combined TICK_JS eval (install +
|
|
8
|
+
// mode-paint + drain in a single JS turn); the EXPENSIVE snapshot runs only when
|
|
9
|
+
// something happened (events drained / URL changed / pendings waiting / no baseline).
|
|
10
|
+
import { TICK_JS, resolveEvent, fromTickFor, chooseToTick, assembleEffect, } from './live.js';
|
|
11
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
12
|
+
import { parseEvalResult } from '../router/browse.js';
|
|
13
|
+
import { classifyReadiness } from '../router/readiness.js';
|
|
14
|
+
import { didNavigate, diffSnapshots } from '../explorer/diff.js';
|
|
15
|
+
export async function runLiveRecord(deps) {
|
|
16
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
17
|
+
const ticks = [];
|
|
18
|
+
const pending = [];
|
|
19
|
+
let appended = 0;
|
|
20
|
+
let errStreak = 0;
|
|
21
|
+
let undrainStreak = 0;
|
|
22
|
+
let endReason = 'stopped';
|
|
23
|
+
let sawRealPage = false;
|
|
24
|
+
let blankStreak = 0;
|
|
25
|
+
try {
|
|
26
|
+
while (!deps.isStopped() && (deps.armed ? true : deps.store.isActive(deps.sessionId))) {
|
|
27
|
+
if (deps.browserAlive && !deps.browserAlive()) {
|
|
28
|
+
deps.log('browser window closed — ending session');
|
|
29
|
+
endReason = 'closed';
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
// 1. where are we? (cheap; also our browser-liveness probe)
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = await deps.adapter.currentUrl();
|
|
36
|
+
// NOTE: do NOT reset errStreak here — currentUrl succeeding every tick while
|
|
37
|
+
// snapshot keeps throwing would pin the streak at 1 forever (found as a
|
|
38
|
+
// microtask-starved infinite loop that hung vitest unkillably). The streak
|
|
39
|
+
// resets only on a successful SNAPSHOT below.
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (deps.isStopped() || (!deps.armed && !deps.store.isActive(deps.sessionId)))
|
|
43
|
+
break;
|
|
44
|
+
if (++errStreak >= 5) {
|
|
45
|
+
deps.log('browser gone — ending');
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
deps.log(`tick error (retrying): ${String(e).split('\n')[0]}`);
|
|
49
|
+
await sleep(deps.intervalMs);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// 2+3+4 in ONE eval: install-if-needed, paint the badge for the CURRENT mode
|
|
53
|
+
// (a fresh doc's badge is born correct — no grey blip), drain the queue.
|
|
54
|
+
// Junk (unparseable) output means the page/window is truly gone — the daemon
|
|
55
|
+
// would resurrect the browser on further evals, so a sustained streak ends
|
|
56
|
+
// the session instead of spamming.
|
|
57
|
+
const modeBefore = deps.store.isActive(deps.sessionId);
|
|
58
|
+
const raw = parseEvalResult(await deps.adapter.evalJs(TICK_JS(modeBefore, deps.tickExtras)).catch(() => 'JUNK'));
|
|
59
|
+
let events = [];
|
|
60
|
+
let tickRes = {};
|
|
61
|
+
try {
|
|
62
|
+
tickRes = JSON.parse(raw);
|
|
63
|
+
events = tickRes.queue ?? [];
|
|
64
|
+
undrainStreak = 0;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Threshold is deliberately generous: DRAIN_JS is in-page try/catch-safe, so an
|
|
68
|
+
// ALIVE page never produces junk — but the eval itself can fail transiently
|
|
69
|
+
// mid-navigation. Only a sustained streak (~2.4s at 300ms) means the window is
|
|
70
|
+
// really gone (live finding: 3 fired during a plain page load and closed the
|
|
71
|
+
// armed window out from under the user).
|
|
72
|
+
if (++undrainStreak === 1)
|
|
73
|
+
deps.log('skip: undrainable batch');
|
|
74
|
+
if (undrainStreak >= 8) {
|
|
75
|
+
deps.log('browser window closed — ending session');
|
|
76
|
+
endReason = 'closed';
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
await sleep(100); // transient (mid-navigation): retry fast so the badge returns sooner
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// RESURRECTION DETECTOR (live finding: closing the window makes the daemon
|
|
83
|
+
// RELAUNCH it as a fresh about:blank on our next eval — where evals succeed,
|
|
84
|
+
// so the junk-based close detection never fires and the window "keeps opening
|
|
85
|
+
// again and again"). Signature: we had a real page, now it's about:blank in a
|
|
86
|
+
// freshly-installed doc. Two consecutive ticks (~400ms) to ride out rare
|
|
87
|
+
// auth-flow blank bounces; then end the session (finally closes the daemon
|
|
88
|
+
// session, which stops the resurrection for good).
|
|
89
|
+
// (installed is NOT part of the streak condition: the second tick on the same
|
|
90
|
+
// resurrected blank doc reports installed=false and would reset the streak.)
|
|
91
|
+
const isBlank = !url || url === 'about:blank';
|
|
92
|
+
if (sawRealPage && isBlank) {
|
|
93
|
+
if (++blankStreak >= 2) {
|
|
94
|
+
deps.log('browser window closed — ending session');
|
|
95
|
+
endReason = 'closed';
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
blankStreak = 0;
|
|
101
|
+
if (!isBlank)
|
|
102
|
+
sawRealPage = true;
|
|
103
|
+
}
|
|
104
|
+
const toggles = events.filter((e) => e.kind === 'toggle');
|
|
105
|
+
let data = events.filter((e) => e.kind !== 'toggle');
|
|
106
|
+
for (const tg of toggles) {
|
|
107
|
+
const cur = deps.store.isActive(deps.sessionId);
|
|
108
|
+
const next = typeof tg.desired === 'boolean' ? tg.desired : !cur;
|
|
109
|
+
if (next === cur)
|
|
110
|
+
continue; // idempotent: a repeated 'stop' is a no-op, never a restart
|
|
111
|
+
if (next)
|
|
112
|
+
deps.store.start(deps.sessionId);
|
|
113
|
+
else
|
|
114
|
+
deps.store.stop(deps.sessionId);
|
|
115
|
+
deps.log('recording ' + (next ? 'STARTED' : 'STOPPED') + ' (pill via queue): ' + deps.sessionId);
|
|
116
|
+
deps.onToggle?.(next);
|
|
117
|
+
deps.onEvent?.('sessions');
|
|
118
|
+
}
|
|
119
|
+
// ARMED = capturing nothing: drop data events entirely. Without this the loop
|
|
120
|
+
// processed them and logged 'recorded …' while the store silently no-opped —
|
|
121
|
+
// the log claimed captures that never persisted (live confusion).
|
|
122
|
+
if (!deps.store.isActive(deps.sessionId))
|
|
123
|
+
data = [];
|
|
124
|
+
for (const ev of data)
|
|
125
|
+
pending.push({
|
|
126
|
+
ev, drainIdx: ticks.length, waits: 0,
|
|
127
|
+
// ledger the raw event NOW — before pairing can lose it (spec 2026-07-16)
|
|
128
|
+
ledgerSeq: deps.store.appendEvent(deps.sessionId, {
|
|
129
|
+
t: ev.t, source: 'human', kind: ev.kind, descriptor: ev
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
// (a toggle's visual flip already happened optimistically in-page; the next
|
|
133
|
+
// tick's TICK_JS paint carries the server truth.)
|
|
134
|
+
// 5. snapshot only when it can matter (the expensive call).
|
|
135
|
+
const urlChanged = ticks.length === 0 || didNavigate(ticks[ticks.length - 1].url, url)
|
|
136
|
+
|| ticks[ticks.length - 1].url !== url;
|
|
137
|
+
if (data.length || pending.length || urlChanged) {
|
|
138
|
+
let snap;
|
|
139
|
+
try {
|
|
140
|
+
snap = await deps.adapter.snapshot();
|
|
141
|
+
errStreak = 0;
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
if (deps.isStopped() || (!deps.armed && !deps.store.isActive(deps.sessionId)))
|
|
145
|
+
break;
|
|
146
|
+
if (++errStreak >= 5) {
|
|
147
|
+
deps.log('browser gone — ending');
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
deps.log(`tick error (retrying): ${String(e).split('\n')[0]}`);
|
|
151
|
+
await sleep(deps.intervalMs);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (classifyReadiness(snap) !== 'loading')
|
|
155
|
+
ticks.push({ url, snapshot: snap });
|
|
156
|
+
else
|
|
157
|
+
ticks.push(ticks[ticks.length - 1] ?? { url, snapshot: snap }); // never archive a loading shell
|
|
158
|
+
}
|
|
159
|
+
// 6. pair pended events to ticks (unchanged semantics).
|
|
160
|
+
// Snapshot the later-click facts BEFORE the loop splices pending: computed inside,
|
|
161
|
+
// an already-processed (spliced) click stops counting as "later" for its same-batch
|
|
162
|
+
// input, and the input then pairs with the click's landing tick (final-review #1).
|
|
163
|
+
const clicks = pending.filter((q) => q.ev.kind === 'click')
|
|
164
|
+
.map((q) => ({ drainIdx: q.drainIdx, seq: q.ev.seq }));
|
|
165
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
166
|
+
const p = pending[i];
|
|
167
|
+
const hasLaterClick = clicks.some((c) => c.drainIdx > p.drainIdx || (c.drainIdx === p.drainIdx && c.seq > p.ev.seq));
|
|
168
|
+
let to = chooseToTick(p.drainIdx, ticks, hasLaterClick);
|
|
169
|
+
if (to === -1 && ++p.waits < 3)
|
|
170
|
+
continue; // wait for the lookahead tick
|
|
171
|
+
if (to === -1)
|
|
172
|
+
to = Math.min(p.drainIdx, ticks.length - 1); // give up waiting → same tick
|
|
173
|
+
let fromIdx = fromTickFor(p.ev, ticks, p.drainIdx - 1);
|
|
174
|
+
if (fromIdx === -1 && !didNavigate(ticks[Math.min(p.drainIdx, ticks.length - 1)].url, p.ev.url)) {
|
|
175
|
+
fromIdx = Math.min(p.drainIdx, ticks.length - 1);
|
|
176
|
+
}
|
|
177
|
+
pending.splice(i, 1);
|
|
178
|
+
if (fromIdx === -1) {
|
|
179
|
+
if (p.ledgerSeq != null)
|
|
180
|
+
deps.store.stampEvent(deps.sessionId, p.ledgerSeq, 'dropped:no-from-page');
|
|
181
|
+
deps.log(`skip: no from-page for seq ${p.ev.seq}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const res = resolveEvent(p.ev, parseSnapshot(ticks[fromIdx].snapshot));
|
|
185
|
+
let ref = res && 'ref' in res ? res.ref : null;
|
|
186
|
+
if (res && 'candidates' in res && !didNavigate(url, p.ev.url)) {
|
|
187
|
+
for (const c of res.candidates) { // ref-scoped probe (same doc only)
|
|
188
|
+
const hit = parseEvalResult(await deps.adapter.evalJs('(el) => el.dataset.webnavHit || null', c).catch(() => 'null'));
|
|
189
|
+
if (hit === String(p.ev.seq)) {
|
|
190
|
+
ref = c;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let fx = assembleEffect(p.ev, ref, ticks[fromIdx], ticks[to]);
|
|
196
|
+
// An unresolved same-page click is normally dropped as noise (honest —
|
|
197
|
+
// never a guessed ref, per the identical-siblings-escalate rule). But a
|
|
198
|
+
// dropdown-item click that visibly CHANGED the page (e.g. a chart-type
|
|
199
|
+
// switch) is real signal, not noise — take ONE fresh snapshot and diff it
|
|
200
|
+
// against the from-tick; a genuine change gets recorded as evidence
|
|
201
|
+
// (action:null, same shape as the unresolved-NAVIGATED-click branch)
|
|
202
|
+
// instead of silently lost.
|
|
203
|
+
if (!fx && p.ev.kind === 'click' && deps.store.isActive(deps.sessionId)) {
|
|
204
|
+
const freshSnap = await deps.adapter.snapshot().catch(() => null);
|
|
205
|
+
if (freshSnap) {
|
|
206
|
+
const diff = diffSnapshots(parseSnapshot(ticks[fromIdx].snapshot), parseSnapshot(freshSnap));
|
|
207
|
+
if (diff.added.length || diff.removed.length) {
|
|
208
|
+
fx = {
|
|
209
|
+
fromUrl: ticks[fromIdx].url, fromSnapshot: ticks[fromIdx].snapshot,
|
|
210
|
+
action: null, toUrl: url, toSnapshot: freshSnap,
|
|
211
|
+
navigated: false, diff,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (fx) {
|
|
217
|
+
// stamp the step with when the human ACTED, not when this slow loop got to it
|
|
218
|
+
const stepSeq = deps.store.appendActionEffect(deps.sessionId, fx, p.ev.t);
|
|
219
|
+
if (p.ledgerSeq != null && stepSeq != null)
|
|
220
|
+
deps.store.stampEvent(deps.sessionId, p.ledgerSeq, 'step:' + stepSeq);
|
|
221
|
+
appended++;
|
|
222
|
+
deps.onEvent?.('step');
|
|
223
|
+
const lag = p.ev.t && Date.now() - p.ev.t > 2000
|
|
224
|
+
? ` (at ${new Date(p.ev.t).toLocaleTimeString()})` : '';
|
|
225
|
+
if (fx.action)
|
|
226
|
+
deps.log(`recorded ${fx.navigated ? 'nav' : fx.action.role}: ${fx.action.name ?? fx.toUrl}${lag}`);
|
|
227
|
+
else
|
|
228
|
+
deps.log(`recorded unresolved same-page change (action:null): ${fx.toUrl}${lag}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
if (p.ledgerSeq != null)
|
|
232
|
+
deps.store.stampEvent(deps.sessionId, p.ledgerSeq, 'dropped:unresolved-same-page');
|
|
233
|
+
deps.log(`skip: unresolved same-page click seq ${p.ev.seq}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// A fresh document just got its badge+listener via this tick's install; tick
|
|
237
|
+
// again immediately so the paint/queue gap after a navigation stays minimal.
|
|
238
|
+
if (!tickRes.installed)
|
|
239
|
+
await sleep(deps.intervalMs);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
// pre-close hook: stop video / flush WHILE the session is still open — after
|
|
244
|
+
// adapter.close() the session is gone and playwright-cli video-stop saves nothing.
|
|
245
|
+
if (deps.onBeforeClose) {
|
|
246
|
+
try {
|
|
247
|
+
await deps.onBeforeClose();
|
|
248
|
+
}
|
|
249
|
+
catch { /* */ }
|
|
250
|
+
}
|
|
251
|
+
await deps.adapter.close().catch(() => { }); // graceful close request
|
|
252
|
+
deps.onEnd?.(endReason);
|
|
253
|
+
}
|
|
254
|
+
return { appended, ticks: ticks.length };
|
|
255
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
2
|
+
import type { ActionEffect } from '../mapstore/record.js';
|
|
3
|
+
export interface LiveEvent {
|
|
4
|
+
seq: number;
|
|
5
|
+
kind: 'click' | 'input' | 'toggle';
|
|
6
|
+
url: string;
|
|
7
|
+
tagName: string;
|
|
8
|
+
desired?: boolean;
|
|
9
|
+
t?: number;
|
|
10
|
+
role?: string | null;
|
|
11
|
+
ariaLabel?: string | null;
|
|
12
|
+
leafText?: string | null;
|
|
13
|
+
href?: string | null;
|
|
14
|
+
placeholder?: string | null;
|
|
15
|
+
nameAttr?: string | null;
|
|
16
|
+
inputType?: string | null;
|
|
17
|
+
value?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare const INSTALLER_JS = "() => {\n // Recording indicator: red inset border + \"REC\" pill. Placed BEFORE the\n // idempotence guard so the every-tick eval self-heals it if an SPA re-render\n // wipes it. pointer-events:none = never intercepts the user's clicks;\n // aria-hidden = never appears in the a11y snapshots we record.\n if (!document.getElementById('__webnav_rec_badge')) {\n const d = document.createElement('div');\n d.id = '__webnav_rec_badge';\n d.setAttribute('aria-hidden', 'true');\n d.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;box-shadow:inset 0 0 0 4px #e5484d;';\n const p = document.createElement('button');\n p.style.cssText = 'position:absolute;top:10px;left:50%;transform:translateX(-50%);background:#e5484d;color:#fff;font:700 11px/1 -apple-system,sans-serif;padding:6px 12px;border-radius:999px;pointer-events:auto;cursor:pointer;border:0;';\n p.textContent = '\\u23FA record';\n // A focusable element inside an aria-hidden container is STILL exposed by the\n // a11y tree (aria-hidden on an ancestor doesn't suppress a focusable descendant) \u2014\n // the wrapper div's aria-hidden alone does not hide this button from snapshots.\n // Make the pill itself a11y-invisible too. Doesn't touch pointer-events/onclick.\n p.setAttribute('aria-hidden', 'true');\n p.setAttribute('tabindex', '-1');\n d.appendChild(p);\n (document.body || document.documentElement).appendChild(d);\n }\n if (document.documentElement.dataset.webnavInstalled) return 'already';\n document.documentElement.dataset.webnavInstalled = '1';\n // Keep the journey in THIS tab: a new tab escapes the recorder (one driven page).\n // Standard recorder trick: _blank links open same-tab; window.open redirects inline.\n // ponytail: OAuth-style popups get redirected into the tab \u2014 acceptable for v1.\n document.addEventListener('click', (ev) => {\n const t = ev.target;\n if (t instanceof Element) {\n const a = t.closest('a[target]');\n if (a && a.getAttribute('target') !== '_self') a.setAttribute('target', '_self');\n }\n }, true);\n window.open = (u) => { if (u) location.href = String(u); return null; };\n const push = (e) => {\n try {\n const q = JSON.parse(sessionStorage.getItem('__webnav_evq') || '[]');\n const seq = (Number(sessionStorage.getItem('__webnav_seq')) || 0) + 1;\n sessionStorage.setItem('__webnav_seq', String(seq));\n q.push(Object.assign({ seq, t: Date.now(), url: location.href }, e));\n sessionStorage.setItem('__webnav_evq', JSON.stringify(q));\n return seq;\n } catch { return 0; } // storage-denied page (opaque origin): drop, never explode\n };\n const badgeBtn = document.querySelector('#__webnav_rec_badge button');\n if (badgeBtn) badgeBtn.onclick = () => {\n // OPTIMISTIC flip: paint the new state NOW (the server's truth repaints next tick).\n // (4-line paint duplicated from TICK_JS's painter \u2014 worlds are isolated, DOM isn't.)\n const on = document.documentElement.dataset.webnavRec === '1';\n const desired = !on;\n document.documentElement.dataset.webnavRec = desired ? '1' : '0';\n document.documentElement.dataset.webnavPinT = String(Date.now()); // pin: stale ticks must not undo this\n const d0 = document.getElementById('__webnav_rec_badge');\n if (d0) { d0.style.boxShadow = 'inset 0 0 0 4px ' + (desired ? '#e5484d' : '#8b93a3');\n const b0 = d0.querySelector('button');\n if (b0) { b0.style.background = desired ? '#e5484d' : '#8b93a3'; b0.textContent = desired ? '\\u25CF REC \\u2014 stop' : '\\u23FA record'; } }\n // REALTIME: POST the DESIRED state straight to the dashboard server (idempotent \u2014\n // a flip could double-toggle if visual state was stale; desired-state can't).\n // Chrome exempts 127.0.0.1 from mixed-content blocking, so this works from https\n // pages too. Fetch failure (strict CSP, no dashboard) \u2192 queue for the loop.\n const port = document.documentElement.dataset.webnavPort;\n const sess = document.documentElement.dataset.webnavSession;\n if (port && sess) {\n fetch('http://127.0.0.1:' + port + '/api/recordings/' + encodeURIComponent(sess) + '/toggle',\n { method: 'POST', keepalive: true, headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ recording: desired }) }).catch(() => push({ kind: 'toggle', desired }));\n } else { push({ kind: 'toggle', desired }); }\n };\n // Click RIPPLE: a brief expanding ring at the click point, painted only while\n // recording \u2014 so the session VIDEO shows WHERE each click landed (the review\n // agent reads frames; a ripple pins the action location). pointer-events:none\n // (never intercepts), aria-hidden (never in the a11y snapshots we record), and\n // it deliberately CREATES a visible change at click time, which biases the\n // scene-change frame extractor toward selecting a frame at each click.\n const ripple = (x, y) => {\n const r = document.createElement('div');\n r.setAttribute('aria-hidden', 'true');\n r.style.cssText = 'position:fixed;left:' + (x - 22) + 'px;top:' + (y - 22) + 'px;width:44px;height:44px;border-radius:50%;border:3px solid #e5484d;background:rgba(229,72,77,.3);z-index:2147483646;pointer-events:none;transform:scale(.4);opacity:1;transition:transform .5s ease-out,opacity .7s ease-out;';\n (document.body || document.documentElement).appendChild(r);\n requestAnimationFrame(() => { r.style.transform = 'scale(1.7)'; r.style.opacity = '0'; });\n setTimeout(() => r.remove(), 800);\n };\n // POINTER DOT: a persistent cursor marker that follows the mouse, so the session\n // VIDEO shows WHERE the agent/human is pointing \u2014 essential for hover-revealed menus\n // (a hover has no click ripple, so without this you can't see what's being hovered).\n // Agent hovers go through playwright's real mouse \u2192 dispatch mousemove \u2192 this moves.\n // One reused element (not per-move), pointer-events:none, aria-hidden (never in the\n // a11y snapshot). Only visible while recording (the frame extractor still keys on the\n // bigger scene changes; the dot is a small always-on locator, not a scene-change bait).\n const pointer = () => {\n let el = document.getElementById('__webnav_ptr');\n if (!el) {\n el = document.createElement('div');\n el.id = '__webnav_ptr';\n el.setAttribute('aria-hidden', 'true');\n el.style.cssText = 'position:fixed;left:0;top:0;width:18px;height:18px;margin:-9px 0 0 -9px;border-radius:50%;border:2px solid #e5484d;background:rgba(229,72,77,.25);box-shadow:0 0 0 2px rgba(255,255,255,.6);z-index:2147483645;pointer-events:none;transition:left .2s ease-out,top .2s ease-out;display:none;';\n (document.body || document.documentElement).appendChild(el);\n }\n return el;\n };\n document.addEventListener('mousemove', (ev) => {\n const el = pointer();\n const on = document.documentElement.dataset.webnavRec === '1';\n el.style.display = on ? 'block' : 'none';\n el.style.left = ev.clientX + 'px';\n el.style.top = ev.clientY + 'px';\n }, true);\n // ownText/nearestLabel: a role-bearing wrapper (e.g. a chart's role=\"toolbar\"\n // holding BOTH a legend and a dropdown) passes the old takeText check and its\n // whole-subtree textContent bled the legend's text onto a dropdown click. These\n // walk up from the ACTUAL click target (not the broad interactive ancestor) and\n // stop at the first element with its own direct text \u2014 never a subtree scrape.\n // Only used as the FALLBACK for broad containers \u2014 see leafLabel below.\n function ownText(node) {\n let s = '';\n for (const c of node.childNodes) if (c.nodeType === 3) s += c.textContent;\n return s.trim();\n }\n function nearestLabel(start) {\n let n = start, depth = 0;\n while (n && depth < 4) {\n const al = n.getAttribute && n.getAttribute('aria-label');\n if (al && al.trim()) return al.trim().slice(0, 80);\n const ot = ownText(n);\n if (ot) return ot.slice(0, 80);\n n = n.parentElement; depth++;\n }\n return null;\n }\n // A genuine LEAF control's full textContent IS its label (safe \u2014 it has no\n // unrelated siblings to bleed in). A BROAD container (toolbar/menu/list/...\n // or no role) is not a single control, so its textContent bleeds siblings \u2014\n // fall back to nearestLabel from the real click target instead.\n var LEAF_TAGS = ['a','button','input','select','textarea','summary','label'];\n var LEAF_ROLES = ['button','link','menuitem','option','tab','checkbox','radio',\n 'switch','menuitemcheckbox','menuitemradio','treeitem'];\n function leafLabel(el, t, matched) {\n var role = el.getAttribute && el.getAttribute('role');\n var isLeaf = matched && (LEAF_TAGS.indexOf(el.tagName.toLowerCase()) >= 0 ||\n (role && LEAF_ROLES.indexOf(role) >= 0));\n if (isLeaf) {\n var al = el.getAttribute && el.getAttribute('aria-label');\n if (al && al.trim()) return al.trim().slice(0, 80);\n return (el.textContent || '').trim().slice(0, 80) || null;\n }\n // el is a broad/unrecognized container, or closest() matched nothing\n // (matched===false, el fell back to t) \u2014 either way not a single control.\n return nearestLabel(t);\n }\n document.addEventListener('click', (ev) => {\n const t = ev.target;\n if (!(t instanceof Element)) return;\n if (t.closest('#__webnav_rec_badge')) return; // our own overlay is never a recorded click\n if (document.documentElement.dataset.webnavRec === '1') ripple(ev.clientX, ev.clientY);\n const closestEl = t.closest('a,button,[role],input,select,textarea,summary,label');\n const el = closestEl || t;\n const tag = el.tagName.toLowerCase();\n const isBtnInput = el instanceof HTMLInputElement && ['submit','button','reset'].indexOf(el.type) >= 0;\n const seq = push({\n kind: 'click', tagName: tag,\n role: el.getAttribute('role'), ariaLabel: el.getAttribute('aria-label'),\n leafText: isBtnInput ? (el.value || null) : leafLabel(el, t, !!closestEl),\n href: el instanceof HTMLAnchorElement ? el.href : null,\n placeholder: el.getAttribute('placeholder'), nameAttr: el.getAttribute('name'),\n inputType: el instanceof HTMLInputElement ? el.type : null,\n });\n if (el instanceof HTMLElement) el.dataset.webnavHit = String(seq);\n }, true);\n document.addEventListener('change', (ev) => {\n const el = ev.target;\n if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return;\n // SECRET RULE (refined): record the supplied VALUE as a flow variable for\n // non-secret fields; password / cc-* autocomplete fields are NEVER captured.\n // The guard must cover: multi-token autocomplete ('billing cc-number'), the\n // password-manager tokens (current-password/new-password/one-time-code), and\n // show-password toggles (type flips to text but autocomplete stays) \u2014 final\n // pre-merge review finding.\n const ac = (el.autocomplete || '');\n const secret = el instanceof HTMLInputElement &&\n (el.type === 'password' || /(^|s)(cc-|current-password|new-password|one-time-code)/.test(ac));\n let value = null;\n if (!secret) {\n if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) value = el.checked ? 'true' : 'false';\n else value = String(el.value ?? '').slice(0, 200);\n }\n push({ kind: 'input', tagName: el.tagName.toLowerCase(), role: el.getAttribute('role'),\n ariaLabel: el.getAttribute('aria-label'), leafText: null, href: null,\n placeholder: el.getAttribute('placeholder'), nameAttr: el.getAttribute('name'),\n inputType: el instanceof HTMLInputElement ? el.type : null, value });\n }, true);\n return 'installed';\n}";
|
|
20
|
+
export declare const MODE_JS: (recording: boolean) => string;
|
|
21
|
+
export declare const DRAIN_JS = "() => {\n try {\n const q = sessionStorage.getItem('__webnav_evq') || '[]';\n sessionStorage.removeItem('__webnav_evq');\n return q;\n } catch { return '[]'; }\n}";
|
|
22
|
+
export declare const TICK_JS: (recording: boolean, extras?: {
|
|
23
|
+
port?: number;
|
|
24
|
+
session?: string;
|
|
25
|
+
}) => string;
|
|
26
|
+
export declare function descriptorRole(ev: LiveEvent): string | null;
|
|
27
|
+
export declare function descriptorName(ev: LiveEvent): string | null;
|
|
28
|
+
export type Resolution = {
|
|
29
|
+
ref: string;
|
|
30
|
+
} | {
|
|
31
|
+
candidates: string[];
|
|
32
|
+
} | null;
|
|
33
|
+
/** Resolve a descriptor against a REAL playwright snapshot. Strict: no role/name
|
|
34
|
+
* → null; unique role+name → ref; multiple + href → match the link target
|
|
35
|
+
* (host+path); still multiple → candidates (the loop may probe them); else null. */
|
|
36
|
+
export declare function resolveEvent(ev: LiveEvent, nodes: SnapNode[]): Resolution;
|
|
37
|
+
export interface Tick {
|
|
38
|
+
url: string;
|
|
39
|
+
snapshot: string;
|
|
40
|
+
}
|
|
41
|
+
/** Latest tick ≤ uptoIdx on the same page as the event (query/hash ignored). */
|
|
42
|
+
export declare function fromTickFor(ev: LiveEvent, ticks: Tick[], uptoIdx: number): number;
|
|
43
|
+
/** Which tick is the event's landing? -1 = wait (need the lookahead tick).
|
|
44
|
+
* A navigation often lands AFTER the drain tick; the one-tick lookahead
|
|
45
|
+
* attributes it — unless a later click was drained, which then owns it.
|
|
46
|
+
* ponytail: bursts inside one interval can misattribute; interval default
|
|
47
|
+
* 500ms makes that rare for deliberate QA clicking. */
|
|
48
|
+
export declare function chooseToTick(drainIdx: number, ticks: Tick[], hasLaterClick: boolean): number;
|
|
49
|
+
/** Build the ActionEffect. Unresolved same-page CLICKS are dropped (noise);
|
|
50
|
+
* unresolved NAVIGATED clicks still emit with action:null (the draft's
|
|
51
|
+
* link-scan + cross-link mesh recover link edges); inputs always emit —
|
|
52
|
+
* the field identity powers the draft's login/credentials linkage. */
|
|
53
|
+
export declare function assembleEffect(ev: LiveEvent, ref: string | null, from: Tick, to: Tick): ActionEffect | null;
|