@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,308 @@
|
|
|
1
|
+
import { PlaywrightAdapter } from '../playwright/adapter.js';
|
|
2
|
+
import { MapStore } from '../mapstore/store.js';
|
|
3
|
+
import { parseSnapshot, findByRoleAndName } from '../playwright/snapshot.js';
|
|
4
|
+
import { makeState, makeAffordance } from '../mapstore/types.js';
|
|
5
|
+
import { walkRoute } from './walk.js';
|
|
6
|
+
/**
|
|
7
|
+
* Seed the `www.saucedemo.com` page-states + navigation edges inline (STRUCTURE
|
|
8
|
+
* ONLY, principle #6 — no credentials/names/zips; those are runtime, supplied by
|
|
9
|
+
* the live browser closure). This replaces the old hand-seeded saucedemo skeleton:
|
|
10
|
+
* saucedemo is now an agent-built `www.saucedemo.com` graph, and the multi-step
|
|
11
|
+
* walk seeds its interior inline here for the live wiring + gated e2es.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: upsertState (ON CONFLICT id) and upsertEdge (UNIQUE from,to,step).
|
|
14
|
+
*/
|
|
15
|
+
export function seedSaucedemoForWalk(store) {
|
|
16
|
+
const N = 'www.saucedemo.com';
|
|
17
|
+
store.transaction(() => {
|
|
18
|
+
// The site-node row (so getNode works — needed by the dashboard, the hosted
|
|
19
|
+
// map pack, and /api/maps). The interior states below are its skeleton.
|
|
20
|
+
store.upsertNode({ id: N, homeUrl: 'https://www.saucedemo.com/' });
|
|
21
|
+
// Each state's REPERTOIRE is the source of truth (spec §8). navigate/reveal
|
|
22
|
+
// affordances with a toState project into the walk's edges; mutate/input never
|
|
23
|
+
// route (they're fired by the agent at a pause if it wants them). The "Finish"
|
|
24
|
+
// affordance is commit:true → it projects as a commit-point and is NEVER fired (#2).
|
|
25
|
+
const states = [
|
|
26
|
+
makeState({ id: `${N}:login`, nodeId: N, semanticName: `${N}:login`,
|
|
27
|
+
urlPattern: 'https://www.saucedemo.com/', role: 'detail',
|
|
28
|
+
fingerprint: ['textbox:Username', 'button:Login'],
|
|
29
|
+
affordances: [
|
|
30
|
+
makeAffordance({ id: 'aff_username', label: 'enter Username', kind: 'input' }),
|
|
31
|
+
makeAffordance({ id: 'aff_password', label: 'enter Password', kind: 'input' }),
|
|
32
|
+
makeAffordance({ id: 'aff_login', label: 'log in by clicking "Login"', kind: 'navigate',
|
|
33
|
+
toState: `${N}:inventory`, needs: ['aff_username', 'aff_password'], acceptsInput: 'credentials', core: true }),
|
|
34
|
+
] }),
|
|
35
|
+
makeState({ id: `${N}:inventory`, nodeId: N, semanticName: `${N}:inventory`,
|
|
36
|
+
urlPattern: '*inventory*', role: 'detail', fingerprint: ['button:Add to cart'],
|
|
37
|
+
affordances: [
|
|
38
|
+
makeAffordance({ id: 'aff_cart', label: 'open the shopping cart', kind: 'navigate',
|
|
39
|
+
toState: `${N}:cart`, addressableUrl: 'https://www.saucedemo.com/cart.html', core: true }),
|
|
40
|
+
makeAffordance({ id: 'aff_sort', label: 'sort products', kind: 'mutate' }),
|
|
41
|
+
makeAffordance({ id: 'aff_addcart', label: 'add an item to the cart', kind: 'mutate' }),
|
|
42
|
+
makeAffordance({ id: 'aff_menu', label: 'open the burger menu', kind: 'reveal', children: [
|
|
43
|
+
makeAffordance({ id: 'aff_allitems', label: 'All Items', kind: 'navigate', toState: `${N}:inventory` }),
|
|
44
|
+
makeAffordance({ id: 'aff_about', label: 'About', kind: 'navigate', toState: null }), // unexplored (offsite)
|
|
45
|
+
makeAffordance({ id: 'aff_logout', label: 'Logout', kind: 'navigate', toState: `${N}:login` }),
|
|
46
|
+
makeAffordance({ id: 'aff_reset', label: 'Reset App State', kind: 'mutate' }),
|
|
47
|
+
] }),
|
|
48
|
+
] }),
|
|
49
|
+
makeState({ id: `${N}:cart`, nodeId: N, semanticName: `${N}:cart`,
|
|
50
|
+
urlPattern: '*cart*', role: 'detail', fingerprint: ['button:Checkout'],
|
|
51
|
+
affordances: [
|
|
52
|
+
makeAffordance({ id: 'aff_checkout', label: 'click "Checkout"', kind: 'navigate',
|
|
53
|
+
toState: `${N}:checkout-info`, core: true }),
|
|
54
|
+
makeAffordance({ id: 'aff_continue_shopping', label: 'Continue Shopping', kind: 'navigate',
|
|
55
|
+
toState: `${N}:inventory` }),
|
|
56
|
+
] }),
|
|
57
|
+
makeState({ id: `${N}:checkout-info`, nodeId: N, semanticName: `${N}:checkout-info`,
|
|
58
|
+
urlPattern: '*checkout-step-one*', role: 'detail', fingerprint: ['textbox:First Name', 'button:Continue'],
|
|
59
|
+
affordances: [
|
|
60
|
+
makeAffordance({ id: 'aff_first', label: 'enter First Name', kind: 'input' }),
|
|
61
|
+
makeAffordance({ id: 'aff_last', label: 'enter Last Name', kind: 'input' }),
|
|
62
|
+
makeAffordance({ id: 'aff_zip', label: 'enter Zip/Postal Code', kind: 'input' }),
|
|
63
|
+
makeAffordance({ id: 'aff_continue', label: 'click "Continue"', kind: 'navigate',
|
|
64
|
+
toState: `${N}:checkout-overview`, needs: ['aff_first', 'aff_last', 'aff_zip'], acceptsInput: 'shipping', core: true }),
|
|
65
|
+
] }),
|
|
66
|
+
makeState({ id: `${N}:checkout-overview`, nodeId: N, semanticName: `${N}:checkout-overview`,
|
|
67
|
+
urlPattern: '*checkout-step-two*', role: 'detail', fingerprint: ['button:Finish'],
|
|
68
|
+
affordances: [
|
|
69
|
+
makeAffordance({ id: 'aff_finish', label: 'click "Finish"', kind: 'navigate',
|
|
70
|
+
toState: `${N}:purchase-complete`, commit: true }),
|
|
71
|
+
] }),
|
|
72
|
+
];
|
|
73
|
+
for (const s of states)
|
|
74
|
+
store.upsertState(s);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build a live WalkBrowser over a playwright adapter, resolving each edge's input
|
|
79
|
+
* slot from `inputs` at fill time. `inputs` is held only in memory here — never
|
|
80
|
+
* persisted. Shared by runWalkLive and the walk / walk-resume CLI verbs.
|
|
81
|
+
*
|
|
82
|
+
* `browserOpts` (the SAME launch opts — incl. `profile` — the caller opened
|
|
83
|
+
* `adapter` with) enables the fresh-session SSO-wall retry (design item 2):
|
|
84
|
+
* when supplied, the returned WalkBrowser gets `currentUrl`/`reopenFresh` so
|
|
85
|
+
* walkRoute can close this session and open a brand-new one under the SAME
|
|
86
|
+
* profile on a wall. Omit it (as the saucedemo dry-run wiring below does) to
|
|
87
|
+
* leave those capabilities off — the walk simply never attempts the retry.
|
|
88
|
+
*/
|
|
89
|
+
export function makeLiveWalkBrowser(adapter, inputs, browserOpts, initialSessionId) {
|
|
90
|
+
let live = adapter;
|
|
91
|
+
let liveSessionId = initialSessionId ?? '';
|
|
92
|
+
let lastSnapshot = '';
|
|
93
|
+
async function fieldRef(name) {
|
|
94
|
+
let nodes = parseSnapshot(lastSnapshot);
|
|
95
|
+
let node = findByRoleAndName(nodes, 'textbox', name);
|
|
96
|
+
if (!node || !node.ref) {
|
|
97
|
+
lastSnapshot = await live.snapshot();
|
|
98
|
+
nodes = parseSnapshot(lastSnapshot);
|
|
99
|
+
node = findByRoleAndName(nodes, 'textbox', name);
|
|
100
|
+
}
|
|
101
|
+
if (!node || !node.ref)
|
|
102
|
+
throw new Error('walk: could not resolve textbox "' + name + '"');
|
|
103
|
+
return node.ref;
|
|
104
|
+
}
|
|
105
|
+
const browser = {
|
|
106
|
+
snapshot: async () => {
|
|
107
|
+
lastSnapshot = await live.snapshot();
|
|
108
|
+
return lastSnapshot;
|
|
109
|
+
},
|
|
110
|
+
callCount: () => live.callCount,
|
|
111
|
+
goto: async (url) => { await live.goto(url); },
|
|
112
|
+
waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
113
|
+
act: async (ref, inputSlot) => {
|
|
114
|
+
if (inputSlot === 'credentials') {
|
|
115
|
+
await live.fill(await fieldRef('Username'), inputs.username);
|
|
116
|
+
await live.fill(await fieldRef('Password'), inputs.password);
|
|
117
|
+
await live.click(ref);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (inputSlot === 'shipping') {
|
|
121
|
+
await live.fill(await fieldRef('First Name'), inputs.firstName ?? 'A');
|
|
122
|
+
await live.fill(await fieldRef('Last Name'), inputs.lastName ?? 'B');
|
|
123
|
+
await live.fill(await fieldRef('Zip/Postal Code'), inputs.zip);
|
|
124
|
+
await live.click(ref);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await live.click(ref);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
if (browserOpts) {
|
|
131
|
+
browser.currentUrl = () => live.currentUrl();
|
|
132
|
+
browser.close = () => live.close().then(() => undefined);
|
|
133
|
+
browser.sessionId = () => liveSessionId;
|
|
134
|
+
// Close the walled session, open a FRESH one under the SAME profile (same
|
|
135
|
+
// identity/cookies — equivalent to the user reopening a tab; NOT evasion),
|
|
136
|
+
// land back on `url`. Never throws on the close (best-effort cleanup).
|
|
137
|
+
browser.reopenFresh = async (url) => {
|
|
138
|
+
await live.close().catch(() => { });
|
|
139
|
+
liveSessionId = 'w-' + Date.now();
|
|
140
|
+
live = new PlaywrightAdapter(liveSessionId, undefined, undefined, browserOpts);
|
|
141
|
+
await live.open(url);
|
|
142
|
+
lastSnapshot = await live.snapshot();
|
|
143
|
+
return lastSnapshot;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return browser;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Live wiring for the saucedemo multi-step walk (increment W2).
|
|
150
|
+
*
|
|
151
|
+
* Drives the REAL PlaywrightAdapter against live saucedemo.com through the SAME
|
|
152
|
+
* async `walkRoute` loop the unit test exercises (zero duplicated loop logic).
|
|
153
|
+
*
|
|
154
|
+
* The walk is runtime-value-free: it only passes each edge's `acceptsInput` slot
|
|
155
|
+
* NAME to `act`. THIS closure owns the `inputs` map and resolves slot -> value(s)
|
|
156
|
+
* when filling fields (principle #6: inputs are runtime, never stored as map).
|
|
157
|
+
*
|
|
158
|
+
* Goal is `www.saucedemo.com:checkout-overview` (a PASS-THROUGH state), so the walk
|
|
159
|
+
* HALTS there and returns `done` WITHOUT ever attempting the next edge — the
|
|
160
|
+
* unclassified "Finish" commit point is never fired (principle #2).
|
|
161
|
+
*/
|
|
162
|
+
export async function runWalkLive(inputs, dbPath) {
|
|
163
|
+
// 1. File-backed MapStore; DB is authoritative — the saucedemo interior is written
|
|
164
|
+
// inline here (saucedemo is no longer part of the seeded graph). If it's absent,
|
|
165
|
+
// the walk seeds it once (the single bootstrap).
|
|
166
|
+
const store = new MapStore(dbPath);
|
|
167
|
+
if (!store.getState('www.saucedemo.com:checkout-overview')) {
|
|
168
|
+
seedSaucedemoForWalk(store);
|
|
169
|
+
}
|
|
170
|
+
// 2. Open a real browser session on the saucedemo login page.
|
|
171
|
+
const adapter = new PlaywrightAdapter('sd-' + Date.now());
|
|
172
|
+
await adapter.open('https://www.saucedemo.com/');
|
|
173
|
+
// Cache the most recent snapshot so `act` can resolve input fields by role+name
|
|
174
|
+
// WITHOUT an extra playwright call. The walk always snapshots immediately before
|
|
175
|
+
// calling act() (the top-of-loop read), so `lastSnapshot` is the current page.
|
|
176
|
+
let lastSnapshot = '';
|
|
177
|
+
// Resolve a textbox ref by its accessible name on the current (cached) page.
|
|
178
|
+
// Falls back to a fresh snapshot if the cache is somehow empty (counts as a call).
|
|
179
|
+
async function fieldRef(name) {
|
|
180
|
+
let nodes = parseSnapshot(lastSnapshot);
|
|
181
|
+
let node = findByRoleAndName(nodes, 'textbox', name);
|
|
182
|
+
if (!node || !node.ref) {
|
|
183
|
+
lastSnapshot = await adapter.snapshot();
|
|
184
|
+
nodes = parseSnapshot(lastSnapshot);
|
|
185
|
+
node = findByRoleAndName(nodes, 'textbox', name);
|
|
186
|
+
}
|
|
187
|
+
if (!node || !node.ref) {
|
|
188
|
+
throw new Error('walk-live: could not resolve textbox "' + name + '" on current page');
|
|
189
|
+
}
|
|
190
|
+
return node.ref;
|
|
191
|
+
}
|
|
192
|
+
// 3. Build the live WalkBrowser. `act` performs the per-edge field-fills BEFORE
|
|
193
|
+
// the resolved click. saucedemo steps are multi-field, so a single edge's act
|
|
194
|
+
// fans out into several adapter calls — the walk stays a single linear loop.
|
|
195
|
+
const browser = {
|
|
196
|
+
snapshot: async () => {
|
|
197
|
+
lastSnapshot = await adapter.snapshot();
|
|
198
|
+
return lastSnapshot;
|
|
199
|
+
},
|
|
200
|
+
callCount: () => adapter.callCount,
|
|
201
|
+
waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
202
|
+
// Tier-1 addressable jump: the walk calls this for an edge with addressableUrl
|
|
203
|
+
// (the cart link is icon-only / unstable on saucedemo, but cart.html is canonical).
|
|
204
|
+
goto: async (url) => { await adapter.goto(url); },
|
|
205
|
+
act: async (ref, inputSlot) => {
|
|
206
|
+
if (inputSlot === 'credentials') {
|
|
207
|
+
// LOGIN edge: replayStep resolved `ref` to the "Login" button. Fill
|
|
208
|
+
// Username + Password first, then click.
|
|
209
|
+
const userRef = await fieldRef('Username');
|
|
210
|
+
const passRef = await fieldRef('Password');
|
|
211
|
+
await adapter.fill(userRef, inputs.username);
|
|
212
|
+
await adapter.fill(passRef, inputs.password);
|
|
213
|
+
await adapter.click(ref);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (inputSlot === 'shipping') {
|
|
217
|
+
// SHIPPING edge (checkout-step-one): fill the three address fields, then
|
|
218
|
+
// click `ref` (the resolved "Continue" button).
|
|
219
|
+
const firstRef = await fieldRef('First Name');
|
|
220
|
+
const lastRef = await fieldRef('Last Name');
|
|
221
|
+
const zipRef = await fieldRef('Zip/Postal Code');
|
|
222
|
+
await adapter.fill(firstRef, inputs.firstName);
|
|
223
|
+
await adapter.fill(lastRef, inputs.lastName);
|
|
224
|
+
await adapter.fill(zipRef, inputs.zip);
|
|
225
|
+
await adapter.click(ref);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Plain-click edge (e.g. cart -> checkout-info "Checkout"): click the resolved ref.
|
|
229
|
+
await adapter.click(ref);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
// 4. Walk login -> checkout-overview through the ONE async loop. Note: `inputs`
|
|
233
|
+
// is captured by the browser closure above, NOT passed into walkRoute (it was
|
|
234
|
+
// removed from WalkArgs — the walk only forwards the acceptsInput slot name).
|
|
235
|
+
const result = await walkRoute({
|
|
236
|
+
goalName: 'complete-checkout-dryrun',
|
|
237
|
+
startStateId: 'www.saucedemo.com:login',
|
|
238
|
+
goalStateId: 'www.saucedemo.com:checkout-overview',
|
|
239
|
+
store,
|
|
240
|
+
states: store.statesForNode('www.saucedemo.com'),
|
|
241
|
+
browser,
|
|
242
|
+
});
|
|
243
|
+
// 5. Always close the browser session, then return the walk result.
|
|
244
|
+
await adapter.close();
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Seed the saucedemo interior WITH the post-commit `checkout-complete` state, so a
|
|
249
|
+
* walk can go all the way through the Finish commit to the "Thank you" page. The
|
|
250
|
+
* Finish affordance is commit:true → the walk still HALTS there (needs-classification)
|
|
251
|
+
* unless the agent classifies it safe (which runWalkLiveComplete does, because
|
|
252
|
+
* saucedemo is a demo with no real payment).
|
|
253
|
+
*/
|
|
254
|
+
export function seedSaucedemoComplete(store) {
|
|
255
|
+
const N = 'www.saucedemo.com';
|
|
256
|
+
seedSaucedemoForWalk(store); // login..checkout-overview + menu
|
|
257
|
+
store.transaction(() => {
|
|
258
|
+
// Re-point Finish at a REAL checkout-complete state and add that state.
|
|
259
|
+
store.upsertState(makeState({ id: `${N}:checkout-overview`, nodeId: N, semanticName: `${N}:checkout-overview`,
|
|
260
|
+
urlPattern: '*checkout-step-two*', role: 'detail', fingerprint: ['button:Finish'],
|
|
261
|
+
affordances: [
|
|
262
|
+
makeAffordance({ id: 'aff_finish', label: 'click "Finish"', kind: 'navigate',
|
|
263
|
+
toState: `${N}:checkout-complete`, commit: true, core: true }),
|
|
264
|
+
] }));
|
|
265
|
+
store.upsertState(makeState({ id: `${N}:checkout-complete`, nodeId: N, semanticName: `${N}:checkout-complete`,
|
|
266
|
+
urlPattern: '*checkout-complete*', role: 'detail', fingerprint: ['heading:Thank you for your order!', 'button:Back Home'],
|
|
267
|
+
affordances: [
|
|
268
|
+
makeAffordance({ id: 'aff_back_home', label: 'Back Home', kind: 'navigate', toState: `${N}:inventory` }),
|
|
269
|
+
] }));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Live wiring for the FULL order completion (increment R5): walk login → … →
|
|
274
|
+
* checkout-overview → (Finish commit, classified SAFE because saucedemo is a demo)
|
|
275
|
+
* → checkout-complete. Proves the resume loop fires a commit point end-to-end on a
|
|
276
|
+
* real site after an explicit agent "safe" verdict — the only path that fires a commit.
|
|
277
|
+
*/
|
|
278
|
+
export async function runWalkLiveComplete(inputs, dbPath) {
|
|
279
|
+
const N = 'www.saucedemo.com';
|
|
280
|
+
const store = new MapStore(dbPath);
|
|
281
|
+
if (!store.getState(`${N}:checkout-complete`))
|
|
282
|
+
seedSaucedemoComplete(store);
|
|
283
|
+
const adapter = new PlaywrightAdapter('sdc' + (Date.now() % 100000));
|
|
284
|
+
await adapter.open('https://www.saucedemo.com/');
|
|
285
|
+
const browser = makeLiveWalkBrowser(adapter, inputs);
|
|
286
|
+
const states = store.statesForNode(N);
|
|
287
|
+
const args = {
|
|
288
|
+
goalName: 'complete-checkout', startStateId: `${N}:login`, goalStateId: `${N}:checkout-complete`,
|
|
289
|
+
store, states, browser,
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
// Leg 1: walk to the goal. It will HALT at the Finish commit (needs-classification).
|
|
293
|
+
let result = await walkRoute(args);
|
|
294
|
+
// Leg 2: agent decides the demo Finish is SAFE → resume; the walk fires it and
|
|
295
|
+
// continues to checkout-complete. (R5 resume loop.)
|
|
296
|
+
if (result.status === 'needs-classification') {
|
|
297
|
+
result = await walkRoute({
|
|
298
|
+
...args,
|
|
299
|
+
startStateId: `${N}:checkout-overview`, // resume from where it halted
|
|
300
|
+
answer: { kind: 'classify', verdict: 'safe' },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
await adapter.close().catch(() => { });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export interface WalkSession {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
startState: string;
|
|
5
|
+
goalState: string;
|
|
6
|
+
path: string[];
|
|
7
|
+
pos: number;
|
|
8
|
+
browserSession: string;
|
|
9
|
+
status: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
observe: string[];
|
|
12
|
+
observeDynamic: boolean;
|
|
13
|
+
observed: string[];
|
|
14
|
+
pauseKind?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Persists a PAUSED walk's position + the playwright session NAME (the browser
|
|
17
|
+
* itself survives across CLI processes, so we don't serialize browser state).
|
|
18
|
+
* No inputs are ever stored — credentials/form values are runtime-only. */
|
|
19
|
+
export declare class WalkSessionStore {
|
|
20
|
+
private db;
|
|
21
|
+
constructor(path?: string);
|
|
22
|
+
static fromDatabase(db: Database.Database): WalkSessionStore;
|
|
23
|
+
private migrate;
|
|
24
|
+
create(args: {
|
|
25
|
+
startState: string;
|
|
26
|
+
goalState: string;
|
|
27
|
+
path: string[];
|
|
28
|
+
browserSession: string;
|
|
29
|
+
profile?: string;
|
|
30
|
+
nowMs?: number;
|
|
31
|
+
observe?: string[];
|
|
32
|
+
observeDynamic?: boolean;
|
|
33
|
+
}): string;
|
|
34
|
+
load(sessionId: string): WalkSession | null;
|
|
35
|
+
advance(sessionId: string, pos: number): void;
|
|
36
|
+
/** Persist checkpoint fire-once state + the pause kind this call returned, so a
|
|
37
|
+
* subsequent walk-resume (a) doesn't re-fire an already-observed checkpoint and
|
|
38
|
+
* (b) can reject `--continue` unless it's actually answering a checkpoint pause. */
|
|
39
|
+
setPause(sessionId: string, observed: string[], pauseKind: string): void;
|
|
40
|
+
/** Repoint a paused session at a DIFFERENT live browser — a wall retry (design
|
|
41
|
+
* item 2) may rotate to a brand-new session mid-walk; the session_id the agent
|
|
42
|
+
* already has (and resumes against) stays stable, only the browser it reattaches
|
|
43
|
+
* to changes. */
|
|
44
|
+
rebrowser(sessionId: string, browserSession: string): void;
|
|
45
|
+
close(sessionId: string): void;
|
|
46
|
+
/** browser_session ids of paused walks older than `maxAgeMs` (abandoned pauses whose live
|
|
47
|
+
* browser nothing else reaps — the ceiling pre-check closes these). */
|
|
48
|
+
staleBrowserSessions(maxAgeMs: number, nowMs?: number): string[];
|
|
49
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { dbPath } from '../paths.js';
|
|
6
|
+
const SCHEMA = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'mapstore', 'schema.sql'), 'utf8');
|
|
7
|
+
/** Persists a PAUSED walk's position + the playwright session NAME (the browser
|
|
8
|
+
* itself survives across CLI processes, so we don't serialize browser state).
|
|
9
|
+
* No inputs are ever stored — credentials/form values are runtime-only. */
|
|
10
|
+
export class WalkSessionStore {
|
|
11
|
+
db;
|
|
12
|
+
constructor(path = dbPath()) {
|
|
13
|
+
this.db = new Database(path);
|
|
14
|
+
this.db.exec(SCHEMA);
|
|
15
|
+
this.migrate();
|
|
16
|
+
}
|
|
17
|
+
static fromDatabase(db) {
|
|
18
|
+
const s = Object.create(WalkSessionStore.prototype);
|
|
19
|
+
s.db = db;
|
|
20
|
+
db.exec(SCHEMA);
|
|
21
|
+
s.migrate();
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
// profile = the named browser profile this walk is running under. Persisted so
|
|
25
|
+
// walk-resume can rebuild the SAME BrowserOpts the original `walk` used (needed
|
|
26
|
+
// for the fresh-session SSO-wall retry, which requires knowing the profile to
|
|
27
|
+
// reopen under). Additive column, same pattern as record_sessions.profile.
|
|
28
|
+
migrate() {
|
|
29
|
+
const cols = new Set(this.db.prepare('PRAGMA table_info(walk_sessions)').all().map((c) => c.name));
|
|
30
|
+
if (!cols.has('profile'))
|
|
31
|
+
this.db.exec('ALTER TABLE walk_sessions ADD COLUMN profile TEXT');
|
|
32
|
+
if (!cols.has('observe'))
|
|
33
|
+
this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observe TEXT');
|
|
34
|
+
if (!cols.has('observe_dynamic'))
|
|
35
|
+
this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observe_dynamic INTEGER');
|
|
36
|
+
if (!cols.has('observed'))
|
|
37
|
+
this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observed TEXT');
|
|
38
|
+
if (!cols.has('pause_kind'))
|
|
39
|
+
this.db.exec('ALTER TABLE walk_sessions ADD COLUMN pause_kind TEXT');
|
|
40
|
+
}
|
|
41
|
+
create(args) {
|
|
42
|
+
const id = `walk-${args.browserSession}`;
|
|
43
|
+
this.db.prepare(`INSERT INTO walk_sessions (session_id,start_state,goal_state,path,pos,browser_session,status,created_at,profile,observe,observe_dynamic,observed,pause_kind)
|
|
44
|
+
VALUES (?,?,?,?,0,?,'paused',?,?,?,?,?,?)
|
|
45
|
+
ON CONFLICT(session_id) DO UPDATE SET start_state=excluded.start_state, goal_state=excluded.goal_state,
|
|
46
|
+
path=excluded.path, pos=0, browser_session=excluded.browser_session, status='paused', created_at=excluded.created_at,
|
|
47
|
+
profile=excluded.profile, observe=excluded.observe, observe_dynamic=excluded.observe_dynamic,
|
|
48
|
+
observed=excluded.observed, pause_kind=excluded.pause_kind`)
|
|
49
|
+
.run(id, args.startState, args.goalState, JSON.stringify(args.path), args.browserSession, args.nowMs ?? Date.now(), args.profile ?? null, JSON.stringify(args.observe ?? []), args.observeDynamic ? 1 : 0, JSON.stringify([]), null);
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
load(sessionId) {
|
|
53
|
+
const r = this.db.prepare("SELECT * FROM walk_sessions WHERE session_id=? AND status='paused'").get(sessionId);
|
|
54
|
+
if (!r)
|
|
55
|
+
return null;
|
|
56
|
+
return { sessionId: r.session_id, startState: r.start_state, goalState: r.goal_state,
|
|
57
|
+
path: JSON.parse(r.path), pos: r.pos, browserSession: r.browser_session, status: r.status,
|
|
58
|
+
profile: r.profile ?? undefined,
|
|
59
|
+
observe: r.observe ? JSON.parse(r.observe) : [],
|
|
60
|
+
observeDynamic: !!r.observe_dynamic,
|
|
61
|
+
observed: r.observed ? JSON.parse(r.observed) : [],
|
|
62
|
+
pauseKind: r.pause_kind ?? undefined };
|
|
63
|
+
}
|
|
64
|
+
advance(sessionId, pos) {
|
|
65
|
+
this.db.prepare('UPDATE walk_sessions SET pos=? WHERE session_id=?').run(pos, sessionId);
|
|
66
|
+
}
|
|
67
|
+
/** Persist checkpoint fire-once state + the pause kind this call returned, so a
|
|
68
|
+
* subsequent walk-resume (a) doesn't re-fire an already-observed checkpoint and
|
|
69
|
+
* (b) can reject `--continue` unless it's actually answering a checkpoint pause. */
|
|
70
|
+
setPause(sessionId, observed, pauseKind) {
|
|
71
|
+
this.db.prepare('UPDATE walk_sessions SET observed=?, pause_kind=? WHERE session_id=?')
|
|
72
|
+
.run(JSON.stringify(observed), pauseKind, sessionId);
|
|
73
|
+
}
|
|
74
|
+
/** Repoint a paused session at a DIFFERENT live browser — a wall retry (design
|
|
75
|
+
* item 2) may rotate to a brand-new session mid-walk; the session_id the agent
|
|
76
|
+
* already has (and resumes against) stays stable, only the browser it reattaches
|
|
77
|
+
* to changes. */
|
|
78
|
+
rebrowser(sessionId, browserSession) {
|
|
79
|
+
this.db.prepare('UPDATE walk_sessions SET browser_session=? WHERE session_id=?').run(browserSession, sessionId);
|
|
80
|
+
}
|
|
81
|
+
close(sessionId) {
|
|
82
|
+
this.db.prepare("UPDATE walk_sessions SET status='done' WHERE session_id=?").run(sessionId);
|
|
83
|
+
}
|
|
84
|
+
/** browser_session ids of paused walks older than `maxAgeMs` (abandoned pauses whose live
|
|
85
|
+
* browser nothing else reaps — the ceiling pre-check closes these). */
|
|
86
|
+
staleBrowserSessions(maxAgeMs, nowMs = Date.now()) {
|
|
87
|
+
const rows = this.db.prepare("SELECT browser_session, created_at FROM walk_sessions WHERE status='paused'").all();
|
|
88
|
+
return rows.filter((r) => nowMs - r.created_at >= maxAgeMs).map((r) => r.browser_session);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { State } from '../mapstore/types.js';
|
|
2
|
+
import type { MapStore } from '../mapstore/store.js';
|
|
3
|
+
import type { RecallResponse } from '../protocol.js';
|
|
4
|
+
export interface WalkBrowser {
|
|
5
|
+
snapshot(): Promise<string>;
|
|
6
|
+
act(ref: string, inputSlot: string | null): Promise<void>;
|
|
7
|
+
typeText?(ref: string, text: string): Promise<void>;
|
|
8
|
+
goto?(url: string, inputSlot: string | null): Promise<void>;
|
|
9
|
+
waitMs?(ms: number): Promise<void>;
|
|
10
|
+
scroll?(dy: number): Promise<void>;
|
|
11
|
+
callCount(): number;
|
|
12
|
+
currentUrl?(): Promise<string>;
|
|
13
|
+
reopenFresh?(url: string): Promise<string>;
|
|
14
|
+
close?(): Promise<void>;
|
|
15
|
+
sessionId?(): string;
|
|
16
|
+
}
|
|
17
|
+
export type WalkAnswer = {
|
|
18
|
+
kind: 'ref';
|
|
19
|
+
ref: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'classify';
|
|
22
|
+
verdict: 'safe' | 'commit';
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'continue';
|
|
25
|
+
};
|
|
26
|
+
export interface WalkArgs {
|
|
27
|
+
goalName: string;
|
|
28
|
+
startStateId: string;
|
|
29
|
+
goalStateId: string;
|
|
30
|
+
store: MapStore;
|
|
31
|
+
states: State[];
|
|
32
|
+
browser: WalkBrowser;
|
|
33
|
+
path?: string[];
|
|
34
|
+
answer?: WalkAnswer;
|
|
35
|
+
profile?: string;
|
|
36
|
+
observe?: string[];
|
|
37
|
+
observeDynamic?: boolean;
|
|
38
|
+
observed?: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The interactive multi-step walk (design §3). Walks a linear route edge-by-edge
|
|
42
|
+
* from `startStateId` toward `goalStateId`, verifying every step (prediction vs
|
|
43
|
+
* observation) and escalating to the agent on drift or at a commit point.
|
|
44
|
+
*
|
|
45
|
+
* Zero LLM: replayStep resolves deterministically (cached ref, then role+name);
|
|
46
|
+
* any decision webnav isn't allowed to make is handed back as a `needs-*` response.
|
|
47
|
+
*/
|
|
48
|
+
export declare function walkRoute(args: WalkArgs): Promise<RecallResponse>;
|