@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,400 @@
|
|
|
1
|
+
// Pure core of the human-driven live recorder (spec:
|
|
2
|
+
// docs/superpowers/specs/2026-07-07-playwright-recorder-design.md).
|
|
3
|
+
// The injected listener only TAGS + REPORTS one-element descriptors; ALL
|
|
4
|
+
// snapshotting is playwright's. No in-page tree serialization, no .value reads.
|
|
5
|
+
import { didNavigate, diffSnapshots } from '../explorer/diff.js';
|
|
6
|
+
import { parseSnapshot } from '../playwright/snapshot.js';
|
|
7
|
+
import { recoverFingerprint } from '../playwright/fingerprint.js';
|
|
8
|
+
// Injected once per document (idempotent — a navigation loses page JS, the loop
|
|
9
|
+
// re-evals this every tick). LIVE lesson #1: the idempotence flag must live on the
|
|
10
|
+
// DOM (documentElement.dataset), NOT on window — playwright-cli runs each eval in a
|
|
11
|
+
// fresh JS world, so a window flag is invisible to the next eval and every tick
|
|
12
|
+
// installed ANOTHER listener (duplicate-event storm; dup clicks then suppressed each
|
|
13
|
+
// other's landing attribution). The DOM is shared across worlds; a new document after
|
|
14
|
+
// navigation has a fresh documentElement, so re-injection still happens exactly once.
|
|
15
|
+
// Events queue in sessionStorage: a window-scoped queue dies with the document,
|
|
16
|
+
// losing the navigating click — the most important event (the Chrome-extension bug,
|
|
17
|
+
// not re-learned twice). Secret-field rule (refined): NON-SECRET field values ARE
|
|
18
|
+
// recorded as flow VARIABLES (typed text / chosen option / checkbox state — what makes
|
|
19
|
+
// a recorded flow re-runnable with different inputs); password and cc-* autocomplete
|
|
20
|
+
// fields are NEVER captured. A submit/button/reset input's .value is its static label
|
|
21
|
+
// (its accessible name — LIVE lesson #2: saucedemo's Login is <input type=submit
|
|
22
|
+
// value="Login">, unresolvable without it). leafText is capped and taken via
|
|
23
|
+
// leafLabel: a genuine LEAF control (a/button/input/select/textarea/summary/
|
|
24
|
+
// label, or a leaf-control role like button/link/checkbox) uses its OWN full
|
|
25
|
+
// textContent (correct — "Cart 3" for a badge-bearing button, not just the
|
|
26
|
+
// badge clicked inside it); a BROAD container (role=toolbar/group/menu/list/...
|
|
27
|
+
// or no role at all) falls back to nearestLabel, the nearest OWN-TEXT ancestor
|
|
28
|
+
// of the actual click target — never a broad ancestor's whole subtree (a
|
|
29
|
+
// role-bearing toolbar wrapping both a legend and a chart-type dropdown used to
|
|
30
|
+
// bleed the legend's text onto a dropdown click — the extension's name-blob
|
|
31
|
+
// failure, one level up). Regression guard: an EARLIER version of this fix
|
|
32
|
+
// always walked from the click target, which broke plain multi-run labels
|
|
33
|
+
// (clicking the badge span inside <button>Cart <span>3</span></button> then
|
|
34
|
+
// returned just "3").
|
|
35
|
+
export const INSTALLER_JS = `() => {
|
|
36
|
+
// Recording indicator: red inset border + "REC" pill. Placed BEFORE the
|
|
37
|
+
// idempotence guard so the every-tick eval self-heals it if an SPA re-render
|
|
38
|
+
// wipes it. pointer-events:none = never intercepts the user's clicks;
|
|
39
|
+
// aria-hidden = never appears in the a11y snapshots we record.
|
|
40
|
+
if (!document.getElementById('__webnav_rec_badge')) {
|
|
41
|
+
const d = document.createElement('div');
|
|
42
|
+
d.id = '__webnav_rec_badge';
|
|
43
|
+
d.setAttribute('aria-hidden', 'true');
|
|
44
|
+
d.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;box-shadow:inset 0 0 0 4px #e5484d;';
|
|
45
|
+
const p = document.createElement('button');
|
|
46
|
+
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;';
|
|
47
|
+
p.textContent = '\\u23FA record';
|
|
48
|
+
// A focusable element inside an aria-hidden container is STILL exposed by the
|
|
49
|
+
// a11y tree (aria-hidden on an ancestor doesn't suppress a focusable descendant) —
|
|
50
|
+
// the wrapper div's aria-hidden alone does not hide this button from snapshots.
|
|
51
|
+
// Make the pill itself a11y-invisible too. Doesn't touch pointer-events/onclick.
|
|
52
|
+
p.setAttribute('aria-hidden', 'true');
|
|
53
|
+
p.setAttribute('tabindex', '-1');
|
|
54
|
+
d.appendChild(p);
|
|
55
|
+
(document.body || document.documentElement).appendChild(d);
|
|
56
|
+
}
|
|
57
|
+
if (document.documentElement.dataset.webnavInstalled) return 'already';
|
|
58
|
+
document.documentElement.dataset.webnavInstalled = '1';
|
|
59
|
+
// Keep the journey in THIS tab: a new tab escapes the recorder (one driven page).
|
|
60
|
+
// Standard recorder trick: _blank links open same-tab; window.open redirects inline.
|
|
61
|
+
// ponytail: OAuth-style popups get redirected into the tab — acceptable for v1.
|
|
62
|
+
document.addEventListener('click', (ev) => {
|
|
63
|
+
const t = ev.target;
|
|
64
|
+
if (t instanceof Element) {
|
|
65
|
+
const a = t.closest('a[target]');
|
|
66
|
+
if (a && a.getAttribute('target') !== '_self') a.setAttribute('target', '_self');
|
|
67
|
+
}
|
|
68
|
+
}, true);
|
|
69
|
+
window.open = (u) => { if (u) location.href = String(u); return null; };
|
|
70
|
+
const push = (e) => {
|
|
71
|
+
try {
|
|
72
|
+
const q = JSON.parse(sessionStorage.getItem('__webnav_evq') || '[]');
|
|
73
|
+
const seq = (Number(sessionStorage.getItem('__webnav_seq')) || 0) + 1;
|
|
74
|
+
sessionStorage.setItem('__webnav_seq', String(seq));
|
|
75
|
+
q.push(Object.assign({ seq, t: Date.now(), url: location.href }, e));
|
|
76
|
+
sessionStorage.setItem('__webnav_evq', JSON.stringify(q));
|
|
77
|
+
return seq;
|
|
78
|
+
} catch { return 0; } // storage-denied page (opaque origin): drop, never explode
|
|
79
|
+
};
|
|
80
|
+
const badgeBtn = document.querySelector('#__webnav_rec_badge button');
|
|
81
|
+
if (badgeBtn) badgeBtn.onclick = () => {
|
|
82
|
+
// OPTIMISTIC flip: paint the new state NOW (the server's truth repaints next tick).
|
|
83
|
+
// (4-line paint duplicated from TICK_JS's painter — worlds are isolated, DOM isn't.)
|
|
84
|
+
const on = document.documentElement.dataset.webnavRec === '1';
|
|
85
|
+
const desired = !on;
|
|
86
|
+
document.documentElement.dataset.webnavRec = desired ? '1' : '0';
|
|
87
|
+
document.documentElement.dataset.webnavPinT = String(Date.now()); // pin: stale ticks must not undo this
|
|
88
|
+
const d0 = document.getElementById('__webnav_rec_badge');
|
|
89
|
+
if (d0) { d0.style.boxShadow = 'inset 0 0 0 4px ' + (desired ? '#e5484d' : '#8b93a3');
|
|
90
|
+
const b0 = d0.querySelector('button');
|
|
91
|
+
if (b0) { b0.style.background = desired ? '#e5484d' : '#8b93a3'; b0.textContent = desired ? '\\u25CF REC \\u2014 stop' : '\\u23FA record'; } }
|
|
92
|
+
// REALTIME: POST the DESIRED state straight to the dashboard server (idempotent —
|
|
93
|
+
// a flip could double-toggle if visual state was stale; desired-state can't).
|
|
94
|
+
// Chrome exempts 127.0.0.1 from mixed-content blocking, so this works from https
|
|
95
|
+
// pages too. Fetch failure (strict CSP, no dashboard) → queue for the loop.
|
|
96
|
+
const port = document.documentElement.dataset.webnavPort;
|
|
97
|
+
const sess = document.documentElement.dataset.webnavSession;
|
|
98
|
+
if (port && sess) {
|
|
99
|
+
fetch('http://127.0.0.1:' + port + '/api/recordings/' + encodeURIComponent(sess) + '/toggle',
|
|
100
|
+
{ method: 'POST', keepalive: true, headers: { 'content-type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ recording: desired }) }).catch(() => push({ kind: 'toggle', desired }));
|
|
102
|
+
} else { push({ kind: 'toggle', desired }); }
|
|
103
|
+
};
|
|
104
|
+
// Click RIPPLE: a brief expanding ring at the click point, painted only while
|
|
105
|
+
// recording — so the session VIDEO shows WHERE each click landed (the review
|
|
106
|
+
// agent reads frames; a ripple pins the action location). pointer-events:none
|
|
107
|
+
// (never intercepts), aria-hidden (never in the a11y snapshots we record), and
|
|
108
|
+
// it deliberately CREATES a visible change at click time, which biases the
|
|
109
|
+
// scene-change frame extractor toward selecting a frame at each click.
|
|
110
|
+
const ripple = (x, y) => {
|
|
111
|
+
const r = document.createElement('div');
|
|
112
|
+
r.setAttribute('aria-hidden', 'true');
|
|
113
|
+
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;';
|
|
114
|
+
(document.body || document.documentElement).appendChild(r);
|
|
115
|
+
requestAnimationFrame(() => { r.style.transform = 'scale(1.7)'; r.style.opacity = '0'; });
|
|
116
|
+
setTimeout(() => r.remove(), 800);
|
|
117
|
+
};
|
|
118
|
+
// POINTER DOT: a persistent cursor marker that follows the mouse, so the session
|
|
119
|
+
// VIDEO shows WHERE the agent/human is pointing — essential for hover-revealed menus
|
|
120
|
+
// (a hover has no click ripple, so without this you can't see what's being hovered).
|
|
121
|
+
// Agent hovers go through playwright's real mouse → dispatch mousemove → this moves.
|
|
122
|
+
// One reused element (not per-move), pointer-events:none, aria-hidden (never in the
|
|
123
|
+
// a11y snapshot). Only visible while recording (the frame extractor still keys on the
|
|
124
|
+
// bigger scene changes; the dot is a small always-on locator, not a scene-change bait).
|
|
125
|
+
const pointer = () => {
|
|
126
|
+
let el = document.getElementById('__webnav_ptr');
|
|
127
|
+
if (!el) {
|
|
128
|
+
el = document.createElement('div');
|
|
129
|
+
el.id = '__webnav_ptr';
|
|
130
|
+
el.setAttribute('aria-hidden', 'true');
|
|
131
|
+
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;';
|
|
132
|
+
(document.body || document.documentElement).appendChild(el);
|
|
133
|
+
}
|
|
134
|
+
return el;
|
|
135
|
+
};
|
|
136
|
+
document.addEventListener('mousemove', (ev) => {
|
|
137
|
+
const el = pointer();
|
|
138
|
+
const on = document.documentElement.dataset.webnavRec === '1';
|
|
139
|
+
el.style.display = on ? 'block' : 'none';
|
|
140
|
+
el.style.left = ev.clientX + 'px';
|
|
141
|
+
el.style.top = ev.clientY + 'px';
|
|
142
|
+
}, true);
|
|
143
|
+
// ownText/nearestLabel: a role-bearing wrapper (e.g. a chart's role="toolbar"
|
|
144
|
+
// holding BOTH a legend and a dropdown) passes the old takeText check and its
|
|
145
|
+
// whole-subtree textContent bled the legend's text onto a dropdown click. These
|
|
146
|
+
// walk up from the ACTUAL click target (not the broad interactive ancestor) and
|
|
147
|
+
// stop at the first element with its own direct text — never a subtree scrape.
|
|
148
|
+
// Only used as the FALLBACK for broad containers — see leafLabel below.
|
|
149
|
+
function ownText(node) {
|
|
150
|
+
let s = '';
|
|
151
|
+
for (const c of node.childNodes) if (c.nodeType === 3) s += c.textContent;
|
|
152
|
+
return s.trim();
|
|
153
|
+
}
|
|
154
|
+
function nearestLabel(start) {
|
|
155
|
+
let n = start, depth = 0;
|
|
156
|
+
while (n && depth < 4) {
|
|
157
|
+
const al = n.getAttribute && n.getAttribute('aria-label');
|
|
158
|
+
if (al && al.trim()) return al.trim().slice(0, 80);
|
|
159
|
+
const ot = ownText(n);
|
|
160
|
+
if (ot) return ot.slice(0, 80);
|
|
161
|
+
n = n.parentElement; depth++;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// A genuine LEAF control's full textContent IS its label (safe — it has no
|
|
166
|
+
// unrelated siblings to bleed in). A BROAD container (toolbar/menu/list/...
|
|
167
|
+
// or no role) is not a single control, so its textContent bleeds siblings —
|
|
168
|
+
// fall back to nearestLabel from the real click target instead.
|
|
169
|
+
var LEAF_TAGS = ['a','button','input','select','textarea','summary','label'];
|
|
170
|
+
var LEAF_ROLES = ['button','link','menuitem','option','tab','checkbox','radio',
|
|
171
|
+
'switch','menuitemcheckbox','menuitemradio','treeitem'];
|
|
172
|
+
function leafLabel(el, t, matched) {
|
|
173
|
+
var role = el.getAttribute && el.getAttribute('role');
|
|
174
|
+
var isLeaf = matched && (LEAF_TAGS.indexOf(el.tagName.toLowerCase()) >= 0 ||
|
|
175
|
+
(role && LEAF_ROLES.indexOf(role) >= 0));
|
|
176
|
+
if (isLeaf) {
|
|
177
|
+
var al = el.getAttribute && el.getAttribute('aria-label');
|
|
178
|
+
if (al && al.trim()) return al.trim().slice(0, 80);
|
|
179
|
+
return (el.textContent || '').trim().slice(0, 80) || null;
|
|
180
|
+
}
|
|
181
|
+
// el is a broad/unrecognized container, or closest() matched nothing
|
|
182
|
+
// (matched===false, el fell back to t) — either way not a single control.
|
|
183
|
+
return nearestLabel(t);
|
|
184
|
+
}
|
|
185
|
+
document.addEventListener('click', (ev) => {
|
|
186
|
+
const t = ev.target;
|
|
187
|
+
if (!(t instanceof Element)) return;
|
|
188
|
+
if (t.closest('#__webnav_rec_badge')) return; // our own overlay is never a recorded click
|
|
189
|
+
if (document.documentElement.dataset.webnavRec === '1') ripple(ev.clientX, ev.clientY);
|
|
190
|
+
const closestEl = t.closest('a,button,[role],input,select,textarea,summary,label');
|
|
191
|
+
const el = closestEl || t;
|
|
192
|
+
const tag = el.tagName.toLowerCase();
|
|
193
|
+
const isBtnInput = el instanceof HTMLInputElement && ['submit','button','reset'].indexOf(el.type) >= 0;
|
|
194
|
+
const seq = push({
|
|
195
|
+
kind: 'click', tagName: tag,
|
|
196
|
+
role: el.getAttribute('role'), ariaLabel: el.getAttribute('aria-label'),
|
|
197
|
+
leafText: isBtnInput ? (el.value || null) : leafLabel(el, t, !!closestEl),
|
|
198
|
+
href: el instanceof HTMLAnchorElement ? el.href : null,
|
|
199
|
+
placeholder: el.getAttribute('placeholder'), nameAttr: el.getAttribute('name'),
|
|
200
|
+
inputType: el instanceof HTMLInputElement ? el.type : null,
|
|
201
|
+
});
|
|
202
|
+
if (el instanceof HTMLElement) el.dataset.webnavHit = String(seq);
|
|
203
|
+
}, true);
|
|
204
|
+
document.addEventListener('change', (ev) => {
|
|
205
|
+
const el = ev.target;
|
|
206
|
+
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return;
|
|
207
|
+
// SECRET RULE (refined): record the supplied VALUE as a flow variable for
|
|
208
|
+
// non-secret fields; password / cc-* autocomplete fields are NEVER captured.
|
|
209
|
+
// The guard must cover: multi-token autocomplete ('billing cc-number'), the
|
|
210
|
+
// password-manager tokens (current-password/new-password/one-time-code), and
|
|
211
|
+
// show-password toggles (type flips to text but autocomplete stays) — final
|
|
212
|
+
// pre-merge review finding.
|
|
213
|
+
const ac = (el.autocomplete || '');
|
|
214
|
+
const secret = el instanceof HTMLInputElement &&
|
|
215
|
+
(el.type === 'password' || /(^|\s)(cc-|current-password|new-password|one-time-code)/.test(ac));
|
|
216
|
+
let value = null;
|
|
217
|
+
if (!secret) {
|
|
218
|
+
if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) value = el.checked ? 'true' : 'false';
|
|
219
|
+
else value = String(el.value ?? '').slice(0, 200);
|
|
220
|
+
}
|
|
221
|
+
push({ kind: 'input', tagName: el.tagName.toLowerCase(), role: el.getAttribute('role'),
|
|
222
|
+
ariaLabel: el.getAttribute('aria-label'), leafText: null, href: null,
|
|
223
|
+
placeholder: el.getAttribute('placeholder'), nameAttr: el.getAttribute('name'),
|
|
224
|
+
inputType: el instanceof HTMLInputElement ? el.type : null, value });
|
|
225
|
+
}, true);
|
|
226
|
+
return 'installed';
|
|
227
|
+
}`;
|
|
228
|
+
// Recolor the overlay for the current mode. Evaled by the loop whenever the mode
|
|
229
|
+
// changes (and after each re-inject). Red = capturing; grey = armed (open, not recording).
|
|
230
|
+
export const MODE_JS = (recording) => `() => {
|
|
231
|
+
const d = document.getElementById('__webnav_rec_badge');
|
|
232
|
+
if (!d) return 'no-badge';
|
|
233
|
+
// authoritative push: paint AND pin — an in-flight stale TICK (built before this
|
|
234
|
+
// state change, landing after) must not repaint the old state and poison the
|
|
235
|
+
// pill's memory (live bug: 'stop' then STARTED again — the stale tick had
|
|
236
|
+
// rewritten webnavRec, so the next click toggled the wrong way).
|
|
237
|
+
document.documentElement.dataset.webnavRec = '${recording ? '1' : '0'}';
|
|
238
|
+
document.documentElement.dataset.webnavPinT = String(Date.now());
|
|
239
|
+
d.style.boxShadow = 'inset 0 0 0 4px ${recording ? '#e5484d' : '#8b93a3'}';
|
|
240
|
+
const p = d.querySelector('button');
|
|
241
|
+
if (p) { p.style.background = '${recording ? '#e5484d' : '#8b93a3'}'; p.textContent = '${recording ? '\\u25CF REC \\u2014 stop' : '\\u23FA record'}'; }
|
|
242
|
+
return 'ok';
|
|
243
|
+
}`;
|
|
244
|
+
// Atomic read+clear: draining and processing are one step, so no event is seen twice.
|
|
245
|
+
// The in-page try/catch is LOAD-BEARING: on about:blank (opaque origin) or mid-
|
|
246
|
+
// navigation, sessionStorage access THROWS while the window is perfectly alive —
|
|
247
|
+
// without it, playwright-cli returns junk, and the loop's window-closed detector
|
|
248
|
+
// false-positived and CLOSED the armed window ~1s after opening (live finding).
|
|
249
|
+
// A page that is alive-but-storage-denied must drain '[]'; only a truly dead
|
|
250
|
+
// page (eval cannot run at all) produces unparseable output.
|
|
251
|
+
export const DRAIN_JS = `() => {
|
|
252
|
+
try {
|
|
253
|
+
const q = sessionStorage.getItem('__webnav_evq') || '[]';
|
|
254
|
+
sessionStorage.removeItem('__webnav_evq');
|
|
255
|
+
return q;
|
|
256
|
+
} catch { return '[]'; }
|
|
257
|
+
}`;
|
|
258
|
+
// ONE eval per tick (live finding: every playwright-cli call spawns a fresh CLI
|
|
259
|
+
// process ~300-500ms and serializes through the daemon — three calls per tick made
|
|
260
|
+
// the overlay lag seconds behind). Composes INSTALLER_JS + DRAIN_JS (no duplicated
|
|
261
|
+
// listener logic) and paints the badge for the CURRENT mode in the same JS turn —
|
|
262
|
+
// a fresh document's badge is created already-correct (no grey-then-red blip).
|
|
263
|
+
// Returns JSON {installed, queue}; unparseable output ⇒ the page is truly gone.
|
|
264
|
+
export const TICK_JS = (recording, extras) => `() => {
|
|
265
|
+
const installed = (${INSTALLER_JS})() === 'installed';
|
|
266
|
+
const rec = ${recording ? 'true' : 'false'};
|
|
267
|
+
${extras?.port ? `document.documentElement.dataset.webnavPort = '${Number(extras.port)}';` : ''}
|
|
268
|
+
${extras?.session ? `document.documentElement.dataset.webnavSession = '${String(extras.session).replace(/[^\w.-]/g, '')}';` : ''}
|
|
269
|
+
// A tick built BEFORE an authoritative change can LAND AFTER it (daemon queue).
|
|
270
|
+
// A fresh pin (pill click / dashboard push) wins over this tick's stale mode —
|
|
271
|
+
// without this, a stale tick rewrote webnavRec and the next pill click toggled
|
|
272
|
+
// the wrong way ('stop' started recording again — live bug).
|
|
273
|
+
const pinT = Number(document.documentElement.dataset.webnavPinT || 0);
|
|
274
|
+
if (Date.now() - pinT > 1500) {
|
|
275
|
+
document.documentElement.dataset.webnavRec = rec ? '1' : '0';
|
|
276
|
+
const d = document.getElementById('__webnav_rec_badge');
|
|
277
|
+
if (d) { d.style.boxShadow = 'inset 0 0 0 4px ' + (rec ? '#e5484d' : '#8b93a3');
|
|
278
|
+
const b = d.querySelector('button');
|
|
279
|
+
if (b) { b.style.background = rec ? '#e5484d' : '#8b93a3'; b.textContent = rec ? '\\u25CF REC \\u2014 stop' : '\\u23FA record'; } }
|
|
280
|
+
}
|
|
281
|
+
let queue = [];
|
|
282
|
+
try { queue = JSON.parse((${DRAIN_JS})()); } catch {}
|
|
283
|
+
return JSON.stringify({ installed, queue });
|
|
284
|
+
}`;
|
|
285
|
+
const TAG_ROLE = {
|
|
286
|
+
a: 'link', button: 'button', select: 'combobox', textarea: 'textbox', summary: 'button',
|
|
287
|
+
};
|
|
288
|
+
export function descriptorRole(ev) {
|
|
289
|
+
if (ev.role)
|
|
290
|
+
return ev.role;
|
|
291
|
+
if (ev.tagName === 'input') {
|
|
292
|
+
if (ev.inputType === 'submit' || ev.inputType === 'button')
|
|
293
|
+
return 'button';
|
|
294
|
+
if (ev.inputType === 'checkbox')
|
|
295
|
+
return 'checkbox';
|
|
296
|
+
if (ev.inputType === 'radio')
|
|
297
|
+
return 'radio';
|
|
298
|
+
return 'textbox';
|
|
299
|
+
}
|
|
300
|
+
return TAG_ROLE[ev.tagName] ?? null;
|
|
301
|
+
}
|
|
302
|
+
export function descriptorName(ev) {
|
|
303
|
+
return ev.ariaLabel || ev.leafText || ev.placeholder || ev.nameAttr || null;
|
|
304
|
+
}
|
|
305
|
+
/** Resolve a descriptor against a REAL playwright snapshot. Strict: no role/name
|
|
306
|
+
* → null; unique role+name → ref; multiple + href → match the link target
|
|
307
|
+
* (host+path); still multiple → candidates (the loop may probe them); else null. */
|
|
308
|
+
export function resolveEvent(ev, nodes) {
|
|
309
|
+
const role = descriptorRole(ev), name = descriptorName(ev);
|
|
310
|
+
if (!role || !name)
|
|
311
|
+
return null;
|
|
312
|
+
const cands = nodes.filter((n) => n.ref && n.role === role && n.name === name);
|
|
313
|
+
if (cands.length === 0)
|
|
314
|
+
return null;
|
|
315
|
+
if (cands.length === 1)
|
|
316
|
+
return { ref: cands[0].ref };
|
|
317
|
+
if (ev.href) {
|
|
318
|
+
const byHref = cands.filter((n) => n.url && !didNavigate(n.url, ev.href));
|
|
319
|
+
if (byHref.length === 1)
|
|
320
|
+
return { ref: byHref[0].ref };
|
|
321
|
+
}
|
|
322
|
+
return { candidates: cands.map((n) => n.ref) };
|
|
323
|
+
}
|
|
324
|
+
/** Latest tick ≤ uptoIdx on the same page as the event (query/hash ignored). */
|
|
325
|
+
export function fromTickFor(ev, ticks, uptoIdx) {
|
|
326
|
+
for (let i = Math.min(uptoIdx, ticks.length - 1); i >= 0; i--) {
|
|
327
|
+
if (!didNavigate(ticks[i].url, ev.url))
|
|
328
|
+
return i;
|
|
329
|
+
}
|
|
330
|
+
return -1;
|
|
331
|
+
}
|
|
332
|
+
/** Which tick is the event's landing? -1 = wait (need the lookahead tick).
|
|
333
|
+
* A navigation often lands AFTER the drain tick; the one-tick lookahead
|
|
334
|
+
* attributes it — unless a later click was drained, which then owns it.
|
|
335
|
+
* ponytail: bursts inside one interval can misattribute; interval default
|
|
336
|
+
* 500ms makes that rare for deliberate QA clicking. */
|
|
337
|
+
export function chooseToTick(drainIdx, ticks, hasLaterClick) {
|
|
338
|
+
const t0 = ticks[drainIdx], t1 = ticks[drainIdx + 1];
|
|
339
|
+
if (!t0)
|
|
340
|
+
return -1;
|
|
341
|
+
if (!t1)
|
|
342
|
+
return -1;
|
|
343
|
+
if (didNavigate(t0.url, t1.url) && !hasLaterClick)
|
|
344
|
+
return drainIdx + 1;
|
|
345
|
+
return drainIdx;
|
|
346
|
+
}
|
|
347
|
+
/** Build the ActionEffect. Unresolved same-page CLICKS are dropped (noise);
|
|
348
|
+
* unresolved NAVIGATED clicks still emit with action:null (the draft's
|
|
349
|
+
* link-scan + cross-link mesh recover link edges); inputs always emit —
|
|
350
|
+
* the field identity powers the draft's login/credentials linkage. */
|
|
351
|
+
export function assembleEffect(ev, ref, from, to) {
|
|
352
|
+
// An input event NEVER navigates, by definition — correct-by-construction guard.
|
|
353
|
+
// Without it, a password-field change drained in the SAME batch as the Login click
|
|
354
|
+
// can pair with the landing tick and record navigated:true, which downstream makes
|
|
355
|
+
// draftFromEffects skip the input-affordance branch → the credentials linkage
|
|
356
|
+
// (needs/acceptsInput) never fires, plus a junk textbox "navigate" edge that
|
|
357
|
+
// passes self-verify (final-review finding #1).
|
|
358
|
+
const role = descriptorRole(ev), name = descriptorName(ev);
|
|
359
|
+
// A CLICK on a form field never navigates either (live finding: a click into
|
|
360
|
+
// "First Name" around a fast page transition paired with the landing tick and
|
|
361
|
+
// recorded "nav: First Name"). Field clicks are input evidence, not edges.
|
|
362
|
+
const fieldClick = ev.kind === 'click' && (role === 'textbox' || role === 'searchbox');
|
|
363
|
+
const navigated = ev.kind === 'input' || fieldClick ? false : didNavigate(ev.url, to.url);
|
|
364
|
+
let action = null;
|
|
365
|
+
if (ref) {
|
|
366
|
+
const fromNodes = parseSnapshot(from.snapshot);
|
|
367
|
+
action = { role: role ?? '', name, ref, elementFp: recoverFingerprint(fromNodes, ref) };
|
|
368
|
+
}
|
|
369
|
+
else if (ev.kind === 'input' && role && name) {
|
|
370
|
+
action = { role, name, ref: null, elementFp: { role, name, near: null } };
|
|
371
|
+
}
|
|
372
|
+
if (action && ev.kind === 'input' && typeof ev.value === 'string')
|
|
373
|
+
action.value = ev.value;
|
|
374
|
+
if (!action && !navigated) {
|
|
375
|
+
return null; // unresolved same-page click → honest drop
|
|
376
|
+
}
|
|
377
|
+
// requestedUrl = the URL the clicked link's DECLARED href asked for (observed
|
|
378
|
+
// evidence, judgment-free) — may differ from the settled to.url on a redirect.
|
|
379
|
+
// Same semantics as runActionRecorded (src/router/browse.ts): absolutize against
|
|
380
|
+
// fromUrl, cross-page http(s) only — a '#'/javascript: href declares no real
|
|
381
|
+
// destination, and recording one would alias the FROM page onto the TO page
|
|
382
|
+
// (state-merge poison).
|
|
383
|
+
let requestedUrl;
|
|
384
|
+
if (navigated && ev.href) {
|
|
385
|
+
try {
|
|
386
|
+
const abs = new URL(ev.href, ev.url);
|
|
387
|
+
if (/^https?:$/.test(abs.protocol) && didNavigate(ev.url, abs.href))
|
|
388
|
+
requestedUrl = abs.href;
|
|
389
|
+
}
|
|
390
|
+
catch { /* unparseable href → no requested url */ }
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
fromUrl: ev.url, fromSnapshot: from.snapshot,
|
|
394
|
+
action,
|
|
395
|
+
toUrl: to.url, toSnapshot: to.snapshot,
|
|
396
|
+
navigated,
|
|
397
|
+
diff: diffSnapshots(parseSnapshot(from.snapshot), parseSnapshot(to.snapshot)),
|
|
398
|
+
requestedUrl,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SnapNode } from '../playwright/snapshot.js';
|
|
2
|
+
/** Interactive roles worth probing for a hidden label when the a11y name is empty. */
|
|
3
|
+
export declare const PROBE_ROLES: Set<string>;
|
|
4
|
+
/** Nodes worth a name-probe: has a ref, role is interactive, and the accessible name is empty. */
|
|
5
|
+
export declare function namelessInteractive(nodes: SnapNode[]): SnapNode[];
|
|
6
|
+
/**
|
|
7
|
+
* Ref-scoped NAME_PROBE_JS eval per nameless node, serialized (playwright-cli runs one
|
|
8
|
+
* command at a time per session — concurrent evals would race the same browser tab),
|
|
9
|
+
* capped, best-effort. An eval that throws (stale ref, CSP) just skips that ref — never
|
|
10
|
+
* aborts the batch (#5a: observe, don't fail the recording over one dead control).
|
|
11
|
+
*/
|
|
12
|
+
export declare function probeNames(adapter: {
|
|
13
|
+
evalJs(js: string, ref?: string): Promise<string>;
|
|
14
|
+
}, nodes: SnapNode[], cap?: number): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NAME_PROBE_JS, enrichName } from './agent-session.js';
|
|
2
|
+
import { parseEvalResult } from '../router/browse.js';
|
|
3
|
+
/** Interactive roles worth probing for a hidden label when the a11y name is empty. */
|
|
4
|
+
export const PROBE_ROLES = new Set([
|
|
5
|
+
'button', 'link', 'menuitem', 'tab', 'checkbox', 'radio', 'switch',
|
|
6
|
+
]);
|
|
7
|
+
/** Nodes worth a name-probe: has a ref, role is interactive, and the accessible name is empty. */
|
|
8
|
+
export function namelessInteractive(nodes) {
|
|
9
|
+
return nodes.filter((n) => n.ref && PROBE_ROLES.has(n.role) && !(n.name ?? '').trim());
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Ref-scoped NAME_PROBE_JS eval per nameless node, serialized (playwright-cli runs one
|
|
13
|
+
* command at a time per session — concurrent evals would race the same browser tab),
|
|
14
|
+
* capped, best-effort. An eval that throws (stale ref, CSP) just skips that ref — never
|
|
15
|
+
* aborts the batch (#5a: observe, don't fail the recording over one dead control).
|
|
16
|
+
*/
|
|
17
|
+
export async function probeNames(adapter, nodes, cap = 16) {
|
|
18
|
+
const hints = {};
|
|
19
|
+
const refs = nodes.map((n) => n.ref).filter((r) => !!r).slice(0, cap);
|
|
20
|
+
for (const ref of refs) {
|
|
21
|
+
let probed = null;
|
|
22
|
+
try {
|
|
23
|
+
probed = parseEvalResult(await adapter.evalJs(NAME_PROBE_JS, ref));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue; // stale ref / eval failure — skip, never abort the batch
|
|
27
|
+
}
|
|
28
|
+
const name = enrichName(null, probed);
|
|
29
|
+
if (name)
|
|
30
|
+
hints[ref] = name;
|
|
31
|
+
}
|
|
32
|
+
return hints;
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { StoredActionEffect, StoredLedgerEvent } from '../mapstore/record.js';
|
|
2
|
+
export interface ReplayStep {
|
|
3
|
+
seq: number;
|
|
4
|
+
label: string;
|
|
5
|
+
status: 'pending' | 'running' | 'ok' | 'fail' | 'jumped' | 'skipped';
|
|
6
|
+
shot: string | null;
|
|
7
|
+
note?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ReplayState {
|
|
10
|
+
session: string;
|
|
11
|
+
running: boolean;
|
|
12
|
+
mode: 'auto' | 'step';
|
|
13
|
+
waiting: 'value' | 'confirm' | null;
|
|
14
|
+
waitingLabel?: string;
|
|
15
|
+
steps: ReplayStep[];
|
|
16
|
+
done: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare class ReplayController {
|
|
20
|
+
readonly state: ReplayState;
|
|
21
|
+
private nextResolve;
|
|
22
|
+
private waitResolve;
|
|
23
|
+
private aborted;
|
|
24
|
+
constructor(session: string, labels: {
|
|
25
|
+
seq: number;
|
|
26
|
+
label: string;
|
|
27
|
+
}[]);
|
|
28
|
+
control(action: 'pause' | 'next' | 'resume' | 'abort'): boolean;
|
|
29
|
+
supply(value: string, save: boolean): boolean;
|
|
30
|
+
confirm(fire: boolean): boolean;
|
|
31
|
+
gate(sleep: (ms: number) => Promise<void>, paceMs: number): Promise<'go' | 'abort'>;
|
|
32
|
+
waitFor(kind: 'value' | 'confirm', label: string): Promise<{
|
|
33
|
+
value?: string;
|
|
34
|
+
save?: boolean;
|
|
35
|
+
fire?: boolean;
|
|
36
|
+
} | 'abort'>;
|
|
37
|
+
}
|
|
38
|
+
export interface ReplayDeps {
|
|
39
|
+
adapter: {
|
|
40
|
+
goto(u: string): Promise<unknown>;
|
|
41
|
+
open(u: string): Promise<unknown>;
|
|
42
|
+
click(r: string): Promise<unknown>;
|
|
43
|
+
fill(r: string, t: string): Promise<unknown>;
|
|
44
|
+
hover(r: string): Promise<unknown>;
|
|
45
|
+
snapshot(): Promise<string>;
|
|
46
|
+
currentUrl(): Promise<string>;
|
|
47
|
+
screenshot(): Promise<string | null>;
|
|
48
|
+
close(): Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
creds: {
|
|
51
|
+
get(site: string): Record<string, string>;
|
|
52
|
+
set(site: string, kv: Record<string, string>): unknown;
|
|
53
|
+
};
|
|
54
|
+
site: string;
|
|
55
|
+
shotsDir: string | null;
|
|
56
|
+
paceMs?: number;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export declare function runReplay(effects: StoredActionEffect[], ctl: ReplayController, deps: ReplayDeps): Promise<ReplayState>;
|
|
60
|
+
export declare function runLedgerReplay(events: StoredLedgerEvent[], ctl: ReplayController, deps: ReplayDeps): Promise<ReplayState>;
|