effing-use 0.1.1 → 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/README.md +45 -18
- package/package.json +4 -3
- package/skills/effing-use/SKILL.md +34 -10
- package/src/browser/delta.ts +136 -0
- package/src/browser/engine.ts +541 -55
- package/src/browser/evidence.ts +186 -0
- package/src/browser/identity.ts +95 -0
- package/src/browser/macro.ts +102 -0
- package/src/browser/record.ts +117 -0
- package/src/browser/refs.ts +158 -7
- package/src/browser/session.ts +16 -0
- package/src/browser/state.ts +100 -0
- package/src/cli.ts +173 -0
- package/src/config.ts +10 -0
- package/src/http.ts +1 -0
- package/src/tools/act.ts +9 -2
- package/src/tools/extract.ts +1 -0
- package/src/tools/observe.ts +5 -1
package/src/browser/engine.ts
CHANGED
|
@@ -2,7 +2,35 @@ import type { Page, BrowserContext } from "playwright";
|
|
|
2
2
|
import type { Config } from "../config.js";
|
|
3
3
|
import { EngineError, resolveLocator } from "./refs.js";
|
|
4
4
|
import { cap, saveText, stamp } from "./output.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getConsoleLogs,
|
|
7
|
+
getNetworkLogs,
|
|
8
|
+
closeSession,
|
|
9
|
+
getMustObserve,
|
|
10
|
+
setMustObserve,
|
|
11
|
+
clearMustObserve,
|
|
12
|
+
} from "./session.js";
|
|
13
|
+
import { collectEffect, evaluateExpect, parseExpect } from "./evidence.js";
|
|
14
|
+
import {
|
|
15
|
+
getBaseline,
|
|
16
|
+
setBaseline,
|
|
17
|
+
computeDelta,
|
|
18
|
+
injectDirtyObserver,
|
|
19
|
+
checkDirty,
|
|
20
|
+
clearDirtyFlag,
|
|
21
|
+
ensureState as ensureDeltaState,
|
|
22
|
+
} from "./delta.js";
|
|
23
|
+
import { appendAction, appendNote, readState } from "./state.js";
|
|
24
|
+
import {
|
|
25
|
+
isRecording,
|
|
26
|
+
captureStep,
|
|
27
|
+
saveRecording,
|
|
28
|
+
loadRecording,
|
|
29
|
+
startRecording,
|
|
30
|
+
stopRecording,
|
|
31
|
+
} from "./record.js";
|
|
32
|
+
import { compileMacro, isIrreversible } from "./macro.js";
|
|
33
|
+
import { registerFingerprints, clearRegistry } from "./identity.js";
|
|
6
34
|
|
|
7
35
|
export type ActAction =
|
|
8
36
|
| "open"
|
|
@@ -31,26 +59,32 @@ export type ActAction =
|
|
|
31
59
|
| "tab_new"
|
|
32
60
|
| "tab_select"
|
|
33
61
|
| "tab_close"
|
|
34
|
-
| "resize"
|
|
62
|
+
| "resize"
|
|
63
|
+
| "note"
|
|
64
|
+
| "record_start"
|
|
65
|
+
| "record_stop"
|
|
66
|
+
| "compile"
|
|
67
|
+
| "replay";
|
|
35
68
|
|
|
36
69
|
export interface ActOpts {
|
|
37
70
|
context?: BrowserContext;
|
|
38
71
|
sessionId?: string;
|
|
72
|
+
expect?: string;
|
|
73
|
+
approve?: boolean;
|
|
39
74
|
}
|
|
40
|
-
|
|
41
75
|
export interface ObserveOpts {
|
|
42
76
|
context?: BrowserContext;
|
|
43
77
|
sessionId?: string;
|
|
78
|
+
mode?: "full" | "delta";
|
|
79
|
+
scope?: string;
|
|
44
80
|
}
|
|
45
81
|
|
|
46
82
|
function timeoutOf(config: Config): number {
|
|
47
83
|
return config.timeoutMs;
|
|
48
84
|
}
|
|
49
|
-
|
|
50
85
|
async function liteState(page: Page): Promise<{ url: string; title: string }> {
|
|
51
86
|
return { url: page.url(), title: await page.title().catch(() => "") };
|
|
52
87
|
}
|
|
53
|
-
|
|
54
88
|
function toEngineError(e: unknown, fallbackHint: string): EngineError {
|
|
55
89
|
if (e instanceof EngineError) return e;
|
|
56
90
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -63,9 +97,34 @@ function toEngineError(e: unknown, fallbackHint: string): EngineError {
|
|
|
63
97
|
fallbackHint,
|
|
64
98
|
);
|
|
65
99
|
}
|
|
100
|
+
const MUTATING = new Set<string>([
|
|
101
|
+
"click",
|
|
102
|
+
"dblclick",
|
|
103
|
+
"fill",
|
|
104
|
+
"type",
|
|
105
|
+
"press",
|
|
106
|
+
"select",
|
|
107
|
+
"check",
|
|
108
|
+
"uncheck",
|
|
109
|
+
"hover",
|
|
110
|
+
"drag",
|
|
111
|
+
"upload",
|
|
112
|
+
"scroll",
|
|
113
|
+
"back",
|
|
114
|
+
"forward",
|
|
115
|
+
"reload",
|
|
116
|
+
"open",
|
|
117
|
+
"goto",
|
|
118
|
+
"resize",
|
|
119
|
+
"tab_new",
|
|
120
|
+
"tab_select",
|
|
121
|
+
"tab_close",
|
|
122
|
+
]);
|
|
123
|
+
function isMutating(a: string): boolean {
|
|
124
|
+
return MUTATING.has(a);
|
|
125
|
+
}
|
|
66
126
|
|
|
67
127
|
// ---------------------------------------------------------------- act
|
|
68
|
-
|
|
69
128
|
export async function doAct(
|
|
70
129
|
page: Page,
|
|
71
130
|
config: Config,
|
|
@@ -75,6 +134,158 @@ export async function doAct(
|
|
|
75
134
|
opts?: ActOpts,
|
|
76
135
|
): Promise<Record<string, unknown>> {
|
|
77
136
|
const timeout = timeoutOf(config);
|
|
137
|
+
const sid = opts?.sessionId ?? "default";
|
|
138
|
+
|
|
139
|
+
// Non-page meta actions (no mustObserve guard, no evidence)
|
|
140
|
+
if (action === "note") {
|
|
141
|
+
if (!value && !target)
|
|
142
|
+
throw new EngineError(
|
|
143
|
+
"E_BAD_INPUT",
|
|
144
|
+
"Missing note text.",
|
|
145
|
+
"Pass value with the note.",
|
|
146
|
+
);
|
|
147
|
+
const note = value ?? target ?? "";
|
|
148
|
+
await appendNote(config, sid, note);
|
|
149
|
+
return { noted: true, note: note.slice(0, 300) };
|
|
150
|
+
}
|
|
151
|
+
if (action === "record_start") {
|
|
152
|
+
const name = (value ?? target ?? "recording").trim() || "recording";
|
|
153
|
+
startRecording(sid, name);
|
|
154
|
+
return { recording: true, name };
|
|
155
|
+
}
|
|
156
|
+
if (action === "record_stop") {
|
|
157
|
+
const rec = stopRecording(sid);
|
|
158
|
+
if (!rec)
|
|
159
|
+
throw new EngineError(
|
|
160
|
+
"E_BAD_INPUT",
|
|
161
|
+
"No active recording.",
|
|
162
|
+
"Call record_start first.",
|
|
163
|
+
);
|
|
164
|
+
const path = await saveRecording(config, rec.name, rec.steps as any);
|
|
165
|
+
return { recording: false, name: rec.name, steps: rec.steps.length, path };
|
|
166
|
+
}
|
|
167
|
+
if (action === "compile") {
|
|
168
|
+
const name = (value ?? target ?? "").trim();
|
|
169
|
+
if (!name)
|
|
170
|
+
throw new EngineError(
|
|
171
|
+
"E_BAD_INPUT",
|
|
172
|
+
"Missing macro name.",
|
|
173
|
+
"Pass value with the recording name.",
|
|
174
|
+
);
|
|
175
|
+
const rec = await loadRecording(config, name).catch(() => {
|
|
176
|
+
throw new EngineError(
|
|
177
|
+
"E_NOT_FOUND",
|
|
178
|
+
`No recording "${name}".`,
|
|
179
|
+
"Call record_start/stop first.",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
const { tsPath, mdPath, warnings } = await compileMacro(
|
|
183
|
+
config,
|
|
184
|
+
name,
|
|
185
|
+
rec.steps as any,
|
|
186
|
+
);
|
|
187
|
+
return {
|
|
188
|
+
compiled: true,
|
|
189
|
+
name,
|
|
190
|
+
steps: rec.steps.length,
|
|
191
|
+
tsPath,
|
|
192
|
+
mdPath,
|
|
193
|
+
warnings,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (action === "replay") {
|
|
197
|
+
const name = (value ?? target ?? "").trim();
|
|
198
|
+
if (!name)
|
|
199
|
+
throw new EngineError(
|
|
200
|
+
"E_BAD_INPUT",
|
|
201
|
+
"Missing macro name.",
|
|
202
|
+
"Pass value with the macro name.",
|
|
203
|
+
);
|
|
204
|
+
const rec = await loadRecording(config, name).catch(() => {
|
|
205
|
+
throw new EngineError(
|
|
206
|
+
"E_NOT_FOUND",
|
|
207
|
+
`No recording "${name}".`,
|
|
208
|
+
"Compile first or check name.",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
const approve = opts?.approve === true;
|
|
212
|
+
// Check approval gate
|
|
213
|
+
const needsApproval = (rec.steps as any[]).filter((s: any) =>
|
|
214
|
+
isIrreversible(s),
|
|
215
|
+
);
|
|
216
|
+
if (needsApproval.length > 0 && !approve) {
|
|
217
|
+
const first = needsApproval[0];
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
code: "E_APPROVAL_REQUIRED",
|
|
221
|
+
message: `Step ${first.seq} requires approval (${first.op}).`,
|
|
222
|
+
hint: "Re-run with approve:true.",
|
|
223
|
+
step: first.seq,
|
|
224
|
+
description: `${first.op} ${first.target ?? ""}`,
|
|
225
|
+
} as any;
|
|
226
|
+
}
|
|
227
|
+
// Deterministic replay
|
|
228
|
+
const results: any[] = [];
|
|
229
|
+
for (const s of rec.steps as any[]) {
|
|
230
|
+
try {
|
|
231
|
+
const r = await doAct(
|
|
232
|
+
page,
|
|
233
|
+
config,
|
|
234
|
+
s.op as ActAction,
|
|
235
|
+
s.target,
|
|
236
|
+
s.value === "«redacted»" ? undefined : s.value,
|
|
237
|
+
{ ...opts, expect: s.expect },
|
|
238
|
+
);
|
|
239
|
+
results.push({ ok: true, seq: s.seq, ...r });
|
|
240
|
+
} catch (e) {
|
|
241
|
+
const err = toEngineError(e, "Replay failed.");
|
|
242
|
+
results.push({
|
|
243
|
+
ok: false,
|
|
244
|
+
seq: s.seq,
|
|
245
|
+
code: err.code,
|
|
246
|
+
message: err.message,
|
|
247
|
+
});
|
|
248
|
+
return { replayed: false, name, failSeq: s.seq, results } as any;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { replayed: true, name, steps: results.length, results } as any;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Failure contract guard
|
|
255
|
+
if (isMutating(action) && getMustObserve(sid)) {
|
|
256
|
+
throw new EngineError(
|
|
257
|
+
"E_MUST_OBSERVE",
|
|
258
|
+
"Must observe before next mutation.",
|
|
259
|
+
"Call browser_observe kind=snapshot first.",
|
|
260
|
+
{ mustObserve: true } as any,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Validate expect syntax early
|
|
265
|
+
if (opts?.expect) {
|
|
266
|
+
const parsed = parseExpect(opts.expect);
|
|
267
|
+
if (!parsed)
|
|
268
|
+
throw new EngineError(
|
|
269
|
+
"E_BAD_EXPECT",
|
|
270
|
+
`Bad expect "${opts.expect}".`,
|
|
271
|
+
"Use url~<regex> | text~<regex> | visible=<css> | gone=<css>",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const urlBefore = page.url();
|
|
276
|
+
let domBefore: string | null = null;
|
|
277
|
+
try {
|
|
278
|
+
domBefore = await page.evaluate(
|
|
279
|
+
() => document.body?.innerText?.slice(0, 200) ?? "",
|
|
280
|
+
);
|
|
281
|
+
} catch {
|
|
282
|
+
domBefore = null;
|
|
283
|
+
}
|
|
284
|
+
const consoleBefore = getConsoleLogs(sid).length;
|
|
285
|
+
const networkBefore = getNetworkLogs(sid).length;
|
|
286
|
+
|
|
287
|
+
let result: Record<string, unknown>;
|
|
288
|
+
let isTimeout = false;
|
|
78
289
|
try {
|
|
79
290
|
switch (action) {
|
|
80
291
|
case "open":
|
|
@@ -87,7 +298,8 @@ export async function doAct(
|
|
|
87
298
|
"Pass the URL in value (or target).",
|
|
88
299
|
);
|
|
89
300
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
90
|
-
|
|
301
|
+
result = { ...(await liteState(page)) };
|
|
302
|
+
break;
|
|
91
303
|
}
|
|
92
304
|
case "click": {
|
|
93
305
|
if (!target)
|
|
@@ -96,11 +308,16 @@ export async function doAct(
|
|
|
96
308
|
"Missing target.",
|
|
97
309
|
"Pass an e-ref, role= selector, or CSS.",
|
|
98
310
|
);
|
|
99
|
-
const loc = await resolveLocator(page, target);
|
|
311
|
+
const loc = await resolveLocator(page, target, sid);
|
|
100
312
|
const button =
|
|
101
313
|
(value as "left" | "middle" | "right" | undefined) ?? "left";
|
|
102
314
|
await loc.click({ button, timeout });
|
|
103
|
-
|
|
315
|
+
result = { ...(await liteState(page)) };
|
|
316
|
+
if ((page as any).__rebound) {
|
|
317
|
+
(result as any).rebound = true;
|
|
318
|
+
delete (page as any).__rebound;
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
104
321
|
}
|
|
105
322
|
case "dblclick": {
|
|
106
323
|
if (!target)
|
|
@@ -109,9 +326,10 @@ export async function doAct(
|
|
|
109
326
|
"Missing target.",
|
|
110
327
|
"Pass an e-ref, role= selector, or CSS.",
|
|
111
328
|
);
|
|
112
|
-
const loc = await resolveLocator(page, target);
|
|
329
|
+
const loc = await resolveLocator(page, target, sid);
|
|
113
330
|
await loc.dblclick({ timeout });
|
|
114
|
-
|
|
331
|
+
result = { ...(await liteState(page)) };
|
|
332
|
+
break;
|
|
115
333
|
}
|
|
116
334
|
case "fill": {
|
|
117
335
|
if (!target)
|
|
@@ -120,13 +338,15 @@ export async function doAct(
|
|
|
120
338
|
"Missing target.",
|
|
121
339
|
"Pass an e-ref, role= selector, or CSS.",
|
|
122
340
|
);
|
|
123
|
-
const loc = await resolveLocator(page, target);
|
|
341
|
+
const loc = await resolveLocator(page, target, sid);
|
|
124
342
|
await loc.fill(value ?? "", { timeout });
|
|
125
|
-
|
|
343
|
+
result = { ...(await liteState(page)) };
|
|
344
|
+
break;
|
|
126
345
|
}
|
|
127
346
|
case "type": {
|
|
128
347
|
await page.keyboard.type(value ?? "", { delay: 0 });
|
|
129
|
-
|
|
348
|
+
result = { ...(await liteState(page)) };
|
|
349
|
+
break;
|
|
130
350
|
}
|
|
131
351
|
case "press": {
|
|
132
352
|
if (!value)
|
|
@@ -136,7 +356,8 @@ export async function doAct(
|
|
|
136
356
|
"Pass a key like Enter, Tab, Escape in value.",
|
|
137
357
|
);
|
|
138
358
|
await page.keyboard.press(value);
|
|
139
|
-
|
|
359
|
+
result = { ...(await liteState(page)) };
|
|
360
|
+
break;
|
|
140
361
|
}
|
|
141
362
|
case "select": {
|
|
142
363
|
if (!target)
|
|
@@ -145,9 +366,10 @@ export async function doAct(
|
|
|
145
366
|
"Missing target.",
|
|
146
367
|
"Pass a select element ref in target.",
|
|
147
368
|
);
|
|
148
|
-
const loc = await resolveLocator(page, target);
|
|
369
|
+
const loc = await resolveLocator(page, target, sid);
|
|
149
370
|
await loc.selectOption(value ?? "", { timeout });
|
|
150
|
-
|
|
371
|
+
result = { ...(await liteState(page)) };
|
|
372
|
+
break;
|
|
151
373
|
}
|
|
152
374
|
case "check": {
|
|
153
375
|
if (!target)
|
|
@@ -156,9 +378,10 @@ export async function doAct(
|
|
|
156
378
|
"Missing target.",
|
|
157
379
|
"Pass a checkbox ref in target.",
|
|
158
380
|
);
|
|
159
|
-
const loc = await resolveLocator(page, target);
|
|
381
|
+
const loc = await resolveLocator(page, target, sid);
|
|
160
382
|
await loc.check({ timeout });
|
|
161
|
-
|
|
383
|
+
result = { ...(await liteState(page)) };
|
|
384
|
+
break;
|
|
162
385
|
}
|
|
163
386
|
case "uncheck": {
|
|
164
387
|
if (!target)
|
|
@@ -167,9 +390,10 @@ export async function doAct(
|
|
|
167
390
|
"Missing target.",
|
|
168
391
|
"Pass a checkbox ref in target.",
|
|
169
392
|
);
|
|
170
|
-
const loc = await resolveLocator(page, target);
|
|
393
|
+
const loc = await resolveLocator(page, target, sid);
|
|
171
394
|
await loc.uncheck({ timeout });
|
|
172
|
-
|
|
395
|
+
result = { ...(await liteState(page)) };
|
|
396
|
+
break;
|
|
173
397
|
}
|
|
174
398
|
case "hover": {
|
|
175
399
|
if (!target)
|
|
@@ -178,9 +402,10 @@ export async function doAct(
|
|
|
178
402
|
"Missing target.",
|
|
179
403
|
"Pass an e-ref, role= selector, or CSS.",
|
|
180
404
|
);
|
|
181
|
-
const loc = await resolveLocator(page, target);
|
|
405
|
+
const loc = await resolveLocator(page, target, sid);
|
|
182
406
|
await loc.hover({ timeout });
|
|
183
|
-
|
|
407
|
+
result = { ...(await liteState(page)) };
|
|
408
|
+
break;
|
|
184
409
|
}
|
|
185
410
|
case "drag": {
|
|
186
411
|
if (!target || !value)
|
|
@@ -189,10 +414,11 @@ export async function doAct(
|
|
|
189
414
|
"Missing drag endpoints.",
|
|
190
415
|
"Pass start ref in target and end ref in value.",
|
|
191
416
|
);
|
|
192
|
-
const start = await resolveLocator(page, target);
|
|
193
|
-
const end = await resolveLocator(page, value);
|
|
417
|
+
const start = await resolveLocator(page, target, sid);
|
|
418
|
+
const end = await resolveLocator(page, value, sid);
|
|
194
419
|
await start.dragTo(end, { timeout });
|
|
195
|
-
|
|
420
|
+
result = { ...(await liteState(page)) };
|
|
421
|
+
break;
|
|
196
422
|
}
|
|
197
423
|
case "upload": {
|
|
198
424
|
const files = (value ?? "")
|
|
@@ -206,7 +432,8 @@ export async function doAct(
|
|
|
206
432
|
"Pass comma-separated file paths in value.",
|
|
207
433
|
);
|
|
208
434
|
await page.setInputFiles("input[type=file]", files, { timeout });
|
|
209
|
-
|
|
435
|
+
result = { ...(await liteState(page)), files };
|
|
436
|
+
break;
|
|
210
437
|
}
|
|
211
438
|
case "scroll": {
|
|
212
439
|
const dir = (target ?? "").toLowerCase();
|
|
@@ -219,52 +446,60 @@ export async function doAct(
|
|
|
219
446
|
window.scrollTo(0, document.body.scrollHeight),
|
|
220
447
|
);
|
|
221
448
|
else if (target) {
|
|
222
|
-
const loc = await resolveLocator(page, target);
|
|
449
|
+
const loc = await resolveLocator(page, target, sid);
|
|
223
450
|
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
224
|
-
} else
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return { ...(await liteState(page)) };
|
|
451
|
+
} else await page.mouse.wheel(0, 500);
|
|
452
|
+
result = { ...(await liteState(page)) };
|
|
453
|
+
break;
|
|
228
454
|
}
|
|
229
455
|
case "back":
|
|
230
456
|
await page
|
|
231
457
|
.goBack({ waitUntil: "domcontentloaded", timeout })
|
|
232
458
|
.catch(() => null);
|
|
233
|
-
|
|
459
|
+
result = { ...(await liteState(page)) };
|
|
460
|
+
break;
|
|
234
461
|
case "forward":
|
|
235
462
|
await page
|
|
236
463
|
.goForward({ waitUntil: "domcontentloaded", timeout })
|
|
237
464
|
.catch(() => null);
|
|
238
|
-
|
|
465
|
+
result = { ...(await liteState(page)) };
|
|
466
|
+
break;
|
|
239
467
|
case "reload":
|
|
240
468
|
await page.reload({ waitUntil: "domcontentloaded", timeout });
|
|
241
|
-
|
|
469
|
+
result = { ...(await liteState(page)) };
|
|
470
|
+
break;
|
|
242
471
|
case "wait": {
|
|
243
472
|
const t = target ?? "";
|
|
244
473
|
const msMatch = /^ms:(\d+)$/.exec(t);
|
|
245
474
|
if (msMatch) {
|
|
246
475
|
await page.waitForTimeout(Number(msMatch[1]));
|
|
247
|
-
|
|
476
|
+
result = { ...(await liteState(page)), waited: t };
|
|
477
|
+
break;
|
|
248
478
|
}
|
|
249
479
|
const textMatch = /^text:(.+)$/.exec(t);
|
|
250
480
|
if (textMatch) {
|
|
251
481
|
await page.getByText(textMatch[1]).first().waitFor({ timeout });
|
|
252
|
-
|
|
482
|
+
result = { ...(await liteState(page)), waited: t };
|
|
483
|
+
break;
|
|
253
484
|
}
|
|
254
485
|
if (t) {
|
|
255
|
-
const loc = await resolveLocator(page, t);
|
|
486
|
+
const loc = await resolveLocator(page, t, sid);
|
|
256
487
|
await loc.waitFor({ timeout });
|
|
257
|
-
|
|
488
|
+
result = { ...(await liteState(page)), waited: t };
|
|
489
|
+
break;
|
|
258
490
|
}
|
|
259
491
|
await page.waitForTimeout(500);
|
|
260
|
-
|
|
492
|
+
result = { ...(await liteState(page)) };
|
|
493
|
+
break;
|
|
261
494
|
}
|
|
262
495
|
case "dialog_accept":
|
|
263
496
|
page.once("dialog", (d) => void d.accept(value));
|
|
264
|
-
|
|
497
|
+
result = { armed: true };
|
|
498
|
+
break;
|
|
265
499
|
case "dialog_dismiss":
|
|
266
500
|
page.once("dialog", (d) => void d.dismiss());
|
|
267
|
-
|
|
501
|
+
result = { armed: true };
|
|
502
|
+
break;
|
|
268
503
|
case "resize": {
|
|
269
504
|
const m = /^(\d+)x(\d+)$/.exec(value ?? "");
|
|
270
505
|
if (!m)
|
|
@@ -277,7 +512,8 @@ export async function doAct(
|
|
|
277
512
|
width: Number(m[1]),
|
|
278
513
|
height: Number(m[2]),
|
|
279
514
|
});
|
|
280
|
-
|
|
515
|
+
result = { ...(await liteState(page)), viewport: value };
|
|
516
|
+
break;
|
|
281
517
|
}
|
|
282
518
|
case "tab_new": {
|
|
283
519
|
if (!opts?.context)
|
|
@@ -292,7 +528,11 @@ export async function doAct(
|
|
|
292
528
|
waitUntil: "domcontentloaded",
|
|
293
529
|
timeout,
|
|
294
530
|
});
|
|
295
|
-
|
|
531
|
+
result = {
|
|
532
|
+
url: p.url(),
|
|
533
|
+
tabs: opts.context.pages().map((x) => x.url()),
|
|
534
|
+
};
|
|
535
|
+
break;
|
|
296
536
|
}
|
|
297
537
|
case "tab_select": {
|
|
298
538
|
if (!opts?.context)
|
|
@@ -310,7 +550,8 @@ export async function doAct(
|
|
|
310
550
|
"Call browser_observe kind=tabs for the tab list.",
|
|
311
551
|
);
|
|
312
552
|
await pages[i].bringToFront();
|
|
313
|
-
|
|
553
|
+
result = { url: pages[i].url() };
|
|
554
|
+
break;
|
|
314
555
|
}
|
|
315
556
|
case "tab_close": {
|
|
316
557
|
if (!opts?.context)
|
|
@@ -329,11 +570,14 @@ export async function doAct(
|
|
|
329
570
|
"Call browser_observe kind=tabs for the tab list.",
|
|
330
571
|
);
|
|
331
572
|
await pages[i].close();
|
|
332
|
-
|
|
573
|
+
result = { closed: i, tabs: opts.context.pages().map((x) => x.url()) };
|
|
574
|
+
break;
|
|
333
575
|
}
|
|
334
576
|
case "close":
|
|
335
|
-
await closeSession(
|
|
336
|
-
|
|
577
|
+
await closeSession(sid);
|
|
578
|
+
clearRegistry(sid);
|
|
579
|
+
result = { closed: true };
|
|
580
|
+
break;
|
|
337
581
|
case "goal":
|
|
338
582
|
return doGoal(page, config, value ?? target ?? "", opts);
|
|
339
583
|
case "batch":
|
|
@@ -350,11 +594,85 @@ export async function doAct(
|
|
|
350
594
|
);
|
|
351
595
|
}
|
|
352
596
|
} catch (e) {
|
|
353
|
-
|
|
597
|
+
const err = toEngineError(
|
|
354
598
|
e,
|
|
355
599
|
"Retry with a fresh snapshot ref, or re-observe state.",
|
|
356
600
|
);
|
|
601
|
+
if (err.code === "E_TIMEOUT") {
|
|
602
|
+
isTimeout = true;
|
|
603
|
+
setMustObserve(sid, true);
|
|
604
|
+
}
|
|
605
|
+
throw err;
|
|
357
606
|
}
|
|
607
|
+
|
|
608
|
+
// Post-action: evidence, expect, state, dirty, recording
|
|
609
|
+
let effect: any = undefined;
|
|
610
|
+
if (isMutating(action)) {
|
|
611
|
+
effect = await collectEffect(
|
|
612
|
+
page,
|
|
613
|
+
sid,
|
|
614
|
+
urlBefore,
|
|
615
|
+
domBefore,
|
|
616
|
+
consoleBefore,
|
|
617
|
+
networkBefore,
|
|
618
|
+
config.effectMaxChars,
|
|
619
|
+
);
|
|
620
|
+
// Failure contract: timeout with no DOM/URL change => mustObserve
|
|
621
|
+
if (isTimeout && !effect.urlChanged && effect.domChanged.length === 0)
|
|
622
|
+
setMustObserve(sid, true);
|
|
623
|
+
// Expect evaluation
|
|
624
|
+
if (opts?.expect) {
|
|
625
|
+
const ev = await evaluateExpect(page, opts.expect);
|
|
626
|
+
if (!ev.ok) {
|
|
627
|
+
effect.mustObserve = false;
|
|
628
|
+
throw new EngineError(
|
|
629
|
+
"E_EXPECT",
|
|
630
|
+
ev.message ?? "Expect failed.",
|
|
631
|
+
"Check effect and re-plan.",
|
|
632
|
+
{ effect, expect: opts.expect } as any,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
(result as any).expect = "pass";
|
|
636
|
+
}
|
|
637
|
+
if (effect) {
|
|
638
|
+
(result as any).effect = effect;
|
|
639
|
+
if (effect.mustObserve) (result as any).mustObserve = true;
|
|
640
|
+
}
|
|
641
|
+
// Mark dirty for delta
|
|
642
|
+
ensureDeltaState(sid).dirty = true;
|
|
643
|
+
try {
|
|
644
|
+
await page.evaluate(() => {
|
|
645
|
+
(window as any).__effDirty = true;
|
|
646
|
+
});
|
|
647
|
+
} catch {}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Append to state ring
|
|
651
|
+
const actionLine = `${action} ${target ?? ""} ${value ?? ""}`
|
|
652
|
+
.trim()
|
|
653
|
+
.slice(0, 120);
|
|
654
|
+
await appendAction(
|
|
655
|
+
config,
|
|
656
|
+
sid,
|
|
657
|
+
`${actionLine} -> ${effect ? JSON.stringify(effect).slice(0, 80) : "ok"}`,
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// Recording capture
|
|
661
|
+
if (isRecording(sid) && isMutating(action)) {
|
|
662
|
+
captureStep(
|
|
663
|
+
sid,
|
|
664
|
+
{
|
|
665
|
+
op: action,
|
|
666
|
+
target,
|
|
667
|
+
value,
|
|
668
|
+
expect: opts?.expect,
|
|
669
|
+
resolvedSelector: target,
|
|
670
|
+
},
|
|
671
|
+
config,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return result;
|
|
358
676
|
}
|
|
359
677
|
|
|
360
678
|
// ---------------------------------------------------------------- goal
|
|
@@ -473,15 +791,109 @@ export async function doObserve(
|
|
|
473
791
|
case "title":
|
|
474
792
|
return { title: await page.title().catch(() => ""), url: page.url() };
|
|
475
793
|
case "snapshot": {
|
|
476
|
-
const
|
|
794
|
+
const mode = (opts?.mode ?? (config.deltaDefault ? "delta" : "full")) as
|
|
795
|
+
| "full"
|
|
796
|
+
| "delta";
|
|
797
|
+
const scope = opts?.scope;
|
|
798
|
+
// Scoped snapshot: only within selector
|
|
799
|
+
let yaml: string;
|
|
800
|
+
if (scope) {
|
|
801
|
+
yaml = await buildSnapshot(page, scope);
|
|
802
|
+
const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
|
|
803
|
+
const { text, truncated } = cap(yaml, config.outputMaxChars);
|
|
804
|
+
clearMustObserve(sessionId);
|
|
805
|
+
return {
|
|
806
|
+
path,
|
|
807
|
+
summary: text,
|
|
808
|
+
truncated,
|
|
809
|
+
url: page.url(),
|
|
810
|
+
title: await page.title().catch(() => ""),
|
|
811
|
+
scope,
|
|
812
|
+
mode: "scoped",
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
// Delta path
|
|
816
|
+
if (mode === "delta") {
|
|
817
|
+
const baseline = getBaseline(sessionId);
|
|
818
|
+
// If no baseline, do full
|
|
819
|
+
if (!baseline) {
|
|
820
|
+
yaml = await buildSnapshot(page);
|
|
821
|
+
setBaseline(sessionId, yaml, page.url());
|
|
822
|
+
await injectDirtyObserver(page, sessionId);
|
|
823
|
+
// register fingerprints
|
|
824
|
+
const fps = await collectFingerprints(page);
|
|
825
|
+
registerFingerprints(sessionId, fps);
|
|
826
|
+
const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
|
|
827
|
+
const { text, truncated } = cap(yaml, config.outputMaxChars);
|
|
828
|
+
clearMustObserve(sessionId);
|
|
829
|
+
return {
|
|
830
|
+
path,
|
|
831
|
+
summary: text,
|
|
832
|
+
truncated,
|
|
833
|
+
url: page.url(),
|
|
834
|
+
title: await page.title().catch(() => ""),
|
|
835
|
+
mode: "full",
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// Check dirty flag
|
|
839
|
+
const dirty = await checkDirty(page).catch(() => true);
|
|
840
|
+
if (!dirty) {
|
|
841
|
+
clearMustObserve(sessionId);
|
|
842
|
+
return {
|
|
843
|
+
unchanged: true,
|
|
844
|
+
url: page.url(),
|
|
845
|
+
title: await page.title().catch(() => ""),
|
|
846
|
+
hint: "last snapshot still valid",
|
|
847
|
+
mode: "delta",
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
yaml = await buildSnapshot(page);
|
|
851
|
+
const { delta, unchanged } = computeDelta(baseline, yaml);
|
|
852
|
+
if (unchanged) {
|
|
853
|
+
await clearDirtyFlag(page);
|
|
854
|
+
clearMustObserve(sessionId);
|
|
855
|
+
return {
|
|
856
|
+
unchanged: true,
|
|
857
|
+
url: page.url(),
|
|
858
|
+
title: await page.title().catch(() => ""),
|
|
859
|
+
hint: "no changes",
|
|
860
|
+
mode: "delta",
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
setBaseline(sessionId, yaml, page.url());
|
|
864
|
+
await clearDirtyFlag(page);
|
|
865
|
+
await injectDirtyObserver(page, sessionId);
|
|
866
|
+
const fps = await collectFingerprints(page);
|
|
867
|
+
registerFingerprints(sessionId, fps);
|
|
868
|
+
const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
|
|
869
|
+
const { text: dtext, truncated } = cap(delta, config.outputMaxChars);
|
|
870
|
+
clearMustObserve(sessionId);
|
|
871
|
+
return {
|
|
872
|
+
path,
|
|
873
|
+
delta: dtext,
|
|
874
|
+
truncated,
|
|
875
|
+
url: page.url(),
|
|
876
|
+
title: await page.title().catch(() => ""),
|
|
877
|
+
mode: "delta",
|
|
878
|
+
fullPath: path,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
// Full mode
|
|
882
|
+
yaml = await buildSnapshot(page);
|
|
883
|
+
setBaseline(sessionId, yaml, page.url());
|
|
884
|
+
await injectDirtyObserver(page, sessionId);
|
|
885
|
+
const fps2 = await collectFingerprints(page);
|
|
886
|
+
registerFingerprints(sessionId, fps2);
|
|
477
887
|
const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
|
|
478
888
|
const { text, truncated } = cap(yaml, config.outputMaxChars);
|
|
889
|
+
clearMustObserve(sessionId);
|
|
479
890
|
return {
|
|
480
891
|
path,
|
|
481
892
|
summary: text,
|
|
482
893
|
truncated,
|
|
483
894
|
url: page.url(),
|
|
484
895
|
title: await page.title().catch(() => ""),
|
|
896
|
+
mode: "full",
|
|
485
897
|
};
|
|
486
898
|
}
|
|
487
899
|
case "screenshot": {
|
|
@@ -524,17 +936,28 @@ export async function doObserve(
|
|
|
524
936
|
}
|
|
525
937
|
}
|
|
526
938
|
|
|
527
|
-
async function buildSnapshot(page: Page): Promise<string> {
|
|
939
|
+
async function buildSnapshot(page: Page, scope?: string): Promise<string> {
|
|
528
940
|
const header = [
|
|
529
941
|
"# Snapshot — refs are nth-match: eN = Nth match of (button, a, input, select, textarea, [role=button], [tabindex]) in DOM order.",
|
|
530
942
|
`# url: ${page.url()}`,
|
|
531
943
|
].join("\n");
|
|
532
|
-
const items = await page.evaluate(() => {
|
|
944
|
+
const items = await page.evaluate((scopeSel) => {
|
|
945
|
+
const root = scopeSel ? document.querySelector(scopeSel) : document;
|
|
946
|
+
if (!root) return [] as string[];
|
|
533
947
|
const els = [
|
|
534
|
-
...
|
|
948
|
+
...(root as Element).querySelectorAll(
|
|
535
949
|
"button, a, input, select, textarea, [role=button], [tabindex]",
|
|
536
950
|
),
|
|
537
951
|
];
|
|
952
|
+
// also include root itself if it matches
|
|
953
|
+
if (
|
|
954
|
+
scopeSel &&
|
|
955
|
+
(root as Element).matches?.(
|
|
956
|
+
"button, a, input, select, textarea, [role=button], [tabindex]",
|
|
957
|
+
)
|
|
958
|
+
) {
|
|
959
|
+
els.unshift(root as Element);
|
|
960
|
+
}
|
|
538
961
|
return els.slice(0, 200).map((el) => {
|
|
539
962
|
const tag = el.tagName.toLowerCase();
|
|
540
963
|
const text = (el.textContent ?? "")
|
|
@@ -553,11 +976,62 @@ async function buildSnapshot(page: Page): Promise<string> {
|
|
|
553
976
|
.join(" ");
|
|
554
977
|
return `${tag} "${text}"${extra ? " " + extra : ""}`;
|
|
555
978
|
});
|
|
556
|
-
});
|
|
979
|
+
}, scope ?? null);
|
|
557
980
|
const lines = items.map((line, i) => `[e${i}] ${line}`);
|
|
558
981
|
return `${header}\n${lines.join("\n") || "(no interactive elements)"}`;
|
|
559
982
|
}
|
|
560
983
|
|
|
984
|
+
async function collectFingerprints(
|
|
985
|
+
page: Page,
|
|
986
|
+
): Promise<Array<{ ref: string; fp: import("./identity.js").Fingerprint }>> {
|
|
987
|
+
const raw = await page
|
|
988
|
+
.evaluate(() => {
|
|
989
|
+
const els = [
|
|
990
|
+
...document.querySelectorAll(
|
|
991
|
+
"button, a, input, select, textarea, [role=button], [tabindex]",
|
|
992
|
+
),
|
|
993
|
+
].slice(0, 200);
|
|
994
|
+
return els.map((el) => {
|
|
995
|
+
const text = (el.textContent ?? "")
|
|
996
|
+
.trim()
|
|
997
|
+
.replace(/\s+/g, " ")
|
|
998
|
+
.slice(0, 80);
|
|
999
|
+
const aria = el.getAttribute("aria-label") ?? "";
|
|
1000
|
+
const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
|
|
1001
|
+
let cur: Element | null = el;
|
|
1002
|
+
const parts: string[] = [];
|
|
1003
|
+
while (cur && parts.length < 6) {
|
|
1004
|
+
parts.push(cur.tagName.toLowerCase());
|
|
1005
|
+
cur = cur.parentElement;
|
|
1006
|
+
}
|
|
1007
|
+
const rect = el.getBoundingClientRect();
|
|
1008
|
+
return {
|
|
1009
|
+
role,
|
|
1010
|
+
accessibleName: aria,
|
|
1011
|
+
text,
|
|
1012
|
+
pathHash: parts.join(">"),
|
|
1013
|
+
box: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
|
1014
|
+
};
|
|
1015
|
+
});
|
|
1016
|
+
})
|
|
1017
|
+
.catch(() => [] as any[]);
|
|
1018
|
+
return raw.map((r: any, i: number) => ({
|
|
1019
|
+
ref: `e${i}`,
|
|
1020
|
+
fp: {
|
|
1021
|
+
role: r.role || "generic",
|
|
1022
|
+
accessibleName: (r.accessibleName || "").trim().slice(0, 80),
|
|
1023
|
+
textHash: (r.text || "").trim().replace(/\s+/g, " ").slice(0, 80),
|
|
1024
|
+
box: {
|
|
1025
|
+
x: Math.round(r.box.x),
|
|
1026
|
+
y: Math.round(r.box.y),
|
|
1027
|
+
w: Math.round(r.box.w),
|
|
1028
|
+
h: Math.round(r.box.h),
|
|
1029
|
+
},
|
|
1030
|
+
pathHash: r.pathHash || "",
|
|
1031
|
+
},
|
|
1032
|
+
}));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
561
1035
|
// ---------------------------------------------------------------- extract
|
|
562
1036
|
|
|
563
1037
|
export async function doExtract(
|
|
@@ -657,11 +1131,23 @@ export async function doExtract(
|
|
|
657
1131
|
await ctx.tracing.stop({ path: saved });
|
|
658
1132
|
return { path: saved };
|
|
659
1133
|
}
|
|
1134
|
+
case "state": {
|
|
1135
|
+
const sid = opts?.sessionId ?? "default";
|
|
1136
|
+
const st = await readState(config, sid);
|
|
1137
|
+
const dirty = await checkDirty(page).catch(() => false);
|
|
1138
|
+
return {
|
|
1139
|
+
notes: st.notes,
|
|
1140
|
+
lastActions: st.lastActions,
|
|
1141
|
+
path: st.path,
|
|
1142
|
+
url: page.url(),
|
|
1143
|
+
dirty,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
660
1146
|
default:
|
|
661
1147
|
throw new EngineError(
|
|
662
1148
|
"E_BAD_INPUT",
|
|
663
1149
|
`Unknown extract kind "${kind}".`,
|
|
664
|
-
"Valid: text, html, table, query, pdf, trace_start, trace_stop.",
|
|
1150
|
+
"Valid: text, html, table, query, pdf, trace_start, trace_stop, state.",
|
|
665
1151
|
);
|
|
666
1152
|
}
|
|
667
1153
|
}
|