stuntdouble 0.1.2 → 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 +37 -9
- package/dist/{chunk-VWTYG7G4.js → chunk-BFI6P5DO.js} +65 -25
- package/dist/chunk-BFI6P5DO.js.map +1 -0
- package/dist/chunk-DFXASIGV.js +431 -0
- package/dist/chunk-DFXASIGV.js.map +1 -0
- package/dist/{chunk-JAB46QV7.js → chunk-F3R3QL7V.js} +64 -8
- package/dist/chunk-F3R3QL7V.js.map +1 -0
- package/dist/{mcp-CYPMT37O.js → chunk-FMZTT2MC.js} +72 -1059
- package/dist/chunk-FMZTT2MC.js.map +1 -0
- package/dist/chunk-MSV5OLSY.js +117 -0
- package/dist/chunk-MSV5OLSY.js.map +1 -0
- package/dist/chunk-P3CI4FTE.js +469 -0
- package/dist/chunk-P3CI4FTE.js.map +1 -0
- package/dist/chunk-QDA5FTUH.js +79 -0
- package/dist/chunk-QDA5FTUH.js.map +1 -0
- package/dist/chunk-XHSDRB3X.js +996 -0
- package/dist/chunk-XHSDRB3X.js.map +1 -0
- package/dist/cli.js +180 -68
- package/dist/cli.js.map +1 -1
- package/dist/cloud-DLLNKYND.js +34 -0
- package/dist/credentials-QHESDY4K.js +27 -0
- package/dist/credentials-QHESDY4K.js.map +1 -0
- package/dist/doctor-ZAYOOCPT.js +13 -0
- package/dist/doctor-ZAYOOCPT.js.map +1 -0
- package/dist/files-UBPF7PKW.js +45 -0
- package/dist/files-UBPF7PKW.js.map +1 -0
- package/dist/mcp-HFIAQ6HP.js +133 -0
- package/dist/mcp-HFIAQ6HP.js.map +1 -0
- package/dist/open-browser-CTOGH5HQ.js +17 -0
- package/dist/open-browser-CTOGH5HQ.js.map +1 -0
- package/dist/run-MPIAR3LQ.js +20 -0
- package/dist/run-MPIAR3LQ.js.map +1 -0
- package/dist/runner-E45EJ76B.js +17 -0
- package/dist/runner-E45EJ76B.js.map +1 -0
- package/dist/studio-FBC66BER.js +360 -0
- package/dist/studio-FBC66BER.js.map +1 -0
- package/dist/studio.js +1621 -0
- package/dist/ui-server-C7CFJWRD.js +12 -0
- package/dist/ui-server-C7CFJWRD.js.map +1 -0
- package/dist/ui.html +210 -103
- package/package.json +2 -2
- package/dist/chunk-JAB46QV7.js.map +0 -1
- package/dist/chunk-VWTYG7G4.js.map +0 -1
- package/dist/mcp-CYPMT37O.js.map +0 -1
- package/dist/ui-server-AYIR7DTN.js +0 -10
- /package/dist/{ui-server-AYIR7DTN.js.map → cloud-DLLNKYND.js.map} +0 -0
|
@@ -1,94 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
startUi
|
|
3
|
-
} from "./chunk-VWTYG7G4.js";
|
|
4
|
-
import {
|
|
5
|
-
ActorSchema,
|
|
6
|
-
ChecklistSchema,
|
|
7
|
-
ConfigSchema,
|
|
8
|
-
appendJsonl,
|
|
9
|
-
describeRunScore,
|
|
10
|
-
isAlive,
|
|
11
|
-
newChecklistScoring,
|
|
12
|
-
projectDir,
|
|
13
|
-
readJson,
|
|
14
|
-
readRuns,
|
|
15
|
-
resolveHaltedChecks,
|
|
16
|
-
resolveSkippedChecks,
|
|
17
|
-
scoreChecklistRun,
|
|
18
|
-
sdDir,
|
|
19
|
-
writeFileAtomic,
|
|
20
|
-
writeJsonAtomic
|
|
21
|
-
} from "./chunk-JAB46QV7.js";
|
|
22
|
-
|
|
23
|
-
// src/mcp.ts
|
|
24
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
-
|
|
27
|
-
// ../browser-toolset/src/keys.ts
|
|
28
|
-
var ALIASES = {
|
|
29
|
-
ctrl: "Control",
|
|
30
|
-
control: "Control",
|
|
31
|
-
cmd: "Meta",
|
|
32
|
-
command: "Meta",
|
|
33
|
-
meta: "Meta",
|
|
34
|
-
super: "Meta",
|
|
35
|
-
win: "Meta",
|
|
36
|
-
alt: "Alt",
|
|
37
|
-
option: "Alt",
|
|
38
|
-
shift: "Shift",
|
|
39
|
-
enter: "Enter",
|
|
40
|
-
return: "Enter",
|
|
41
|
-
kp_enter: "Enter",
|
|
42
|
-
esc: "Escape",
|
|
43
|
-
escape: "Escape",
|
|
44
|
-
tab: "Tab",
|
|
45
|
-
space: " ",
|
|
46
|
-
backspace: "Backspace",
|
|
47
|
-
delete: "Delete",
|
|
48
|
-
del: "Delete",
|
|
49
|
-
insert: "Insert",
|
|
50
|
-
home: "Home",
|
|
51
|
-
end: "End",
|
|
52
|
-
page_up: "PageUp",
|
|
53
|
-
pageup: "PageUp",
|
|
54
|
-
prior: "PageUp",
|
|
55
|
-
page_down: "PageDown",
|
|
56
|
-
pagedown: "PageDown",
|
|
57
|
-
next: "PageDown",
|
|
58
|
-
up: "ArrowUp",
|
|
59
|
-
down: "ArrowDown",
|
|
60
|
-
left: "ArrowLeft",
|
|
61
|
-
right: "ArrowRight",
|
|
62
|
-
arrowup: "ArrowUp",
|
|
63
|
-
arrowdown: "ArrowDown",
|
|
64
|
-
arrowleft: "ArrowLeft",
|
|
65
|
-
arrowright: "ArrowRight",
|
|
66
|
-
capslock: "CapsLock",
|
|
67
|
-
caps_lock: "CapsLock"
|
|
68
|
-
};
|
|
69
|
-
function normaliseKey(raw) {
|
|
70
|
-
const key = raw.trim();
|
|
71
|
-
const alias = ALIASES[key.toLowerCase()];
|
|
72
|
-
if (alias) return alias;
|
|
73
|
-
if (/^f([1-9]|1[0-2])$/i.test(key)) return key.toUpperCase();
|
|
74
|
-
return key;
|
|
75
|
-
}
|
|
76
|
-
function toPlaywrightChord(chord) {
|
|
77
|
-
const parts = chord.split("+").filter((p) => p.length > 0);
|
|
78
|
-
if (chord.endsWith("+") && chord.length > 1 && chord[chord.length - 2] === "+") parts.push("+");
|
|
79
|
-
if (parts.length === 0) return chord === "+" ? "+" : chord;
|
|
80
|
-
const keys = parts.map(normaliseKey);
|
|
81
|
-
if (keys.length > 1) {
|
|
82
|
-
const last = keys[keys.length - 1];
|
|
83
|
-
if (last.length === 1) keys[keys.length - 1] = last.toUpperCase();
|
|
84
|
-
}
|
|
85
|
-
return keys.join("+");
|
|
86
|
-
}
|
|
87
|
-
function toPlaywrightKeySequence(text2) {
|
|
88
|
-
if (text2 === " ") return [" "];
|
|
89
|
-
return text2.split(/\s+/).filter((k) => k.length > 0).map(toPlaywrightChord);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
1
|
// ../browser-toolset/src/safety.ts
|
|
93
2
|
var INDEX_SESSION_SAFETY = {
|
|
94
3
|
refuseSensitiveInput: true,
|
|
@@ -205,6 +114,71 @@ function refusalText(reason) {
|
|
|
205
114
|
return `Refused: ${reason} ${SAFETY_REFUSAL_SUFFIX}`;
|
|
206
115
|
}
|
|
207
116
|
|
|
117
|
+
// ../browser-toolset/src/keys.ts
|
|
118
|
+
var ALIASES = {
|
|
119
|
+
ctrl: "Control",
|
|
120
|
+
control: "Control",
|
|
121
|
+
cmd: "Meta",
|
|
122
|
+
command: "Meta",
|
|
123
|
+
meta: "Meta",
|
|
124
|
+
super: "Meta",
|
|
125
|
+
win: "Meta",
|
|
126
|
+
alt: "Alt",
|
|
127
|
+
option: "Alt",
|
|
128
|
+
shift: "Shift",
|
|
129
|
+
enter: "Enter",
|
|
130
|
+
return: "Enter",
|
|
131
|
+
kp_enter: "Enter",
|
|
132
|
+
esc: "Escape",
|
|
133
|
+
escape: "Escape",
|
|
134
|
+
tab: "Tab",
|
|
135
|
+
space: " ",
|
|
136
|
+
backspace: "Backspace",
|
|
137
|
+
delete: "Delete",
|
|
138
|
+
del: "Delete",
|
|
139
|
+
insert: "Insert",
|
|
140
|
+
home: "Home",
|
|
141
|
+
end: "End",
|
|
142
|
+
page_up: "PageUp",
|
|
143
|
+
pageup: "PageUp",
|
|
144
|
+
prior: "PageUp",
|
|
145
|
+
page_down: "PageDown",
|
|
146
|
+
pagedown: "PageDown",
|
|
147
|
+
next: "PageDown",
|
|
148
|
+
up: "ArrowUp",
|
|
149
|
+
down: "ArrowDown",
|
|
150
|
+
left: "ArrowLeft",
|
|
151
|
+
right: "ArrowRight",
|
|
152
|
+
arrowup: "ArrowUp",
|
|
153
|
+
arrowdown: "ArrowDown",
|
|
154
|
+
arrowleft: "ArrowLeft",
|
|
155
|
+
arrowright: "ArrowRight",
|
|
156
|
+
capslock: "CapsLock",
|
|
157
|
+
caps_lock: "CapsLock"
|
|
158
|
+
};
|
|
159
|
+
function normaliseKey(raw) {
|
|
160
|
+
const key = raw.trim();
|
|
161
|
+
const alias = ALIASES[key.toLowerCase()];
|
|
162
|
+
if (alias) return alias;
|
|
163
|
+
if (/^f([1-9]|1[0-2])$/i.test(key)) return key.toUpperCase();
|
|
164
|
+
return key;
|
|
165
|
+
}
|
|
166
|
+
function toPlaywrightChord(chord) {
|
|
167
|
+
const parts = chord.split("+").filter((p) => p.length > 0);
|
|
168
|
+
if (chord.endsWith("+") && chord.length > 1 && chord[chord.length - 2] === "+") parts.push("+");
|
|
169
|
+
if (parts.length === 0) return chord === "+" ? "+" : chord;
|
|
170
|
+
const keys = parts.map(normaliseKey);
|
|
171
|
+
if (keys.length > 1) {
|
|
172
|
+
const last = keys[keys.length - 1];
|
|
173
|
+
if (last.length === 1) keys[keys.length - 1] = last.toUpperCase();
|
|
174
|
+
}
|
|
175
|
+
return keys.join("+");
|
|
176
|
+
}
|
|
177
|
+
function toPlaywrightKeySequence(text2) {
|
|
178
|
+
if (text2 === " ") return [" "];
|
|
179
|
+
return text2.split(/\s+/).filter((k) => k.length > 0).map(toPlaywrightChord);
|
|
180
|
+
}
|
|
181
|
+
|
|
208
182
|
// ../browser-toolset/src/page-script.ts
|
|
209
183
|
var MAX_PAGE_TEXT_CHARS = 5e4;
|
|
210
184
|
var CARD_PATTERN_SOURCE = JSON.stringify(CARD_FIELD_PATTERN.source);
|
|
@@ -1419,973 +1393,12 @@ ${where}` : where,
|
|
|
1419
1393
|
}
|
|
1420
1394
|
};
|
|
1421
1395
|
|
|
1422
|
-
// src/mcp.ts
|
|
1423
|
-
import { z } from "zod";
|
|
1424
|
-
|
|
1425
|
-
// src/run.ts
|
|
1426
|
-
import { mkdir, mkdtemp, readFile, rm, stat } from "fs/promises";
|
|
1427
|
-
import { homedir, tmpdir } from "os";
|
|
1428
|
-
import { join } from "path";
|
|
1429
|
-
|
|
1430
|
-
// package.json
|
|
1431
|
-
var package_default = {
|
|
1432
|
-
name: "stuntdouble",
|
|
1433
|
-
version: "0.1.2",
|
|
1434
|
-
description: "Stunt Double Local: run Stunt Double checklists in a local Chrome from your own coding agent session, with the runs kept as files and a local viewer",
|
|
1435
|
-
keywords: [
|
|
1436
|
-
"stunt-double",
|
|
1437
|
-
"testing",
|
|
1438
|
-
"browser",
|
|
1439
|
-
"checklist",
|
|
1440
|
-
"mcp",
|
|
1441
|
-
"playwright",
|
|
1442
|
-
"agents"
|
|
1443
|
-
],
|
|
1444
|
-
license: "Apache-2.0",
|
|
1445
|
-
author: "Turnout Labs Ltd",
|
|
1446
|
-
homepage: "https://github.com/stunt-double/stuntkit/tree/main/packages/local#readme",
|
|
1447
|
-
bugs: {
|
|
1448
|
-
url: "https://github.com/stunt-double/stuntkit/issues"
|
|
1449
|
-
},
|
|
1450
|
-
repository: {
|
|
1451
|
-
type: "git",
|
|
1452
|
-
url: "git+https://github.com/stunt-double/stuntkit.git",
|
|
1453
|
-
directory: "packages/local"
|
|
1454
|
-
},
|
|
1455
|
-
type: "module",
|
|
1456
|
-
bin: {
|
|
1457
|
-
stuntdouble: "dist/cli.js"
|
|
1458
|
-
},
|
|
1459
|
-
files: [
|
|
1460
|
-
"dist",
|
|
1461
|
-
"README.md",
|
|
1462
|
-
"LICENSE"
|
|
1463
|
-
],
|
|
1464
|
-
scripts: {
|
|
1465
|
-
build: "tsup",
|
|
1466
|
-
"type-check": "tsc -p tsconfig.json",
|
|
1467
|
-
test: 'node --conditions=@stunt-double/source --test "test/**/*.test.ts"',
|
|
1468
|
-
prepack: "pnpm run build"
|
|
1469
|
-
},
|
|
1470
|
-
dependencies: {
|
|
1471
|
-
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
1472
|
-
"playwright-core": "^1.64.1",
|
|
1473
|
-
zod: "^4.6.7"
|
|
1474
|
-
},
|
|
1475
|
-
devDependencies: {
|
|
1476
|
-
"@stunt-double/browser-toolset": "workspace:*"
|
|
1477
|
-
},
|
|
1478
|
-
engines: {
|
|
1479
|
-
node: ">=22.18"
|
|
1480
|
-
},
|
|
1481
|
-
publishConfig: {
|
|
1482
|
-
access: "public",
|
|
1483
|
-
registry: "https://registry.npmjs.org",
|
|
1484
|
-
provenance: true
|
|
1485
|
-
}
|
|
1486
|
-
};
|
|
1487
|
-
|
|
1488
|
-
// src/brief.ts
|
|
1489
|
-
var CONDITION_LABELS = {
|
|
1490
|
-
always: "always",
|
|
1491
|
-
if_previous_passed: "only if the check before it passed",
|
|
1492
|
-
if_previous_failed: "only if the check before it failed"
|
|
1493
|
-
};
|
|
1494
|
-
function checkLabel(check) {
|
|
1495
|
-
const kind = check.type === "informational" ? "informational, record what you observe" : check.type === "weighted" ? `weighted x${check.weight ?? 1}${(check.weight ?? 1) < 0 ? ", a penalty" : ""}` : "pass/fail";
|
|
1496
|
-
const parts = [kind, CONDITION_LABELS[check.run_condition]];
|
|
1497
|
-
if (check.on_failure === "stop") parts.push("the run stops if it fails");
|
|
1498
|
-
return `(${parts.join(", ")})`;
|
|
1499
|
-
}
|
|
1500
|
-
function checkLine(check) {
|
|
1501
|
-
return `- [${check.key}] ${checkLabel(check)} ${check.description}`;
|
|
1502
|
-
}
|
|
1503
|
-
function buildBrief(input) {
|
|
1504
|
-
const { actor, checklist, url, checks, resumed } = input;
|
|
1505
|
-
const persona = [
|
|
1506
|
-
`You are ${actor.name}${actor.description ? `: ${actor.description}` : ""}.`,
|
|
1507
|
-
actor.system_prompt ?? ""
|
|
1508
|
-
].filter(Boolean).join("\n");
|
|
1509
|
-
const knowledge = actor.knowledge.length ? [
|
|
1510
|
-
"What you know:",
|
|
1511
|
-
...actor.knowledge.map((k) => `### ${k.title} (${k.kind})
|
|
1512
|
-
${k.content}`)
|
|
1513
|
-
].join("\n") : "";
|
|
1514
|
-
return [
|
|
1515
|
-
persona,
|
|
1516
|
-
knowledge,
|
|
1517
|
-
`Your task: ${checklist.instructions}`,
|
|
1518
|
-
`Start at ${url}${resumed ? " (this run is resuming: earlier verdicts stand, judge only the checks below)" : ""}.`,
|
|
1519
|
-
`Checks to judge:
|
|
1520
|
-
${checks.map(checkLine).join("\n")}`,
|
|
1521
|
-
[
|
|
1522
|
-
"How to work:",
|
|
1523
|
-
"- Stay in persona: act and judge as this person would, not as a tester.",
|
|
1524
|
-
"- Read the page with read_page and find. Ask for a screenshot only when how something looks is the question.",
|
|
1525
|
-
"- Judge each check with evaluate_check as soon as you have observed it, while the evidence is on screen.",
|
|
1526
|
-
"- A passed verdict needs a locator: text visible on the page, or a CSS selector, that shows the evidence. A failure may describe something missing.",
|
|
1527
|
-
"- Cite only what you saw in this run. Never pass a check on what a page probably does.",
|
|
1528
|
-
'- When something stops you (a sign-in wall, a broken page, a CAPTCHA), judge the checks you cannot reach as "blocked" and say what stopped you.',
|
|
1529
|
-
"- Always end with finish_run and a one or two sentence summary, even when blocked or stopped."
|
|
1530
|
-
].join("\n")
|
|
1531
|
-
].filter(Boolean).join("\n\n");
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// src/driver.ts
|
|
1535
|
-
import { chromium } from "playwright-core";
|
|
1536
|
-
var pageIds = /* @__PURE__ */ new WeakMap();
|
|
1537
|
-
var pageSeq = 0;
|
|
1538
|
-
function idOf(page) {
|
|
1539
|
-
let id = pageIds.get(page);
|
|
1540
|
-
if (!id) {
|
|
1541
|
-
pageSeq += 1;
|
|
1542
|
-
id = `pw-${pageSeq}`;
|
|
1543
|
-
pageIds.set(page, id);
|
|
1544
|
-
}
|
|
1545
|
-
return id;
|
|
1546
|
-
}
|
|
1547
|
-
var PlaywrightPage = class {
|
|
1548
|
-
id;
|
|
1549
|
-
raw;
|
|
1550
|
-
constructor(page) {
|
|
1551
|
-
this.raw = page;
|
|
1552
|
-
this.id = idOf(page);
|
|
1553
|
-
}
|
|
1554
|
-
goto(url, options = {}) {
|
|
1555
|
-
return this.raw.goto(url, {
|
|
1556
|
-
...options.waitUntil ? { waitUntil: options.waitUntil } : {},
|
|
1557
|
-
...options.timeoutMs ? { timeout: options.timeoutMs } : {}
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
goBack() {
|
|
1561
|
-
return this.raw.goBack();
|
|
1562
|
-
}
|
|
1563
|
-
goForward() {
|
|
1564
|
-
return this.raw.goForward();
|
|
1565
|
-
}
|
|
1566
|
-
screenshot(options = {}) {
|
|
1567
|
-
return this.raw.screenshot(options);
|
|
1568
|
-
}
|
|
1569
|
-
evaluate(expression, arg) {
|
|
1570
|
-
if (typeof expression === "string") return this.raw.evaluate(expression);
|
|
1571
|
-
return this.raw.evaluate(expression, arg);
|
|
1572
|
-
}
|
|
1573
|
-
url() {
|
|
1574
|
-
return this.raw.url();
|
|
1575
|
-
}
|
|
1576
|
-
title() {
|
|
1577
|
-
return this.raw.title();
|
|
1578
|
-
}
|
|
1579
|
-
setViewportSize(width, height) {
|
|
1580
|
-
return this.raw.setViewportSize({ width, height });
|
|
1581
|
-
}
|
|
1582
|
-
waitForTimeout(ms) {
|
|
1583
|
-
return this.raw.waitForTimeout(ms);
|
|
1584
|
-
}
|
|
1585
|
-
waitForSelector(selector, options = {}) {
|
|
1586
|
-
return this.raw.waitForSelector(selector, options);
|
|
1587
|
-
}
|
|
1588
|
-
waitForLoadState(state, timeoutMs) {
|
|
1589
|
-
return this.raw.waitForLoadState(state, timeoutMs ? { timeout: timeoutMs } : void 0);
|
|
1590
|
-
}
|
|
1591
|
-
locator(selector) {
|
|
1592
|
-
return this.raw.locator(selector).first();
|
|
1593
|
-
}
|
|
1594
|
-
click(x, y, options = {}) {
|
|
1595
|
-
return this.raw.mouse.click(x, y, options);
|
|
1596
|
-
}
|
|
1597
|
-
hover(x, y) {
|
|
1598
|
-
return this.raw.mouse.move(x, y);
|
|
1599
|
-
}
|
|
1600
|
-
async scroll(x, y, deltaX, deltaY) {
|
|
1601
|
-
await this.raw.mouse.move(x, y);
|
|
1602
|
-
await this.raw.mouse.wheel(deltaX, deltaY);
|
|
1603
|
-
}
|
|
1604
|
-
async dragAndDrop(fromX, fromY, toX, toY) {
|
|
1605
|
-
await this.raw.mouse.move(fromX, fromY);
|
|
1606
|
-
await this.raw.mouse.down();
|
|
1607
|
-
await this.raw.mouse.move(toX, toY, { steps: 10 });
|
|
1608
|
-
await this.raw.mouse.up();
|
|
1609
|
-
}
|
|
1610
|
-
type(text2) {
|
|
1611
|
-
return this.raw.keyboard.type(text2);
|
|
1612
|
-
}
|
|
1613
|
-
keyPress(key) {
|
|
1614
|
-
return this.raw.keyboard.press(key);
|
|
1615
|
-
}
|
|
1616
|
-
close() {
|
|
1617
|
-
return this.raw.close();
|
|
1618
|
-
}
|
|
1619
|
-
};
|
|
1620
|
-
var PlaywrightContext = class {
|
|
1621
|
-
context;
|
|
1622
|
-
active;
|
|
1623
|
-
policy = {};
|
|
1624
|
-
routed = false;
|
|
1625
|
-
constructor(context) {
|
|
1626
|
-
this.context = context;
|
|
1627
|
-
context.on("page", (page) => {
|
|
1628
|
-
this.active = page;
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
1631
|
-
activePage() {
|
|
1632
|
-
const open = this.context.pages();
|
|
1633
|
-
if (!this.active || this.active.isClosed()) this.active = open[open.length - 1];
|
|
1634
|
-
return this.active ? new PlaywrightPage(this.active) : void 0;
|
|
1635
|
-
}
|
|
1636
|
-
pages() {
|
|
1637
|
-
return this.context.pages().map((p) => new PlaywrightPage(p));
|
|
1638
|
-
}
|
|
1639
|
-
async newPage(url) {
|
|
1640
|
-
const page = await this.context.newPage();
|
|
1641
|
-
this.active = page;
|
|
1642
|
-
if (url) await page.goto(url);
|
|
1643
|
-
return new PlaywrightPage(page);
|
|
1644
|
-
}
|
|
1645
|
-
async setActivePage(page) {
|
|
1646
|
-
const raw = this.context.pages().find((p) => idOf(p) === page.id);
|
|
1647
|
-
if (!raw) throw new Error(`No open tab with id ${page.id}`);
|
|
1648
|
-
this.active = raw;
|
|
1649
|
-
await raw.bringToFront();
|
|
1650
|
-
}
|
|
1651
|
-
clearCookies() {
|
|
1652
|
-
return this.context.clearCookies();
|
|
1653
|
-
}
|
|
1654
|
-
async addInitScript(source) {
|
|
1655
|
-
await this.context.addInitScript(source);
|
|
1656
|
-
}
|
|
1657
|
-
/** Abort requests to blocked hosts, or to anything outside the allow list. */
|
|
1658
|
-
async setDomainPolicy(policy) {
|
|
1659
|
-
this.policy = policy;
|
|
1660
|
-
if (this.routed) return;
|
|
1661
|
-
this.routed = true;
|
|
1662
|
-
await this.context.route("**/*", (route) => {
|
|
1663
|
-
let host = "";
|
|
1664
|
-
try {
|
|
1665
|
-
host = new URL(route.request().url()).hostname;
|
|
1666
|
-
} catch {
|
|
1667
|
-
return route.continue();
|
|
1668
|
-
}
|
|
1669
|
-
const { allowedDomains, blockedDomains } = this.policy;
|
|
1670
|
-
const blocked = (blockedDomains ?? []).some((d) => hostMatchesDomain(host, d)) || allowedDomains !== void 0 && allowedDomains.length > 0 && !allowedDomains.some((d) => hostMatchesDomain(host, d));
|
|
1671
|
-
return blocked ? route.abort("blockedbyclient") : route.continue();
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
};
|
|
1675
|
-
var PlaywrightDriver = class _PlaywrightDriver {
|
|
1676
|
-
context;
|
|
1677
|
-
browserContext;
|
|
1678
|
-
refusal = null;
|
|
1679
|
-
constructor(context) {
|
|
1680
|
-
this.browserContext = context;
|
|
1681
|
-
this.context = new PlaywrightContext(context);
|
|
1682
|
-
}
|
|
1683
|
-
/**
|
|
1684
|
-
* The installed Google Chrome, or Playwright's own Chromium when Chrome is
|
|
1685
|
-
* missing and `npx playwright install chromium` has been run.
|
|
1686
|
-
*/
|
|
1687
|
-
static async launch(options) {
|
|
1688
|
-
const settings = {
|
|
1689
|
-
headless: options.headless,
|
|
1690
|
-
viewport: options.viewport,
|
|
1691
|
-
hasTouch: options.touch,
|
|
1692
|
-
...options.locale ? { locale: options.locale } : {}
|
|
1693
|
-
};
|
|
1694
|
-
let context;
|
|
1695
|
-
try {
|
|
1696
|
-
context = await chromium.launchPersistentContext(options.profileDir, {
|
|
1697
|
-
...settings,
|
|
1698
|
-
channel: "chrome"
|
|
1699
|
-
});
|
|
1700
|
-
} catch (chromeError) {
|
|
1701
|
-
try {
|
|
1702
|
-
context = await chromium.launchPersistentContext(options.profileDir, settings);
|
|
1703
|
-
} catch {
|
|
1704
|
-
throw new Error(
|
|
1705
|
-
`Could not start Chrome (${chromeError.message.split("\n")[0]}). Install Google Chrome, or run \`npx playwright install chromium\`.`
|
|
1706
|
-
);
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
const driver = new _PlaywrightDriver(context);
|
|
1710
|
-
const refuse = (request) => {
|
|
1711
|
-
const reason = request.isNavigationRequest() ? options.refuseNavigation(request.url()) : null;
|
|
1712
|
-
if (reason && !request.frame().parentFrame()) driver.refusal = reason;
|
|
1713
|
-
return reason;
|
|
1714
|
-
};
|
|
1715
|
-
await context.route(
|
|
1716
|
-
"**/*",
|
|
1717
|
-
(route) => refuse(route.request()) ? route.abort("blockedbyclient") : route.fallback()
|
|
1718
|
-
);
|
|
1719
|
-
context.on("request", (request) => {
|
|
1720
|
-
if (request.redirectedFrom() && refuse(request)) {
|
|
1721
|
-
request.frame().goto("about:blank").catch(() => {
|
|
1722
|
-
});
|
|
1723
|
-
}
|
|
1724
|
-
});
|
|
1725
|
-
if (context.pages().length === 0) await driver.context.newPage();
|
|
1726
|
-
return driver;
|
|
1727
|
-
}
|
|
1728
|
-
takeRefusal() {
|
|
1729
|
-
const refusal = this.refusal;
|
|
1730
|
-
this.refusal = null;
|
|
1731
|
-
return refusal;
|
|
1732
|
-
}
|
|
1733
|
-
/** Instruction-style actions need a model of their own. Nothing here calls them. */
|
|
1734
|
-
async act() {
|
|
1735
|
-
throw new Error("act is not supported by this driver: use the browser toolset instead");
|
|
1736
|
-
}
|
|
1737
|
-
async extract() {
|
|
1738
|
-
throw new Error("extract is not supported by this driver: use the browser toolset instead");
|
|
1739
|
-
}
|
|
1740
|
-
close() {
|
|
1741
|
-
return this.browserContext.close();
|
|
1742
|
-
}
|
|
1743
|
-
};
|
|
1744
|
-
|
|
1745
|
-
// src/evidence.ts
|
|
1746
|
-
var MAX_LOCATOR_TEXT = 120;
|
|
1747
|
-
var MAX_LOCATOR_SELECTOR = 400;
|
|
1748
|
-
var SETTLE_MS2 = 350;
|
|
1749
|
-
function cleanLocator(locator) {
|
|
1750
|
-
const clean = (value) => value?.replace(/\s+/g, " ").trim() || null;
|
|
1751
|
-
const selector = clean(locator?.selector);
|
|
1752
|
-
return {
|
|
1753
|
-
selector: selector && selector.length <= MAX_LOCATOR_SELECTOR ? selector : null,
|
|
1754
|
-
text: clean(locator?.text)?.slice(0, MAX_LOCATOR_TEXT) ?? null
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
|
-
async function focusEvidence(page, query) {
|
|
1758
|
-
if (!query.selector && !query.text) return null;
|
|
1759
|
-
const via = await page.evaluate(findAndScroll, query).catch(() => null);
|
|
1760
|
-
if (via) await page.waitForTimeout(SETTLE_MS2).catch(() => {
|
|
1761
|
-
});
|
|
1762
|
-
return via;
|
|
1763
|
-
}
|
|
1764
|
-
function findAndScroll(query) {
|
|
1765
|
-
const centre = { block: "center", inline: "center" };
|
|
1766
|
-
const shown = (el) => {
|
|
1767
|
-
const rect = el.getBoundingClientRect();
|
|
1768
|
-
return rect.width > 0 && rect.height > 0 && el.checkVisibility({ visibilityProperty: true, opacityProperty: true });
|
|
1769
|
-
};
|
|
1770
|
-
const norm = (s) => (s ?? "").replace(/\s+/g, " ").toLowerCase();
|
|
1771
|
-
const wanted = query.text ? norm(query.text) : null;
|
|
1772
|
-
const labelled = (el, fragment) => {
|
|
1773
|
-
const labels = ["placeholder", "aria-label", "alt", "title"].map((a) => el.getAttribute(a));
|
|
1774
|
-
if ("value" in el && typeof el.value === "string") labels.push(el.value);
|
|
1775
|
-
return labels.some((l) => norm(l).includes(fragment));
|
|
1776
|
-
};
|
|
1777
|
-
const rendered = (el) => norm(el instanceof HTMLElement ? el.innerText : "");
|
|
1778
|
-
if (query.selector) {
|
|
1779
|
-
let matches = [];
|
|
1780
|
-
try {
|
|
1781
|
-
matches = Array.from(document.querySelectorAll(query.selector));
|
|
1782
|
-
} catch {
|
|
1783
|
-
}
|
|
1784
|
-
const el = matches.find(
|
|
1785
|
-
(m) => m !== document.documentElement && m !== document.body && m.tagName !== "MAIN" && shown(m) && (!wanted || labelled(m, wanted) || rendered(m).includes(wanted))
|
|
1786
|
-
);
|
|
1787
|
-
if (el) {
|
|
1788
|
-
el.scrollIntoView(centre);
|
|
1789
|
-
return "selector";
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
if (!wanted) return null;
|
|
1793
|
-
const elements = [];
|
|
1794
|
-
const collect = (root) => {
|
|
1795
|
-
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
1796
|
-
elements.push(el);
|
|
1797
|
-
if (el.shadowRoot) collect(el.shadowRoot);
|
|
1798
|
-
}
|
|
1799
|
-
};
|
|
1800
|
-
collect(document.body);
|
|
1801
|
-
let best = null;
|
|
1802
|
-
let bestLength = Number.POSITIVE_INFINITY;
|
|
1803
|
-
for (const el of elements) {
|
|
1804
|
-
if (["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(el.tagName)) continue;
|
|
1805
|
-
let length = 0;
|
|
1806
|
-
if (!labelled(el, wanted)) {
|
|
1807
|
-
const text2 = rendered(el);
|
|
1808
|
-
length = text2.includes(wanted) ? text2.length : Number.POSITIVE_INFINITY;
|
|
1809
|
-
}
|
|
1810
|
-
if (length >= bestLength || !shown(el)) continue;
|
|
1811
|
-
best = el;
|
|
1812
|
-
bestLength = length;
|
|
1813
|
-
}
|
|
1814
|
-
if (!best) return null;
|
|
1815
|
-
best.scrollIntoView(centre);
|
|
1816
|
-
return "text";
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
// src/run.ts
|
|
1820
|
-
var PACKAGE = `${package_default.name}@${package_default.version}`;
|
|
1821
|
-
var STOPPED = "This run was stopped from the viewer. Do not use the browser again: call finish_run now with a summary of what you found.";
|
|
1822
|
-
var NOT_JUDGED = "Not judged before the run finished.";
|
|
1823
|
-
var PRIVATE_REFUSED = "Local and private addresses other than the app under test are not reachable.";
|
|
1824
|
-
var LOG_TEXT_CHARS = 500;
|
|
1825
|
-
var PRESETS = {
|
|
1826
|
-
desktop: { width: 1280, height: 720, touch: false },
|
|
1827
|
-
tablet: { width: 1024, height: 1366, touch: true },
|
|
1828
|
-
mobile: { width: 390, height: 844, touch: true }
|
|
1829
|
-
};
|
|
1830
|
-
function rebase(url, localBase) {
|
|
1831
|
-
if (!localBase) return url;
|
|
1832
|
-
const target = new URL(url);
|
|
1833
|
-
return new URL(target.pathname + target.search + target.hash, new URL(localBase).origin).href;
|
|
1834
|
-
}
|
|
1835
|
-
function stamp(date = /* @__PURE__ */ new Date()) {
|
|
1836
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
1837
|
-
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
1838
|
-
}
|
|
1839
|
-
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1840
|
-
var LocalSession = class {
|
|
1841
|
-
root;
|
|
1842
|
-
launch;
|
|
1843
|
-
active = null;
|
|
1844
|
-
ui = null;
|
|
1845
|
-
constructor(options) {
|
|
1846
|
-
this.root = sdDir(options.project);
|
|
1847
|
-
this.launch = options.launch ?? PlaywrightDriver.launch;
|
|
1848
|
-
}
|
|
1849
|
-
/** The viewer's address, starting it on first use. */
|
|
1850
|
-
async openUi(path = "/") {
|
|
1851
|
-
this.ui ??= await startUi({ root: this.root });
|
|
1852
|
-
await writeJsonAtomic(join(this.root, ".live", "server.json"), {
|
|
1853
|
-
port: this.ui.port,
|
|
1854
|
-
token: this.ui.token,
|
|
1855
|
-
pid: process.pid
|
|
1856
|
-
});
|
|
1857
|
-
return this.ui.link(path);
|
|
1858
|
-
}
|
|
1859
|
-
async startRun(input) {
|
|
1860
|
-
if (this.active) return this.resumeActive(input);
|
|
1861
|
-
if (!/^[a-z0-9][a-z0-9-]*$/.test(input.checklist)) {
|
|
1862
|
-
throw new Error(`"${input.checklist}" is not a checklist slug (checklists/<slug>.json).`);
|
|
1863
|
-
}
|
|
1864
|
-
const config = await readJson(
|
|
1865
|
-
join(this.root, "stuntdouble.json"),
|
|
1866
|
-
ConfigSchema,
|
|
1867
|
-
".stuntdouble/stuntdouble.json"
|
|
1868
|
-
);
|
|
1869
|
-
let checklist = await readJson(
|
|
1870
|
-
join(this.root, "checklists", `${input.checklist}.json`),
|
|
1871
|
-
ChecklistSchema,
|
|
1872
|
-
`.stuntdouble/checklists/${input.checklist}.json`
|
|
1873
|
-
);
|
|
1874
|
-
const actor = await readJson(
|
|
1875
|
-
join(this.root, "actors", `${checklist.actor}.json`),
|
|
1876
|
-
ActorSchema,
|
|
1877
|
-
`.stuntdouble/actors/${checklist.actor}.json`
|
|
1878
|
-
);
|
|
1879
|
-
await this.markInterrupted();
|
|
1880
|
-
let run;
|
|
1881
|
-
let dir;
|
|
1882
|
-
if (input.resume) {
|
|
1883
|
-
const entry = (await readRuns(this.root)).find(
|
|
1884
|
-
(e) => "run" in e && e.run.checklist === input.checklist && e.run.runner.kind === "local" && (e.run.status === "interrupted" || e.run.status === "stopped")
|
|
1885
|
-
);
|
|
1886
|
-
if (!entry || !("run" in entry)) {
|
|
1887
|
-
throw new Error(`No interrupted or stopped run of ${input.checklist} to resume.`);
|
|
1888
|
-
}
|
|
1889
|
-
dir = join(this.root, "runs", entry.id);
|
|
1890
|
-
checklist = ChecklistSchema.parse(entry.run.checklist_snapshot);
|
|
1891
|
-
await this.clearStop(entry.id);
|
|
1892
|
-
run = {
|
|
1893
|
-
...entry.run,
|
|
1894
|
-
results: entry.run.results.filter((r) => r.evidence !== NOT_JUDGED),
|
|
1895
|
-
status: "running",
|
|
1896
|
-
pid: process.pid,
|
|
1897
|
-
finished_at: null,
|
|
1898
|
-
score: null,
|
|
1899
|
-
summary: null
|
|
1900
|
-
};
|
|
1901
|
-
} else {
|
|
1902
|
-
dir = "";
|
|
1903
|
-
run = {
|
|
1904
|
-
format: "stuntdouble.run/1",
|
|
1905
|
-
id: "",
|
|
1906
|
-
runner: { kind: "local", package: PACKAGE, judged: false },
|
|
1907
|
-
status: "running",
|
|
1908
|
-
pid: process.pid,
|
|
1909
|
-
url: rebase(checklist.url, config.local_base_url),
|
|
1910
|
-
started_at: now(),
|
|
1911
|
-
finished_at: null,
|
|
1912
|
-
checklist: input.checklist,
|
|
1913
|
-
actor: checklist.actor,
|
|
1914
|
-
checklist_snapshot: { ...checklist, cloud: void 0 },
|
|
1915
|
-
results: [],
|
|
1916
|
-
score: null,
|
|
1917
|
-
summary: null,
|
|
1918
|
-
cloud: null
|
|
1919
|
-
};
|
|
1920
|
-
}
|
|
1921
|
-
const trusted = [run.url, checklist.url, config.local_base_url].filter((u) => Boolean(u)).map((u) => new URL(u));
|
|
1922
|
-
const trustedHosts = [...new Set(trusted.map((u) => u.hostname))];
|
|
1923
|
-
const trustedOrigins = new Set(trusted.map((u) => u.origin));
|
|
1924
|
-
const refuseNavigation = (href) => {
|
|
1925
|
-
const url = URL.parse(href);
|
|
1926
|
-
return url && isPrivateHost(url.hostname) && !trustedOrigins.has(url.origin) ? PRIVATE_REFUSED : null;
|
|
1927
|
-
};
|
|
1928
|
-
const preset = PRESETS[checklist.browser_config?.preset ?? "desktop"];
|
|
1929
|
-
const toolOptions = {
|
|
1930
|
-
viewport: checklist.browser_config?.viewport ?? preset,
|
|
1931
|
-
allowPrivateHosts: true,
|
|
1932
|
-
checkNavigation: refuseNavigation
|
|
1933
|
-
};
|
|
1934
|
-
const tempProfile = actor.persist_login ? null : await mkdtemp(join(tmpdir(), "stuntdouble-profile-"));
|
|
1935
|
-
const profileDir = tempProfile ?? join(homedir(), ".stuntdouble", "profiles", checklist.actor);
|
|
1936
|
-
await mkdir(profileDir, { recursive: true });
|
|
1937
|
-
let driver;
|
|
1938
|
-
try {
|
|
1939
|
-
driver = await this.launch({
|
|
1940
|
-
profileDir,
|
|
1941
|
-
headless: config.headless,
|
|
1942
|
-
viewport: toolOptions.viewport,
|
|
1943
|
-
touch: checklist.browser_config?.touch ?? preset.touch,
|
|
1944
|
-
locale: checklist.browser_config?.locale,
|
|
1945
|
-
refuseNavigation
|
|
1946
|
-
});
|
|
1947
|
-
} catch (err) {
|
|
1948
|
-
if (tempProfile) await rm(tempProfile, { recursive: true, force: true });
|
|
1949
|
-
throw err;
|
|
1950
|
-
}
|
|
1951
|
-
if (!input.resume) {
|
|
1952
|
-
await mkdir(join(this.root, "runs"), { recursive: true });
|
|
1953
|
-
for (let n = 1; !dir; n++) {
|
|
1954
|
-
const id = `${stamp()}-${input.checklist}${n > 1 ? `-${n}` : ""}`;
|
|
1955
|
-
const made = await mkdir(join(this.root, "runs", id)).then(
|
|
1956
|
-
() => true,
|
|
1957
|
-
(err) => {
|
|
1958
|
-
if (err.code === "EEXIST") return false;
|
|
1959
|
-
throw err;
|
|
1960
|
-
}
|
|
1961
|
-
);
|
|
1962
|
-
if (made) [run.id, dir] = [id, join(this.root, "runs", id)];
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
const gitignore = join(this.root, ".gitignore");
|
|
1966
|
-
if (!await stat(gitignore).catch(() => null)) await writeFileAtomic(gitignore, ".live/\n");
|
|
1967
|
-
this.active = {
|
|
1968
|
-
dir,
|
|
1969
|
-
run,
|
|
1970
|
-
checklist,
|
|
1971
|
-
actor,
|
|
1972
|
-
driver,
|
|
1973
|
-
trusted: new DirectBrowserTools(driver, toolOptions),
|
|
1974
|
-
guarded: config.safety === "third_party" ? new DirectBrowserTools(driver, { ...toolOptions, safety: INDEX_SESSION_SAFETY }) : null,
|
|
1975
|
-
trustedHosts,
|
|
1976
|
-
tempProfile,
|
|
1977
|
-
everyScreenshot: config.model_screenshots === "every_action",
|
|
1978
|
-
stopped: false
|
|
1979
|
-
};
|
|
1980
|
-
await this.save();
|
|
1981
|
-
await this.log("start_run", input, input.resume ? "Resumed" : "Started");
|
|
1982
|
-
const opened = await this.browserTool("navigate", { url: run.url });
|
|
1983
|
-
const pending = this.pending();
|
|
1984
|
-
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
1985
|
-
return [
|
|
1986
|
-
`Run ${run.id} ${input.resume ? "resumed" : "started"}. Watch it or stop it at ${viewer}`,
|
|
1987
|
-
buildBrief({
|
|
1988
|
-
actor,
|
|
1989
|
-
checklist,
|
|
1990
|
-
url: run.url,
|
|
1991
|
-
checks: pending,
|
|
1992
|
-
resumed: Boolean(input.resume)
|
|
1993
|
-
}),
|
|
1994
|
-
opened.isError ? `The start page did not load: ${opened.text}
|
|
1995
|
-
Try navigate again, or judge the checks you cannot reach as "blocked".` : opened.text,
|
|
1996
|
-
this.nextStep()
|
|
1997
|
-
].join("\n\n");
|
|
1998
|
-
}
|
|
1999
|
-
/** One of the browser toolset's direct tools, on the active run's browser. */
|
|
2000
|
-
async browserTool(name, input) {
|
|
2001
|
-
const active = this.requireActive();
|
|
2002
|
-
if (await this.stopRequested(active)) return { text: STOPPED, isError: true };
|
|
2003
|
-
const page = await active.driver.context.activePage();
|
|
2004
|
-
const host = page ? URL.parse(await page.url())?.hostname ?? "" : "";
|
|
2005
|
-
const tools = active.guarded && host && !active.trustedHosts.some((t) => hostMatchesDomain(host, t)) ? active.guarded : active.trusted;
|
|
2006
|
-
const result = await tools.run(name, input);
|
|
2007
|
-
const refusal = active.driver.takeRefusal();
|
|
2008
|
-
if (refusal) {
|
|
2009
|
-
const text2 = `Error: Navigation refused. ${refusal}`;
|
|
2010
|
-
await this.log(name, redactInput(name, input), text2);
|
|
2011
|
-
return { text: text2, isError: true };
|
|
2012
|
-
}
|
|
2013
|
-
if (result.screenshotBase64)
|
|
2014
|
-
await this.saveFrame(Buffer.from(result.screenshotBase64, "base64"));
|
|
2015
|
-
await this.log(name, redactInput(name, input), result.text);
|
|
2016
|
-
const image2 = result.screenshotBase64 && (name === "screenshot" || active.everyScreenshot) ? result.screenshotBase64 : void 0;
|
|
2017
|
-
return { text: result.text, ...image2 ? { image: image2 } : {}, isError: Boolean(result.error) };
|
|
2018
|
-
}
|
|
2019
|
-
async evaluateCheck(input) {
|
|
2020
|
-
const active = this.requireActive();
|
|
2021
|
-
if (await this.stopRequested(active)) throw new Error(STOPPED);
|
|
2022
|
-
const { run, checklist } = active;
|
|
2023
|
-
const check = checklist.checks.find((c) => c.key === input.check);
|
|
2024
|
-
if (!check) {
|
|
2025
|
-
throw new Error(
|
|
2026
|
-
`No check "${input.check}" in this run. The keys are: ${checklist.checks.map((c) => c.key).join(", ")}.`
|
|
2027
|
-
);
|
|
2028
|
-
}
|
|
2029
|
-
const earlier = run.results.find((r) => r.check === check.key);
|
|
2030
|
-
if (earlier) {
|
|
2031
|
-
throw new Error(
|
|
2032
|
-
`[${check.key}] already has a verdict (${earlier.status}). Each check gets one.`
|
|
2033
|
-
);
|
|
2034
|
-
}
|
|
2035
|
-
if (check.type === "informational" && (input.status === "passed" || input.status === "failed")) {
|
|
2036
|
-
throw new Error(
|
|
2037
|
-
`[${check.key}] is informational: record what you observed with status "informational".`
|
|
2038
|
-
);
|
|
2039
|
-
}
|
|
2040
|
-
if (check.type !== "informational" && input.status === "informational") {
|
|
2041
|
-
throw new Error(
|
|
2042
|
-
`[${check.key}] is scored: judge it "passed" or "failed", or "blocked" if you could not reach it.`
|
|
2043
|
-
);
|
|
2044
|
-
}
|
|
2045
|
-
const page = await active.driver.context.activePage();
|
|
2046
|
-
const query = cleanLocator(input.locator);
|
|
2047
|
-
const found = page ? await focusEvidence(page, query) : null;
|
|
2048
|
-
if (input.status === "passed" && !found) {
|
|
2049
|
-
throw new Error(
|
|
2050
|
-
query.text || query.selector ? `Not recorded: the locator ${JSON.stringify(query.text ?? query.selector)} is not visible on the current page. Go back to where you saw the evidence, or cite text that is on screen now.` : 'Not recorded: a passed verdict needs a locator, {"text": "..."} visible on the page or {"selector": "..."}.'
|
|
2051
|
-
);
|
|
2052
|
-
}
|
|
2053
|
-
let screenshot;
|
|
2054
|
-
if (page) {
|
|
2055
|
-
try {
|
|
2056
|
-
const png = await page.screenshot({ type: "png", timeout: 1e4 });
|
|
2057
|
-
screenshot = `shots/${check.key}.png`;
|
|
2058
|
-
await writeFileAtomic(join(active.dir, screenshot), png);
|
|
2059
|
-
await this.saveFrame(png);
|
|
2060
|
-
} catch {
|
|
2061
|
-
screenshot = void 0;
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
run.results.push({
|
|
2065
|
-
check: check.key,
|
|
2066
|
-
status: input.status === "blocked" ? "error" : input.status,
|
|
2067
|
-
evidence: input.evidence,
|
|
2068
|
-
...query.text || query.selector ? {
|
|
2069
|
-
locator: {
|
|
2070
|
-
...query.text ? { text: query.text } : {},
|
|
2071
|
-
...query.selector ? { selector: query.selector } : {}
|
|
2072
|
-
}
|
|
2073
|
-
} : {},
|
|
2074
|
-
...screenshot ? { screenshot } : {},
|
|
2075
|
-
at: now()
|
|
2076
|
-
});
|
|
2077
|
-
const statuses = new Map(run.results.map((r) => [r.check, r.status]));
|
|
2078
|
-
const halted = resolveHaltedChecks({
|
|
2079
|
-
checks: checklist.checks.map((c) => ({
|
|
2080
|
-
id: c.key,
|
|
2081
|
-
description: c.description,
|
|
2082
|
-
onFailure: c.on_failure
|
|
2083
|
-
})),
|
|
2084
|
-
statuses
|
|
2085
|
-
});
|
|
2086
|
-
const skipped = halted.length ? halted : this.conditionSkips(statuses);
|
|
2087
|
-
for (const s of skipped) {
|
|
2088
|
-
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
2089
|
-
}
|
|
2090
|
-
await this.save();
|
|
2091
|
-
const recorded = `Recorded [${check.key}] as ${input.status}.`;
|
|
2092
|
-
await this.log("evaluate_check", input, recorded);
|
|
2093
|
-
const notes = skipped.map((s) => `[${s.checkId}] ${s.reason}`);
|
|
2094
|
-
if (halted.length) notes.push("The run has stopped.");
|
|
2095
|
-
return [recorded, ...notes, this.nextStep()].join("\n");
|
|
2096
|
-
}
|
|
2097
|
-
async finishRun(input) {
|
|
2098
|
-
const active = this.requireActive();
|
|
2099
|
-
const stopped = await this.stopRequested(active);
|
|
2100
|
-
const { run, checklist } = active;
|
|
2101
|
-
for (const s of this.conditionSkips(new Map(run.results.map((r) => [r.check, r.status])))) {
|
|
2102
|
-
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
2103
|
-
}
|
|
2104
|
-
for (const check of this.pending()) {
|
|
2105
|
-
run.results.push({
|
|
2106
|
-
check: check.key,
|
|
2107
|
-
status: "error",
|
|
2108
|
-
evidence: NOT_JUDGED,
|
|
2109
|
-
at: now()
|
|
2110
|
-
});
|
|
2111
|
-
}
|
|
2112
|
-
const score = scoreLocalRun(checklist, run.results);
|
|
2113
|
-
run.score = {
|
|
2114
|
-
score: score.score,
|
|
2115
|
-
unit: score.scoreUnit,
|
|
2116
|
-
outcome: score.outcome,
|
|
2117
|
-
pass_threshold: score.passThreshold,
|
|
2118
|
-
grade: score.grade
|
|
2119
|
-
};
|
|
2120
|
-
run.summary = input.summary;
|
|
2121
|
-
run.status = stopped ? "stopped" : "completed";
|
|
2122
|
-
run.finished_at = now();
|
|
2123
|
-
await this.save();
|
|
2124
|
-
await this.log("finish_run", input, `Finished: ${score.outcome}`);
|
|
2125
|
-
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
2126
|
-
await this.endBrowser();
|
|
2127
|
-
const line = (r) => `- [${r.check}] ${r.evidence}`;
|
|
2128
|
-
const failed = run.results.filter((r) => r.status === "failed");
|
|
2129
|
-
const blocked = run.results.filter((r) => r.status === "error");
|
|
2130
|
-
const counts = describeRunScore(score, {
|
|
2131
|
-
passedCount: run.results.filter((r) => r.status === "passed").length,
|
|
2132
|
-
totalCount: checklist.checks.length
|
|
2133
|
-
});
|
|
2134
|
-
return [
|
|
2135
|
-
`Run ${run.id} ${run.status}: ${score.outcome}. ${counts}. Self-judged: the actor recorded its own verdicts.`,
|
|
2136
|
-
failed.length ? `Failed:
|
|
2137
|
-
${failed.map(line).join("\n")}` : "",
|
|
2138
|
-
blocked.length ? `Not judged:
|
|
2139
|
-
${blocked.map(line).join("\n")}` : "",
|
|
2140
|
-
`Report: ${viewer}`
|
|
2141
|
-
].filter(Boolean).join("\n\n");
|
|
2142
|
-
}
|
|
2143
|
-
/**
|
|
2144
|
-
* start_run while this process still has a run open, as when the person
|
|
2145
|
-
* interrupts the actor: resume carries on with it in the same browser.
|
|
2146
|
-
*/
|
|
2147
|
-
async resumeActive(input) {
|
|
2148
|
-
const active = this.requireActive();
|
|
2149
|
-
const { run } = active;
|
|
2150
|
-
if (!input.resume || run.checklist !== input.checklist) {
|
|
2151
|
-
throw new Error(
|
|
2152
|
-
`Run ${run.id} of ${run.checklist} is still going: call start_run with checklist "${run.checklist}" and resume to carry on with it, or finish_run to end it.`
|
|
2153
|
-
);
|
|
2154
|
-
}
|
|
2155
|
-
active.stopped = false;
|
|
2156
|
-
run.status = "running";
|
|
2157
|
-
await this.clearStop(run.id);
|
|
2158
|
-
await this.save();
|
|
2159
|
-
await this.log("start_run", input, "Resumed");
|
|
2160
|
-
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
2161
|
-
return [
|
|
2162
|
-
`Run ${run.id} resumed. Watch it or stop it at ${viewer}`,
|
|
2163
|
-
buildBrief({
|
|
2164
|
-
actor: active.actor,
|
|
2165
|
-
checklist: active.checklist,
|
|
2166
|
-
url: run.url,
|
|
2167
|
-
checks: this.pending(),
|
|
2168
|
-
resumed: true
|
|
2169
|
-
}),
|
|
2170
|
-
"The browser is still open where the run left off: read the page before acting.",
|
|
2171
|
-
this.nextStep()
|
|
2172
|
-
].join("\n\n");
|
|
2173
|
-
}
|
|
2174
|
-
/** On shutdown: an unfinished run is marked interrupted, so it can be resumed. */
|
|
2175
|
-
async close() {
|
|
2176
|
-
if (this.active) {
|
|
2177
|
-
this.active.run.status = this.active.stopped ? "stopped" : "interrupted";
|
|
2178
|
-
await this.save().catch(() => {
|
|
2179
|
-
});
|
|
2180
|
-
await this.endBrowser();
|
|
2181
|
-
}
|
|
2182
|
-
await this.ui?.close();
|
|
2183
|
-
this.ui = null;
|
|
2184
|
-
}
|
|
2185
|
-
requireActive() {
|
|
2186
|
-
if (!this.active) throw new Error("No run is going: call start_run first.");
|
|
2187
|
-
return this.active;
|
|
2188
|
-
}
|
|
2189
|
-
/** Checks with no result yet, in run order. */
|
|
2190
|
-
pending() {
|
|
2191
|
-
const active = this.requireActive();
|
|
2192
|
-
const judged = new Set(active.run.results.map((r) => r.check));
|
|
2193
|
-
return active.checklist.checks.filter((c) => !judged.has(c.key));
|
|
2194
|
-
}
|
|
2195
|
-
conditionSkips(statuses) {
|
|
2196
|
-
return resolveSkippedChecks({
|
|
2197
|
-
checks: this.requireActive().checklist.checks.map((c) => ({
|
|
2198
|
-
id: c.key,
|
|
2199
|
-
description: c.description,
|
|
2200
|
-
runCondition: c.run_condition
|
|
2201
|
-
})),
|
|
2202
|
-
statuses
|
|
2203
|
-
});
|
|
2204
|
-
}
|
|
2205
|
-
nextStep() {
|
|
2206
|
-
const pending = this.pending();
|
|
2207
|
-
if (pending.length === 0)
|
|
2208
|
-
return "Every check has a verdict: call finish_run with a short summary.";
|
|
2209
|
-
const next = pending[0];
|
|
2210
|
-
return `Next: [${next.key}] ${checkLabel(next)} ${next.description} (${pending.length} left).`;
|
|
2211
|
-
}
|
|
2212
|
-
/** The viewer's Stop writes the run id to `.live/stop`; the run notices on its next call. */
|
|
2213
|
-
async stopRequested(active) {
|
|
2214
|
-
if (active.stopped) return true;
|
|
2215
|
-
const flag = await readFile(join(this.root, ".live", "stop"), "utf8").catch(() => "");
|
|
2216
|
-
if (flag.trim() !== active.run.id) return false;
|
|
2217
|
-
active.stopped = true;
|
|
2218
|
-
active.run.status = "stopped";
|
|
2219
|
-
await this.save();
|
|
2220
|
-
await this.log("stop", {}, "Stopped from the viewer");
|
|
2221
|
-
return true;
|
|
2222
|
-
}
|
|
2223
|
-
/** Runs left `running` by a process that is gone, or by this one before a restart. */
|
|
2224
|
-
async markInterrupted() {
|
|
2225
|
-
for (const entry of await readRuns(this.root)) {
|
|
2226
|
-
if (!("run" in entry) || entry.run.status !== "running") continue;
|
|
2227
|
-
if (entry.run.pid !== process.pid && isAlive(entry.run.pid)) continue;
|
|
2228
|
-
await writeJsonAtomic(join(this.root, "runs", entry.id, "run.json"), {
|
|
2229
|
-
...entry.run,
|
|
2230
|
-
status: "interrupted"
|
|
2231
|
-
});
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
async endBrowser() {
|
|
2235
|
-
const active = this.active;
|
|
2236
|
-
if (!active) return;
|
|
2237
|
-
this.active = null;
|
|
2238
|
-
await active.driver.close().catch(() => {
|
|
2239
|
-
});
|
|
2240
|
-
if (active.tempProfile) await rm(active.tempProfile, { recursive: true, force: true });
|
|
2241
|
-
await this.clearStop(active.run.id);
|
|
2242
|
-
}
|
|
2243
|
-
async clearStop(id) {
|
|
2244
|
-
const stopFile = join(this.root, ".live", "stop");
|
|
2245
|
-
const flag = await readFile(stopFile, "utf8").catch(() => "");
|
|
2246
|
-
if (flag.trim() === id) await rm(stopFile, { force: true });
|
|
2247
|
-
}
|
|
2248
|
-
async save() {
|
|
2249
|
-
const active = this.requireActive();
|
|
2250
|
-
await writeJsonAtomic(join(active.dir, "run.json"), active.run);
|
|
2251
|
-
}
|
|
2252
|
-
async log(tool, input, text2) {
|
|
2253
|
-
const active = this.requireActive();
|
|
2254
|
-
await appendJsonl(join(active.dir, "activity.jsonl"), {
|
|
2255
|
-
at: now(),
|
|
2256
|
-
tool,
|
|
2257
|
-
input,
|
|
2258
|
-
text: text2.length > LOG_TEXT_CHARS ? `${text2.slice(0, LOG_TEXT_CHARS)}...` : text2
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
/** The viewer's live frame, best effort. ponytail: one per project, so two sessions running at once share it. */
|
|
2262
|
-
async saveFrame(png) {
|
|
2263
|
-
await writeFileAtomic(join(this.root, ".live", "latest.png"), png).catch(() => {
|
|
2264
|
-
});
|
|
2265
|
-
}
|
|
2266
|
-
};
|
|
2267
|
-
function scoreLocalRun(checklist, results) {
|
|
2268
|
-
const scoring = newChecklistScoring({
|
|
2269
|
-
scoreType: checklist.score_type,
|
|
2270
|
-
scoreUnit: checklist.score_unit,
|
|
2271
|
-
passThreshold: checklist.pass_threshold,
|
|
2272
|
-
scoreBands: checklist.score_bands,
|
|
2273
|
-
checks: checklist.checks.map((c) => ({ checkType: c.type, weight: c.weight }))
|
|
2274
|
-
});
|
|
2275
|
-
return scoreChecklistRun({
|
|
2276
|
-
checks: checklist.checks.map((c) => ({
|
|
2277
|
-
id: c.key,
|
|
2278
|
-
checkType: c.type,
|
|
2279
|
-
weight: c.weight ?? 1,
|
|
2280
|
-
onFailure: c.on_failure
|
|
2281
|
-
})),
|
|
2282
|
-
results: results.map((r) => ({ checkId: r.check, status: r.status })),
|
|
2283
|
-
passThreshold: scoring.pass_threshold,
|
|
2284
|
-
scoreBands: scoring.score_bands,
|
|
2285
|
-
scoreType: scoring.score_type,
|
|
2286
|
-
scoreUnit: scoring.score_unit
|
|
2287
|
-
});
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
// src/mcp.ts
|
|
2291
|
-
var reply = ({ text: text2, image: image2, isError }) => ({
|
|
2292
|
-
content: [
|
|
2293
|
-
{ type: "text", text: text2 },
|
|
2294
|
-
...image2 ? [{ type: "image", data: image2, mimeType: "image/png" }] : []
|
|
2295
|
-
],
|
|
2296
|
-
...isError ? { isError: true } : {}
|
|
2297
|
-
});
|
|
2298
|
-
var queue = Promise.resolve();
|
|
2299
|
-
function attempt(work) {
|
|
2300
|
-
const run = queue.then(async () => {
|
|
2301
|
-
try {
|
|
2302
|
-
const out = await work();
|
|
2303
|
-
return reply(typeof out === "string" ? { text: out } : out);
|
|
2304
|
-
} catch (err) {
|
|
2305
|
-
return reply({ text: err.message, isError: true });
|
|
2306
|
-
}
|
|
2307
|
-
});
|
|
2308
|
-
queue = run;
|
|
2309
|
-
return run;
|
|
2310
|
-
}
|
|
2311
|
-
function shapeOf(spec) {
|
|
2312
|
-
const required = new Set(spec.parameters.required ?? []);
|
|
2313
|
-
return Object.fromEntries(
|
|
2314
|
-
Object.entries(spec.parameters.properties).map(([key, raw]) => {
|
|
2315
|
-
const prop = raw;
|
|
2316
|
-
let schema = prop.enum ? z.enum(prop.enum) : prop.type === "number" ? z.number() : z.string();
|
|
2317
|
-
if (prop.description) schema = schema.describe(prop.description);
|
|
2318
|
-
return [key, required.has(key) ? schema : schema.optional()];
|
|
2319
|
-
})
|
|
2320
|
-
);
|
|
2321
|
-
}
|
|
2322
|
-
function createServer(session) {
|
|
2323
|
-
const server = new McpServer({ name: "stuntdouble", version: PACKAGE.split("@")[1] });
|
|
2324
|
-
for (const spec of DIRECT_TOOL_SPECS.filter((s) => s.name !== "done")) {
|
|
2325
|
-
server.registerTool(
|
|
2326
|
-
spec.name,
|
|
2327
|
-
{ description: spec.description, inputSchema: shapeOf(spec) },
|
|
2328
|
-
(input) => attempt(() => session.browserTool(spec.name, input))
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2331
|
-
server.registerTool(
|
|
2332
|
-
"start_run",
|
|
2333
|
-
{
|
|
2334
|
-
description: "Start a local run of a checklist from .stuntdouble/checklists/<checklist>.json: opens a fresh Chrome at the checklist's URL, and returns the viewer link, the actor's brief and the checks to judge. If a file is invalid, returns each field to fix. With resume, continues the run still open in this session, or else the newest interrupted or stopped run of that checklist, judging only the checks without a verdict.",
|
|
2335
|
-
inputSchema: {
|
|
2336
|
-
checklist: z.string().describe('The checklist slug, e.g. "checkout".'),
|
|
2337
|
-
resume: z.boolean().optional().describe("Continue the last unfinished run instead.")
|
|
2338
|
-
}
|
|
2339
|
-
},
|
|
2340
|
-
(input) => attempt(() => session.startRun(input))
|
|
2341
|
-
);
|
|
2342
|
-
server.registerTool(
|
|
2343
|
-
"evaluate_check",
|
|
2344
|
-
{
|
|
2345
|
-
description: 'Record the verdict on one check, as soon as you have observed it. A passed verdict needs a locator for evidence visible on the page now; a failure may describe something missing. Use "informational" only for informational checks, and "blocked" when you could not reach what the check is about. Saves a screenshot of the evidence and returns the next check.',
|
|
2346
|
-
inputSchema: {
|
|
2347
|
-
check: z.string().describe('The check key, e.g. "guest-checkout".'),
|
|
2348
|
-
status: z.enum(["passed", "failed", "informational", "blocked"]),
|
|
2349
|
-
evidence: z.string().min(1).describe("What you observed, in a sentence or two, quoting the page where you can."),
|
|
2350
|
-
locator: z.object({
|
|
2351
|
-
text: z.string().optional().describe("Distinctive text visible on the page."),
|
|
2352
|
-
selector: z.string().optional().describe("A CSS selector for the element.")
|
|
2353
|
-
}).optional().describe("Where the evidence is on the page. Required for passed.")
|
|
2354
|
-
}
|
|
2355
|
-
},
|
|
2356
|
-
(input) => attempt(() => session.evaluateCheck(input))
|
|
2357
|
-
);
|
|
2358
|
-
server.registerTool(
|
|
2359
|
-
"finish_run",
|
|
2360
|
-
{
|
|
2361
|
-
description: "End the run: settles the checks left, scores it the way the cloud does, writes run.json, closes the browser and returns the outcome. Always call it, even when blocked or stopped.",
|
|
2362
|
-
inputSchema: {
|
|
2363
|
-
summary: z.string().min(1).describe("One or two sentences on what you found.")
|
|
2364
|
-
}
|
|
2365
|
-
},
|
|
2366
|
-
(input) => attempt(() => session.finishRun(input))
|
|
2367
|
-
);
|
|
2368
|
-
server.registerTool(
|
|
2369
|
-
"open_ui",
|
|
2370
|
-
{
|
|
2371
|
-
description: "The local viewer for this project: every run with its verdicts, screenshots and activity, and the checklists and actors. Returns a link to open in a browser."
|
|
2372
|
-
},
|
|
2373
|
-
() => attempt(() => session.openUi())
|
|
2374
|
-
);
|
|
2375
|
-
return server;
|
|
2376
|
-
}
|
|
2377
|
-
async function serveMcp() {
|
|
2378
|
-
const session = new LocalSession({ project: projectDir() });
|
|
2379
|
-
const server = createServer(session);
|
|
2380
|
-
let closing;
|
|
2381
|
-
const shutdown = () => closing ??= session.close().finally(() => process.exit(0));
|
|
2382
|
-
process.stdin.on("close", shutdown);
|
|
2383
|
-
process.on("SIGINT", shutdown);
|
|
2384
|
-
process.on("SIGTERM", shutdown);
|
|
2385
|
-
await server.connect(new StdioServerTransport());
|
|
2386
|
-
}
|
|
2387
1396
|
export {
|
|
2388
|
-
|
|
2389
|
-
|
|
1397
|
+
INDEX_SESSION_SAFETY,
|
|
1398
|
+
hostMatchesDomain,
|
|
1399
|
+
isPrivateHost,
|
|
1400
|
+
redactInput,
|
|
1401
|
+
DIRECT_TOOL_SPECS,
|
|
1402
|
+
DirectBrowserTools
|
|
2390
1403
|
};
|
|
2391
|
-
//# sourceMappingURL=
|
|
1404
|
+
//# sourceMappingURL=chunk-FMZTT2MC.js.map
|