@yibie/pi-jev-browser 0.1.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 +201 -0
- package/README.md +137 -0
- package/extensions/jev-browser.ts +466 -0
- package/package.json +56 -0
- package/pi-jev-browser.config.example.json +23 -0
- package/src/actions.ts +205 -0
- package/src/browser-setup.ts +45 -0
- package/src/config.ts +118 -0
- package/src/credentials.ts +26 -0
- package/src/jev-browser.ts +456 -0
- package/src/jev-model.ts +107 -0
- package/src/jev-run.ts +334 -0
- package/src/pi-model.ts +167 -0
- package/src/recording-overlay.ts +82 -0
- package/src/runtime.ts +588 -0
- package/src/stream.ts +132 -0
- package/src/types.ts +79 -0
- package/src/typesafe.ts +137 -0
- package/test/browser-setup.test.ts +32 -0
- package/test/credentials.test.ts +53 -0
- package/test/extension.test.ts +180 -0
- package/test/jev.test.ts +559 -0
- package/test/navigation-observation.test.ts +94 -0
- package/test/pi-model.test.ts +148 -0
- package/test/runtime.test.ts +121 -0
- package/test/smoke-config.json +17 -0
- package/test/typesafe.test.ts +129 -0
package/test/jev.test.ts
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { type Observation, observe } from "../src/jev-browser.ts";
|
|
5
|
+
import { buildQuestions, type JevPolicy, parseText } from "../src/jev-model.ts";
|
|
6
|
+
import { type RunStep, runJev, throttleCategory } from "../src/jev-run.ts";
|
|
7
|
+
|
|
8
|
+
const observation: Observation = {
|
|
9
|
+
url: "https://example.test",
|
|
10
|
+
title: "Search",
|
|
11
|
+
text: "Search",
|
|
12
|
+
scrollUp: false,
|
|
13
|
+
scrollDown: true,
|
|
14
|
+
targets: [
|
|
15
|
+
{ id: "1", operation: "TYPE_TEXT", label: "Query", value: "" },
|
|
16
|
+
{ id: "2", operation: "CLICK", label: "Search", value: "" },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("one question compares concrete actions against scrolling and terminal choices", () => {
|
|
21
|
+
const q = buildQuestions(observation, "Find cats");
|
|
22
|
+
assert.equal(q.action.type, "choice");
|
|
23
|
+
assert.deepEqual(Object.keys(q.action.criteria), [
|
|
24
|
+
"WAIT",
|
|
25
|
+
"BLOCKED",
|
|
26
|
+
"REVIEW",
|
|
27
|
+
"DONE",
|
|
28
|
+
"TYPE_TEXT:1",
|
|
29
|
+
"CLICK:2",
|
|
30
|
+
"SCROLL_DOWN",
|
|
31
|
+
]);
|
|
32
|
+
const checked = buildQuestions(
|
|
33
|
+
{
|
|
34
|
+
...observation,
|
|
35
|
+
targets: [
|
|
36
|
+
{
|
|
37
|
+
id: "3",
|
|
38
|
+
operation: "CLICK",
|
|
39
|
+
label: "Small",
|
|
40
|
+
value: "small",
|
|
41
|
+
role: "radio",
|
|
42
|
+
checked: "true",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
"Choose small",
|
|
47
|
+
);
|
|
48
|
+
assert.ok(!Object.hasOwn(checked.action.criteria, "CLICK:3"));
|
|
49
|
+
assert.ok(
|
|
50
|
+
!Object.hasOwn(
|
|
51
|
+
buildQuestions({ ...observation, text: "" }, "Finish").action.criteria,
|
|
52
|
+
"DONE",
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("text helper rejects missing, invented-shape, empty, or excessive outputs", () => {
|
|
58
|
+
assert.equal(parseText('{"text":"cats"}'), "cats");
|
|
59
|
+
for (const value of [
|
|
60
|
+
"{}",
|
|
61
|
+
'{"text":null}',
|
|
62
|
+
'{"text":""}',
|
|
63
|
+
'{"text":"cat","action":"click"}',
|
|
64
|
+
"not json",
|
|
65
|
+
JSON.stringify({ text: "x".repeat(2001) }),
|
|
66
|
+
]) {
|
|
67
|
+
assert.throws(() => parseText(value));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("browser loop and stale-target guards (offline)", async (t) => {
|
|
72
|
+
const browser = await chromium.launch({
|
|
73
|
+
headless: true,
|
|
74
|
+
...(process.env.JEV_TEST_BROWSER
|
|
75
|
+
? { executablePath: process.env.JEV_TEST_BROWSER }
|
|
76
|
+
: {}),
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const page = await browser.newPage();
|
|
80
|
+
const fixture = async () =>
|
|
81
|
+
page.setContent(
|
|
82
|
+
`<label>Query <input id="query"></label><button onclick="document.querySelector('#result').textContent = document.querySelector('#query').value">Search</button><p id="result"></p><input type="password" value="secret"><input disabled value="disabled">`,
|
|
83
|
+
);
|
|
84
|
+
await t.test(
|
|
85
|
+
"fills and clicks, records actions, returns DONE as unverified",
|
|
86
|
+
async () => {
|
|
87
|
+
await fixture();
|
|
88
|
+
let calls = 0;
|
|
89
|
+
let textCalls = 0;
|
|
90
|
+
const recorded: RunStep[] = [];
|
|
91
|
+
const policy: JevPolicy = {
|
|
92
|
+
async choose(data) {
|
|
93
|
+
assert.equal(
|
|
94
|
+
data.targets.some(
|
|
95
|
+
(e) => e.value === "secret" || e.value === "disabled",
|
|
96
|
+
),
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
calls++;
|
|
100
|
+
const operation =
|
|
101
|
+
calls === 1 ? "TYPE_TEXT" : calls === 2 ? "CLICK" : "DONE";
|
|
102
|
+
return {
|
|
103
|
+
operation,
|
|
104
|
+
probability: 0.99,
|
|
105
|
+
target: data.targets.find(
|
|
106
|
+
(e) =>
|
|
107
|
+
e.operation === operation &&
|
|
108
|
+
(operation !== "CLICK" || e.label === "Search"),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
async text() {
|
|
113
|
+
textCalls++;
|
|
114
|
+
return "cats";
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const result = await runJev(
|
|
118
|
+
{ goal: "Search for cats" },
|
|
119
|
+
{
|
|
120
|
+
page: () => page,
|
|
121
|
+
policy,
|
|
122
|
+
onStep: async (step) => {
|
|
123
|
+
recorded.push(step);
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
assert.equal(result.status, "done_unverified");
|
|
128
|
+
assert.equal(await page.locator("#result").textContent(), "cats");
|
|
129
|
+
assert.equal(textCalls, 1);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
recorded.map((s) => s.status),
|
|
132
|
+
[
|
|
133
|
+
"decision",
|
|
134
|
+
"attempted",
|
|
135
|
+
"executed",
|
|
136
|
+
"decision",
|
|
137
|
+
"attempted",
|
|
138
|
+
"executed",
|
|
139
|
+
"decision",
|
|
140
|
+
],
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
await t.test(
|
|
145
|
+
"stale text decision re-evaluates without executing the stale mutation",
|
|
146
|
+
async () => {
|
|
147
|
+
await fixture();
|
|
148
|
+
let calls = 0;
|
|
149
|
+
const result = await runJev(
|
|
150
|
+
{ goal: "Search" },
|
|
151
|
+
{
|
|
152
|
+
page: () => page,
|
|
153
|
+
policy: {
|
|
154
|
+
async choose(data) {
|
|
155
|
+
calls++;
|
|
156
|
+
if (calls === 2) return { operation: "BLOCKED" };
|
|
157
|
+
return {
|
|
158
|
+
operation: "TYPE_TEXT",
|
|
159
|
+
target: data.targets.find((e) => e.operation === "TYPE_TEXT"),
|
|
160
|
+
probability: 1,
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
async text() {
|
|
164
|
+
await page.locator("#query").fill("changed by user");
|
|
165
|
+
return "cats";
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
assert.equal(result.status, "blocked");
|
|
171
|
+
assert.equal(calls, 2);
|
|
172
|
+
assert.equal(
|
|
173
|
+
await page.locator("#query").inputValue(),
|
|
174
|
+
"changed by user",
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
await t.test(
|
|
179
|
+
"ARIA flight controls are offered with shared element IDs",
|
|
180
|
+
async () => {
|
|
181
|
+
await page.setContent(
|
|
182
|
+
'<div role="combobox" aria-label="Trip type">Round trip</div><div role="menuitemradio" aria-checked="false">One way</div><div role="gridcell">September 20</div><input aria-label="Origin"><select aria-label="Cabin"><option>Economy</option><option>Business</option></select>',
|
|
183
|
+
);
|
|
184
|
+
const snapshot = await observe(page);
|
|
185
|
+
try {
|
|
186
|
+
for (const role of ["combobox", "menuitemradio", "gridcell"])
|
|
187
|
+
assert.ok(snapshot.data.targets.some((t) => t.role === role));
|
|
188
|
+
assert.equal(
|
|
189
|
+
snapshot.data.targets.find((t) => t.role === "combobox")?.value,
|
|
190
|
+
"Round trip",
|
|
191
|
+
);
|
|
192
|
+
const origin = snapshot.data.targets.filter(
|
|
193
|
+
(t) => t.label === "Origin",
|
|
194
|
+
);
|
|
195
|
+
assert.equal(origin.length, 2);
|
|
196
|
+
assert.equal(origin[0].id, origin[1].id);
|
|
197
|
+
const option = snapshot.data.targets.find(
|
|
198
|
+
(t) => t.operation === "SELECT",
|
|
199
|
+
);
|
|
200
|
+
assert.ok(option);
|
|
201
|
+
await snapshot.execute(
|
|
202
|
+
"SELECT",
|
|
203
|
+
option,
|
|
204
|
+
undefined,
|
|
205
|
+
new AbortController().signal,
|
|
206
|
+
);
|
|
207
|
+
assert.equal(await page.locator("select").inputValue(), "Business");
|
|
208
|
+
} finally {
|
|
209
|
+
await snapshot.dispose();
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
await t.test(
|
|
214
|
+
"low probability proceeds by default and history includes typed text and progress",
|
|
215
|
+
async () => {
|
|
216
|
+
await fixture();
|
|
217
|
+
let calls = 0;
|
|
218
|
+
const result = await runJev(
|
|
219
|
+
{ goal: "Search cats" },
|
|
220
|
+
{
|
|
221
|
+
page: () => page,
|
|
222
|
+
policy: {
|
|
223
|
+
async choose(data, _goal, history) {
|
|
224
|
+
if (calls++ === 0)
|
|
225
|
+
return {
|
|
226
|
+
operation: "TYPE_TEXT",
|
|
227
|
+
probability: 0.1,
|
|
228
|
+
target: data.targets.find(
|
|
229
|
+
(t) => t.operation === "TYPE_TEXT",
|
|
230
|
+
),
|
|
231
|
+
};
|
|
232
|
+
assert.deepEqual(history, [
|
|
233
|
+
{
|
|
234
|
+
action: "Query",
|
|
235
|
+
kind: "TYPE_TEXT",
|
|
236
|
+
text: "cats",
|
|
237
|
+
page_changed: true,
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
return { operation: "DONE" };
|
|
241
|
+
},
|
|
242
|
+
async text() {
|
|
243
|
+
return "cats";
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
assert.equal(result.status, "done_unverified");
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
await t.test("stale terminal decisions are re-evaluated", async () => {
|
|
252
|
+
await fixture();
|
|
253
|
+
let calls = 0;
|
|
254
|
+
const result = await runJev(
|
|
255
|
+
{ goal: "Search" },
|
|
256
|
+
{
|
|
257
|
+
page: () => page,
|
|
258
|
+
policy: {
|
|
259
|
+
async choose() {
|
|
260
|
+
if (calls++ === 0) {
|
|
261
|
+
await page.locator("#query").fill("new");
|
|
262
|
+
return { operation: "DONE" };
|
|
263
|
+
}
|
|
264
|
+
return { operation: "BLOCKED" };
|
|
265
|
+
},
|
|
266
|
+
async text() {
|
|
267
|
+
throw new Error("Unexpected helper");
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
assert.equal(result.status, "blocked");
|
|
273
|
+
assert.equal(calls, 2);
|
|
274
|
+
});
|
|
275
|
+
await t.test(
|
|
276
|
+
"replacement node with identical markup is rejected",
|
|
277
|
+
async () => {
|
|
278
|
+
await fixture();
|
|
279
|
+
const snapshot = await observe(page);
|
|
280
|
+
try {
|
|
281
|
+
const target = snapshot.data.targets.find(
|
|
282
|
+
(e) => e.label === "Search",
|
|
283
|
+
);
|
|
284
|
+
assert.ok(target);
|
|
285
|
+
await page
|
|
286
|
+
.locator("button")
|
|
287
|
+
.evaluate((e) => e.replaceWith(e.cloneNode(true)));
|
|
288
|
+
await assert.rejects(
|
|
289
|
+
snapshot.execute(
|
|
290
|
+
"CLICK",
|
|
291
|
+
target,
|
|
292
|
+
undefined,
|
|
293
|
+
new AbortController().signal,
|
|
294
|
+
),
|
|
295
|
+
/disappeared/,
|
|
296
|
+
);
|
|
297
|
+
} finally {
|
|
298
|
+
await snapshot.dispose();
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
await t.test(
|
|
303
|
+
"stable targets survive unrelated changes but reject changed destinations",
|
|
304
|
+
async () => {
|
|
305
|
+
await page.setContent('<a href="#one">Buy</a><p id="ticker">1</p>');
|
|
306
|
+
const snapshot = await observe(page);
|
|
307
|
+
try {
|
|
308
|
+
await page.locator("#ticker").evaluate((e) => (e.textContent = "2"));
|
|
309
|
+
await snapshot.execute(
|
|
310
|
+
"CLICK",
|
|
311
|
+
snapshot.data.targets[0],
|
|
312
|
+
undefined,
|
|
313
|
+
new AbortController().signal,
|
|
314
|
+
);
|
|
315
|
+
assert.ok(page.url().endsWith("#one"));
|
|
316
|
+
} finally {
|
|
317
|
+
await snapshot.dispose();
|
|
318
|
+
}
|
|
319
|
+
const changed = await observe(page);
|
|
320
|
+
try {
|
|
321
|
+
await page
|
|
322
|
+
.locator("a")
|
|
323
|
+
.evaluate((e) => e.setAttribute("href", "#different"));
|
|
324
|
+
await assert.rejects(
|
|
325
|
+
changed.execute(
|
|
326
|
+
"CLICK",
|
|
327
|
+
changed.data.targets[0],
|
|
328
|
+
undefined,
|
|
329
|
+
new AbortController().signal,
|
|
330
|
+
),
|
|
331
|
+
/changed/,
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
await changed.dispose();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
await t.test(
|
|
339
|
+
"visible labels expose hidden radio options and their selected state",
|
|
340
|
+
async () => {
|
|
341
|
+
await page.setContent(
|
|
342
|
+
'<input style="display:none" id="size" type="radio" name="size" value="small"><label for="size">Small $10</label><input style="display:none" id="disabled" type="radio" disabled><label for="disabled">Unavailable</label>',
|
|
343
|
+
);
|
|
344
|
+
const snapshot = await observe(page);
|
|
345
|
+
try {
|
|
346
|
+
const target = snapshot.data.targets.find(
|
|
347
|
+
(t) => t.label === "Small $10",
|
|
348
|
+
);
|
|
349
|
+
assert.ok(target);
|
|
350
|
+
assert.equal(target.role, "radio");
|
|
351
|
+
assert.equal(target.checked, "false");
|
|
352
|
+
assert.ok(
|
|
353
|
+
!snapshot.data.targets.some((t) => t.label === "Unavailable"),
|
|
354
|
+
);
|
|
355
|
+
await snapshot.execute(
|
|
356
|
+
"CLICK",
|
|
357
|
+
target,
|
|
358
|
+
undefined,
|
|
359
|
+
new AbortController().signal,
|
|
360
|
+
);
|
|
361
|
+
assert.equal(await page.locator("#size").isChecked(), true);
|
|
362
|
+
} finally {
|
|
363
|
+
await snapshot.dispose();
|
|
364
|
+
}
|
|
365
|
+
const updated = await observe(page);
|
|
366
|
+
try {
|
|
367
|
+
assert.equal(
|
|
368
|
+
updated.data.targets.find((t) => t.label === "Small $10")?.checked,
|
|
369
|
+
"true",
|
|
370
|
+
);
|
|
371
|
+
} finally {
|
|
372
|
+
await updated.dispose();
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
await t.test(
|
|
377
|
+
"labels covered by their own input click the associated input",
|
|
378
|
+
async () => {
|
|
379
|
+
await page.setContent(
|
|
380
|
+
'<div style="position:relative;width:180px;height:60px"><input id="option" type="radio" style="position:absolute;inset:0;width:100%;height:100%;opacity:0.01;z-index:2"><label for="option" style="display:block;width:100%;height:100%">No extras</label></div>',
|
|
381
|
+
);
|
|
382
|
+
const snapshot = await observe(page);
|
|
383
|
+
try {
|
|
384
|
+
const target = snapshot.data.targets.find(
|
|
385
|
+
(t) => t.label === "No extras" && t.role === "radio",
|
|
386
|
+
);
|
|
387
|
+
assert.ok(target);
|
|
388
|
+
await snapshot.execute(
|
|
389
|
+
"CLICK",
|
|
390
|
+
target,
|
|
391
|
+
undefined,
|
|
392
|
+
new AbortController().signal,
|
|
393
|
+
);
|
|
394
|
+
assert.ok(await page.locator("#option").isChecked());
|
|
395
|
+
} finally {
|
|
396
|
+
await snapshot.dispose();
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
await t.test(
|
|
401
|
+
"offscreen choices guide scrolling while preserving selected options",
|
|
402
|
+
async () => {
|
|
403
|
+
await page.setContent(
|
|
404
|
+
'<input id="chosen" type="radio" name="size" value="small" checked><label for="chosen">Small</label><div style="height:2000px"></div><input id="later" type="radio" name="carrier" value="later"><label for="later">Connect later</label><button disabled>Unavailable</button>',
|
|
405
|
+
);
|
|
406
|
+
const snapshot = await observe(page);
|
|
407
|
+
try {
|
|
408
|
+
assert.ok(
|
|
409
|
+
snapshot.data.offscreenControls?.below.includes("Connect later"),
|
|
410
|
+
);
|
|
411
|
+
assert.ok(
|
|
412
|
+
!snapshot.data.targets.some((t) => t.label === "Connect later"),
|
|
413
|
+
);
|
|
414
|
+
assert.ok(
|
|
415
|
+
!snapshot.data.offscreenControls?.below.includes("Unavailable"),
|
|
416
|
+
);
|
|
417
|
+
assert.deepEqual(snapshot.data.selectedOptions, [
|
|
418
|
+
{ group: "size", label: "Small", value: "small" },
|
|
419
|
+
]);
|
|
420
|
+
} finally {
|
|
421
|
+
await snapshot.dispose();
|
|
422
|
+
}
|
|
423
|
+
await page.locator("#later").scrollIntoViewIfNeeded();
|
|
424
|
+
const scrolled = await observe(page);
|
|
425
|
+
try {
|
|
426
|
+
assert.equal(scrolled.data.selectedOptions?.[0].value, "small");
|
|
427
|
+
assert.ok(
|
|
428
|
+
scrolled.data.targets.some((t) => t.label === "Connect later"),
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
await scrolled.dispose();
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
await t.test("covered targets are rejected", async () => {
|
|
436
|
+
await fixture();
|
|
437
|
+
const snapshot = await observe(page);
|
|
438
|
+
try {
|
|
439
|
+
await page.evaluate(() => {
|
|
440
|
+
const cover = document.createElement("div");
|
|
441
|
+
cover.style.cssText = "position:fixed;inset:0;z-index:999";
|
|
442
|
+
document.body.append(cover);
|
|
443
|
+
});
|
|
444
|
+
await assert.rejects(
|
|
445
|
+
snapshot.execute(
|
|
446
|
+
"CLICK",
|
|
447
|
+
snapshot.data.targets.find((e) => e.label === "Search"),
|
|
448
|
+
undefined,
|
|
449
|
+
new AbortController().signal,
|
|
450
|
+
),
|
|
451
|
+
/covered/,
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
await snapshot.dispose();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
await t.test(
|
|
458
|
+
"review, uncertainty, limits, and cancellation stop the loop",
|
|
459
|
+
async () => {
|
|
460
|
+
for (const [operation, probability, expected] of [
|
|
461
|
+
["REVIEW", 1, "needs_review"],
|
|
462
|
+
["CLICK", 0.1, "uncertain"],
|
|
463
|
+
["CLICK", undefined, "uncertain"],
|
|
464
|
+
["WAIT", 1, "step_limit"],
|
|
465
|
+
] as const) {
|
|
466
|
+
await fixture();
|
|
467
|
+
const result = await runJev(
|
|
468
|
+
{ goal: "Search", maxSteps: 1, minProbability: 0.6 },
|
|
469
|
+
{
|
|
470
|
+
page: () => page,
|
|
471
|
+
policy: {
|
|
472
|
+
async choose() {
|
|
473
|
+
return { operation, probability };
|
|
474
|
+
},
|
|
475
|
+
async text() {
|
|
476
|
+
throw new Error("Unexpected helper");
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
assert.equal(result.status, expected);
|
|
482
|
+
}
|
|
483
|
+
const controller = new AbortController();
|
|
484
|
+
const result = await runJev(
|
|
485
|
+
{ goal: "Search" },
|
|
486
|
+
{
|
|
487
|
+
page: () => page,
|
|
488
|
+
signal: controller.signal,
|
|
489
|
+
policy: {
|
|
490
|
+
async choose(data) {
|
|
491
|
+
controller.abort();
|
|
492
|
+
return {
|
|
493
|
+
operation: "CLICK",
|
|
494
|
+
probability: 1,
|
|
495
|
+
target: data.targets.find((e) => e.label === "Search"),
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
async text() {
|
|
499
|
+
throw new Error("Unexpected helper");
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
assert.equal(result.status, "interrupted");
|
|
505
|
+
assert.equal(result.steps.length, 0);
|
|
506
|
+
},
|
|
507
|
+
);
|
|
508
|
+
} finally {
|
|
509
|
+
await browser.close();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("throttling gets its own category instead of a mystery failure", async () => {
|
|
514
|
+
assert.equal(throttleCategory({ statusCode: 429 }), "rate_limited");
|
|
515
|
+
assert.equal(throttleCategory({ statusCode: 529 }), "overloaded");
|
|
516
|
+
assert.equal(
|
|
517
|
+
throttleCategory(new Error("TypeSafe rate-limited the request")),
|
|
518
|
+
"rate_limited",
|
|
519
|
+
);
|
|
520
|
+
assert.equal(throttleCategory(new Error("service is overloaded")), "overloaded");
|
|
521
|
+
assert.equal(throttleCategory(new Error("boom")), undefined);
|
|
522
|
+
assert.equal(throttleCategory(undefined), undefined);
|
|
523
|
+
|
|
524
|
+
const browser = await chromium.launch({ headless: true });
|
|
525
|
+
try {
|
|
526
|
+
const page = await browser.newPage();
|
|
527
|
+
await page.setContent("<button>Go</button>");
|
|
528
|
+
const result = await runJev(
|
|
529
|
+
{ goal: "Press Go" },
|
|
530
|
+
{
|
|
531
|
+
page: () => page,
|
|
532
|
+
policy: {
|
|
533
|
+
async choose() {
|
|
534
|
+
throw Object.assign(
|
|
535
|
+
new Error("TypeSafe request failed with 429"),
|
|
536
|
+
{ statusCode: 429 },
|
|
537
|
+
);
|
|
538
|
+
},
|
|
539
|
+
async text() {
|
|
540
|
+
return "unused";
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
assert.equal(result.status, "interrupted");
|
|
546
|
+
assert.deepEqual(result.failure, {
|
|
547
|
+
stage: "evaluation",
|
|
548
|
+
category: "rate_limited",
|
|
549
|
+
detail: result.failure?.detail,
|
|
550
|
+
});
|
|
551
|
+
// A failure the caller cannot see is a failure nobody can fix.
|
|
552
|
+
assert.match(result.failure?.detail ?? "", /HTTP 429/);
|
|
553
|
+
assert.match(result.message, /rate-limited .*\(HTTP 429\)/);
|
|
554
|
+
assert.match(result.message, /Wait before retrying/);
|
|
555
|
+
assert.equal(result.steps.length, 0);
|
|
556
|
+
} finally {
|
|
557
|
+
await browser.close();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { observe } from "../src/jev-browser.ts";
|
|
5
|
+
import { runJev } from "../src/jev-run.ts";
|
|
6
|
+
|
|
7
|
+
test("post-action navigation read is recovered without repeating the click", async () => {
|
|
8
|
+
const browser = await chromium.launch({ headless: true });
|
|
9
|
+
try {
|
|
10
|
+
const page = await browser.newPage();
|
|
11
|
+
await page.setContent(
|
|
12
|
+
"<button onclick=\"this.textContent='Opened'\">Open</button>",
|
|
13
|
+
);
|
|
14
|
+
const originalRead = page.evaluateHandle.bind(page);
|
|
15
|
+
let invalidateNextRead = false;
|
|
16
|
+
let failures = 0;
|
|
17
|
+
page.evaluateHandle = (async (
|
|
18
|
+
...args: Parameters<typeof page.evaluateHandle>
|
|
19
|
+
) => {
|
|
20
|
+
if (invalidateNextRead) {
|
|
21
|
+
invalidateNextRead = false;
|
|
22
|
+
failures++;
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Execution context was destroyed, most likely because of a navigation",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return originalRead(...args);
|
|
28
|
+
}) as typeof page.evaluateHandle;
|
|
29
|
+
let clicks = 0;
|
|
30
|
+
const result = await runJev(
|
|
31
|
+
{ goal: "Open" },
|
|
32
|
+
{
|
|
33
|
+
page: () => page,
|
|
34
|
+
onStep: async (step) => {
|
|
35
|
+
if (step.status === "executed") {
|
|
36
|
+
clicks++;
|
|
37
|
+
invalidateNextRead = true;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
policy: {
|
|
41
|
+
async choose(data, _goal, history) {
|
|
42
|
+
if (clicks) {
|
|
43
|
+
assert.equal(data.targets[0]?.label, "Opened");
|
|
44
|
+
assert.equal(history.length, 1);
|
|
45
|
+
return { operation: "DONE" };
|
|
46
|
+
}
|
|
47
|
+
return { operation: "CLICK", target: data.targets[0] };
|
|
48
|
+
},
|
|
49
|
+
async text() {
|
|
50
|
+
throw new Error("Unexpected text request");
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
assert.equal(result.status, "done_unverified");
|
|
56
|
+
assert.equal(clicks, 1);
|
|
57
|
+
assert.equal(failures, 1);
|
|
58
|
+
assert.equal(result.steps.length, 1);
|
|
59
|
+
|
|
60
|
+
page.evaluateHandle = originalRead;
|
|
61
|
+
await page.evaluate(() => {
|
|
62
|
+
document.body.remove();
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
const body = document.createElement("body");
|
|
65
|
+
body.innerHTML = "<h1>New document ready</h1>";
|
|
66
|
+
document.documentElement.append(body);
|
|
67
|
+
}, 150);
|
|
68
|
+
});
|
|
69
|
+
const settled = await observe(page);
|
|
70
|
+
assert.match(settled.data.text, /New document ready/);
|
|
71
|
+
await settled.dispose();
|
|
72
|
+
|
|
73
|
+
let reads = 0;
|
|
74
|
+
page.evaluateHandle = async () => {
|
|
75
|
+
reads++;
|
|
76
|
+
throw new Error("Execution context was destroyed");
|
|
77
|
+
};
|
|
78
|
+
await assert.rejects(observe(page), /Execution context/);
|
|
79
|
+
assert.equal(reads, 5);
|
|
80
|
+
reads = 0;
|
|
81
|
+
page.evaluateHandle = async () => {
|
|
82
|
+
reads++;
|
|
83
|
+
throw new Error("Unexpected page bug");
|
|
84
|
+
};
|
|
85
|
+
await assert.rejects(observe(page), /Unexpected page bug/);
|
|
86
|
+
assert.equal(reads, 1);
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
controller.abort();
|
|
89
|
+
await assert.rejects(observe(page, controller.signal), /abort/i);
|
|
90
|
+
assert.equal(reads, 1);
|
|
91
|
+
} finally {
|
|
92
|
+
await browser.close();
|
|
93
|
+
}
|
|
94
|
+
});
|