stuntdouble 0.0.0-stage → 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 +202 -0
- package/README.md +57 -2
- package/dist/chunk-JAB46QV7.js +506 -0
- package/dist/chunk-JAB46QV7.js.map +1 -0
- package/dist/chunk-VWTYG7G4.js +238 -0
- package/dist/chunk-VWTYG7G4.js.map +1 -0
- package/dist/cli.js +97 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp-HXJQN2C7.js +2391 -0
- package/dist/mcp-HXJQN2C7.js.map +1 -0
- package/dist/ui-server-AYIR7DTN.js +10 -0
- package/dist/ui-server-AYIR7DTN.js.map +1 -0
- package/dist/ui.html +698 -0
- package/package.json +54 -4
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// src/files.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { appendFile, lstat, mkdir, readdir, readFile, rename, writeFile } from "fs/promises";
|
|
4
|
+
import { dirname, join, sep } from "path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
// src/vendor/scoring/conditions.ts
|
|
8
|
+
var CHECKLIST_RUN_CONDITIONS = [
|
|
9
|
+
"always",
|
|
10
|
+
"if_previous_passed",
|
|
11
|
+
"if_previous_failed"
|
|
12
|
+
];
|
|
13
|
+
function quote(description) {
|
|
14
|
+
const trimmed = description.trim();
|
|
15
|
+
return trimmed ? `"${trimmed}"` : "the check before it";
|
|
16
|
+
}
|
|
17
|
+
function checkRunIsSkipped(check, previous) {
|
|
18
|
+
if (check.runCondition === "always") return null;
|
|
19
|
+
if (!previous) return null;
|
|
20
|
+
const status = previous.status;
|
|
21
|
+
if (status === void 0 || status === "error" || status === "informational") return null;
|
|
22
|
+
if (check.runCondition === "if_previous_passed") {
|
|
23
|
+
if (status === "passed") return null;
|
|
24
|
+
return {
|
|
25
|
+
checkId: check.id,
|
|
26
|
+
reason: status === "skipped" ? `Skipped: this check runs only when ${quote(previous.description)} passes, and that check was skipped too.` : `Skipped: this check runs only when ${quote(previous.description)} passes, and it failed.`
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (status === "failed") return null;
|
|
30
|
+
return {
|
|
31
|
+
checkId: check.id,
|
|
32
|
+
reason: status === "skipped" ? `Skipped: this check runs only when ${quote(previous.description)} fails, and that check was skipped.` : `Skipped: this check runs only when ${quote(previous.description)} fails, and it passed.`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function resolveSkippedChecks(input) {
|
|
36
|
+
const effective = new Map(input.statuses);
|
|
37
|
+
const skipped = [];
|
|
38
|
+
input.checks.forEach((check, index) => {
|
|
39
|
+
if (effective.has(check.id)) return;
|
|
40
|
+
const previousCheck = index > 0 ? input.checks[index - 1] : null;
|
|
41
|
+
const decision = checkRunIsSkipped(
|
|
42
|
+
check,
|
|
43
|
+
previousCheck ? { description: previousCheck.description, status: effective.get(previousCheck.id) } : null
|
|
44
|
+
);
|
|
45
|
+
if (!decision) return;
|
|
46
|
+
skipped.push(decision);
|
|
47
|
+
effective.set(check.id, "skipped");
|
|
48
|
+
});
|
|
49
|
+
return skipped;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/vendor/scoring/failure.ts
|
|
53
|
+
var CHECKLIST_FAILURE_ACTIONS = ["continue", "stop"];
|
|
54
|
+
function isChecklistFailureAction(value) {
|
|
55
|
+
return typeof value === "string" && CHECKLIST_FAILURE_ACTIONS.includes(value);
|
|
56
|
+
}
|
|
57
|
+
function resolveFailureAction(value) {
|
|
58
|
+
return isChecklistFailureAction(value) ? value : "continue";
|
|
59
|
+
}
|
|
60
|
+
function findHaltingCheck(input) {
|
|
61
|
+
return input.checks.find(
|
|
62
|
+
(check) => check.onFailure === "stop" && input.statuses.get(check.id) === "failed"
|
|
63
|
+
) ?? null;
|
|
64
|
+
}
|
|
65
|
+
function quote2(description) {
|
|
66
|
+
const trimmed = description.trim();
|
|
67
|
+
return trimmed ? `"${trimmed}"` : "a required check";
|
|
68
|
+
}
|
|
69
|
+
function haltedReason(halting) {
|
|
70
|
+
return `Skipped: the run stopped when ${quote2(halting.description)} failed.`;
|
|
71
|
+
}
|
|
72
|
+
function resolveHaltedChecks(input) {
|
|
73
|
+
const halting = findHaltingCheck(input);
|
|
74
|
+
if (!halting) return [];
|
|
75
|
+
const reason = haltedReason(halting);
|
|
76
|
+
return input.checks.filter((check) => !input.statuses.has(check.id)).map((check) => ({ checkId: check.id, reason }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/vendor/scoring/scoring.ts
|
|
80
|
+
var CHECKLIST_CHECK_TYPES = ["pass_fail", "weighted", "informational"];
|
|
81
|
+
function isChecklistCheckType(value) {
|
|
82
|
+
return typeof value === "string" && CHECKLIST_CHECK_TYPES.includes(value);
|
|
83
|
+
}
|
|
84
|
+
function resolveCheckType(value) {
|
|
85
|
+
return isChecklistCheckType(value) ? value : "pass_fail";
|
|
86
|
+
}
|
|
87
|
+
var CHECKLIST_SCORE_TYPES = ["percent", "points"];
|
|
88
|
+
function isChecklistScoreType(value) {
|
|
89
|
+
return typeof value === "string" && CHECKLIST_SCORE_TYPES.includes(value);
|
|
90
|
+
}
|
|
91
|
+
function resolveScoreType(value) {
|
|
92
|
+
return isChecklistScoreType(value) ? value : "percent";
|
|
93
|
+
}
|
|
94
|
+
var DEFAULT_SCORE_UNIT = "points";
|
|
95
|
+
var MAX_SCORE_UNIT_LENGTH = 24;
|
|
96
|
+
function resolveScoreUnit(value) {
|
|
97
|
+
if (typeof value !== "string") return null;
|
|
98
|
+
const trimmed = value.trim().slice(0, MAX_SCORE_UNIT_LENGTH);
|
|
99
|
+
return trimmed || null;
|
|
100
|
+
}
|
|
101
|
+
function runScoreUnit(scoreType, scoreUnit) {
|
|
102
|
+
return resolveScoreType(scoreType) === "points" ? resolveScoreUnit(scoreUnit) ?? DEFAULT_SCORE_UNIT : null;
|
|
103
|
+
}
|
|
104
|
+
function formatScoreValue(value, unit) {
|
|
105
|
+
if (unit == null) return `${formatScore(value)}%`;
|
|
106
|
+
const label = Math.abs(value) === 1 && /[^s]s$/i.test(unit) ? unit.slice(0, -1) : unit;
|
|
107
|
+
return `${formatScore(value)} ${label}`;
|
|
108
|
+
}
|
|
109
|
+
var DEFAULT_CHECK_WEIGHT = 1;
|
|
110
|
+
var MIN_CHECK_WEIGHT = -100;
|
|
111
|
+
var MAX_CHECK_WEIGHT = 100;
|
|
112
|
+
function resolveCheckWeight(value) {
|
|
113
|
+
const parsed = typeof value === "string" ? Number(value) : value;
|
|
114
|
+
if (typeof parsed !== "number" || !Number.isFinite(parsed)) return DEFAULT_CHECK_WEIGHT;
|
|
115
|
+
return Math.min(MAX_CHECK_WEIGHT, Math.max(MIN_CHECK_WEIGHT, parsed));
|
|
116
|
+
}
|
|
117
|
+
function checklistPossibleWeight(checks) {
|
|
118
|
+
let total = 0;
|
|
119
|
+
for (const check of checks) {
|
|
120
|
+
if (typeof check === "string" || resolveCheckType(check.checkType) !== "weighted") continue;
|
|
121
|
+
const weight = resolveCheckWeight(check.weight);
|
|
122
|
+
if (weight > 0) total += weight;
|
|
123
|
+
}
|
|
124
|
+
return total;
|
|
125
|
+
}
|
|
126
|
+
var DEFAULT_PASS_THRESHOLD = 100;
|
|
127
|
+
var MAX_POINTS_SCORE = 1e5;
|
|
128
|
+
function scoreCeiling(scoreType) {
|
|
129
|
+
return resolveScoreType(scoreType) === "points" ? MAX_POINTS_SCORE : 100;
|
|
130
|
+
}
|
|
131
|
+
function resolvePassThreshold(value, scoreType) {
|
|
132
|
+
const parsed = typeof value === "string" ? Number(value) : value;
|
|
133
|
+
if (typeof parsed !== "number" || !Number.isFinite(parsed)) return DEFAULT_PASS_THRESHOLD;
|
|
134
|
+
return Math.min(scoreCeiling(scoreType), Math.max(0, parsed));
|
|
135
|
+
}
|
|
136
|
+
function defaultPassThreshold(scoreType, possibleWeight) {
|
|
137
|
+
return resolveScoreType(scoreType) === "points" ? possibleWeight : DEFAULT_PASS_THRESHOLD;
|
|
138
|
+
}
|
|
139
|
+
function newChecklistScoring(input) {
|
|
140
|
+
const scoreType = resolveScoreType(input.scoreType);
|
|
141
|
+
return {
|
|
142
|
+
score_type: scoreType,
|
|
143
|
+
score_unit: resolveScoreUnit(input.scoreUnit),
|
|
144
|
+
pass_threshold: input.passThreshold == null ? resolvePassThreshold(
|
|
145
|
+
defaultPassThreshold(scoreType, checklistPossibleWeight(input.checks)),
|
|
146
|
+
scoreType
|
|
147
|
+
) : resolvePassThreshold(input.passThreshold, scoreType),
|
|
148
|
+
score_bands: resolveScoreBands(input.scoreBands, scoreType)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
var MAX_SCORE_BANDS = 6;
|
|
152
|
+
var MAX_SCORE_BAND_LABEL_LENGTH = 40;
|
|
153
|
+
function resolveScoreBands(value, scoreType) {
|
|
154
|
+
if (!Array.isArray(value)) return null;
|
|
155
|
+
const bands = [];
|
|
156
|
+
const seen = /* @__PURE__ */ new Set();
|
|
157
|
+
for (const entry of value) {
|
|
158
|
+
if (!entry || typeof entry !== "object") continue;
|
|
159
|
+
const { label, min } = entry;
|
|
160
|
+
if (typeof label !== "string") continue;
|
|
161
|
+
const trimmed = label.trim().slice(0, MAX_SCORE_BAND_LABEL_LENGTH);
|
|
162
|
+
if (!trimmed) continue;
|
|
163
|
+
const parsed = typeof min === "string" ? Number(min) : min;
|
|
164
|
+
if (typeof parsed !== "number" || !Number.isFinite(parsed)) continue;
|
|
165
|
+
const clamped = Math.min(scoreCeiling(scoreType), Math.max(0, parsed));
|
|
166
|
+
if (seen.has(clamped)) continue;
|
|
167
|
+
seen.add(clamped);
|
|
168
|
+
bands.push({ label: trimmed, min: clamped });
|
|
169
|
+
if (bands.length === MAX_SCORE_BANDS) break;
|
|
170
|
+
}
|
|
171
|
+
if (bands.length === 0) return null;
|
|
172
|
+
bands.sort((a, b) => b.min - a.min);
|
|
173
|
+
return bands;
|
|
174
|
+
}
|
|
175
|
+
function gradeForScore(bands, score) {
|
|
176
|
+
if (score == null || !bands || bands.length === 0) return null;
|
|
177
|
+
const rank = bands.findIndex((band) => score >= band.min);
|
|
178
|
+
const index = rank === -1 ? bands.length - 1 : rank;
|
|
179
|
+
return { label: bands[index].label, rank: index, count: bands.length };
|
|
180
|
+
}
|
|
181
|
+
function scoreChecklistRun(input) {
|
|
182
|
+
const scoreType = resolveScoreType(input.scoreType);
|
|
183
|
+
const passThreshold = resolvePassThreshold(
|
|
184
|
+
input.passThreshold ?? DEFAULT_PASS_THRESHOLD,
|
|
185
|
+
scoreType
|
|
186
|
+
);
|
|
187
|
+
const scoreBands = resolveScoreBands(input.scoreBands, scoreType);
|
|
188
|
+
const byCheckId = /* @__PURE__ */ new Map();
|
|
189
|
+
for (const result of input.results) {
|
|
190
|
+
if (!byCheckId.has(result.checkId)) byCheckId.set(result.checkId, result.status);
|
|
191
|
+
}
|
|
192
|
+
let earnedWeight = 0;
|
|
193
|
+
let possibleWeight = 0;
|
|
194
|
+
let penaltyWeight = 0;
|
|
195
|
+
let blockingFailures = 0;
|
|
196
|
+
let blockingPasses = 0;
|
|
197
|
+
let inconclusiveCount = 0;
|
|
198
|
+
let informationalCount = 0;
|
|
199
|
+
let skippedCount = 0;
|
|
200
|
+
let weighted = false;
|
|
201
|
+
let weightedErrored = 0;
|
|
202
|
+
let stoppedByCheckId = null;
|
|
203
|
+
for (const check of input.checks) {
|
|
204
|
+
const status = byCheckId.get(check.id) ?? "error";
|
|
205
|
+
if (status === "skipped") {
|
|
206
|
+
skippedCount++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (check.checkType === "informational") {
|
|
210
|
+
if (status !== "error") informationalCount++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const halted = status === "failed" && resolveFailureAction(check.onFailure) === "stop";
|
|
214
|
+
if (halted && !stoppedByCheckId) stoppedByCheckId = check.id;
|
|
215
|
+
if (check.checkType === "weighted") {
|
|
216
|
+
weighted = true;
|
|
217
|
+
if (status === "error" || status === "informational") {
|
|
218
|
+
inconclusiveCount++;
|
|
219
|
+
weightedErrored++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const weight = resolveCheckWeight(check.weight);
|
|
223
|
+
if (weight < 0) {
|
|
224
|
+
if (status === "failed") {
|
|
225
|
+
penaltyWeight += -weight;
|
|
226
|
+
earnedWeight += weight;
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
possibleWeight += weight;
|
|
230
|
+
if (status === "passed") earnedWeight += weight;
|
|
231
|
+
}
|
|
232
|
+
if (halted) blockingFailures++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (status === "passed") blockingPasses++;
|
|
236
|
+
else if (status === "failed") blockingFailures++;
|
|
237
|
+
else inconclusiveCount++;
|
|
238
|
+
}
|
|
239
|
+
const nothingToScore = possibleWeight === 0 && penaltyWeight === 0;
|
|
240
|
+
const score = nothingToScore ? null : scoreType === "points" ? Math.round(earnedWeight * 10) / 10 : possibleWeight > 0 ? Math.max(0, Math.round(earnedWeight / possibleWeight * 1e3) / 10) : 0;
|
|
241
|
+
const nothingJudged = possibleWeight === 0 && penaltyWeight === 0 && blockingPasses === 0 && blockingFailures === 0 && inconclusiveCount > 0;
|
|
242
|
+
const scoreUnreached = weighted && score === null && weightedErrored > 0;
|
|
243
|
+
const outcome = blockingFailures > 0 || score !== null && score < passThreshold ? "failed" : nothingJudged || scoreUnreached ? "inconclusive" : "passed";
|
|
244
|
+
return {
|
|
245
|
+
score,
|
|
246
|
+
scoreUnit: runScoreUnit(scoreType, input.scoreUnit),
|
|
247
|
+
earnedWeight,
|
|
248
|
+
possibleWeight,
|
|
249
|
+
penaltyWeight,
|
|
250
|
+
grade: gradeForScore(scoreBands, score)?.label ?? null,
|
|
251
|
+
outcome,
|
|
252
|
+
blockingFailures,
|
|
253
|
+
blockingPasses,
|
|
254
|
+
inconclusiveCount,
|
|
255
|
+
informationalCount,
|
|
256
|
+
skippedCount,
|
|
257
|
+
weighted,
|
|
258
|
+
passThreshold,
|
|
259
|
+
stoppedByCheckId
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function describeRunScore(score, counts) {
|
|
263
|
+
const scored = counts.totalCount - score.informationalCount - score.skippedCount;
|
|
264
|
+
if (score.weighted && score.score !== null) {
|
|
265
|
+
const graded = score.grade ? ` (${score.grade})` : "";
|
|
266
|
+
const unit = score.scoreUnit;
|
|
267
|
+
const parts = [
|
|
268
|
+
unit == null ? `Scored ${formatScore(score.score)}%${graded} against a ${formatScore(score.passThreshold)}% threshold` : `Scored ${formatScore(score.score)} of ${formatScoreValue(score.possibleWeight, unit)}${graded} against a threshold of ${formatScore(score.passThreshold)}`
|
|
269
|
+
];
|
|
270
|
+
if (score.penaltyWeight > 0) {
|
|
271
|
+
parts.push(`${formatScoreValue(score.penaltyWeight, unit ?? DEFAULT_SCORE_UNIT)} deducted`);
|
|
272
|
+
}
|
|
273
|
+
if (score.blockingFailures > 0) {
|
|
274
|
+
parts.push(
|
|
275
|
+
`${score.blockingFailures} required ${score.blockingFailures === 1 ? "check" : "checks"} failed`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (score.informationalCount > 0) {
|
|
279
|
+
parts.push(`${score.informationalCount} informational`);
|
|
280
|
+
}
|
|
281
|
+
if (score.skippedCount > 0) {
|
|
282
|
+
parts.push(`${score.skippedCount} skipped`);
|
|
283
|
+
}
|
|
284
|
+
return parts.join(", ");
|
|
285
|
+
}
|
|
286
|
+
const base = score.outcome !== "inconclusive" ? `${counts.passedCount}/${scored} checks passed` : score.weighted && score.blockingPasses > 0 ? `No weighted checks could be evaluated, ${score.blockingPasses} required ${score.blockingPasses === 1 ? "check" : "checks"} passed` : "No checks could be evaluated";
|
|
287
|
+
const notes = [];
|
|
288
|
+
if (score.informationalCount > 0) notes.push(`${score.informationalCount} informational`);
|
|
289
|
+
if (score.skippedCount > 0) notes.push(`${score.skippedCount} skipped`);
|
|
290
|
+
return notes.length > 0 ? `${base}, ${notes.join(", ")}` : base;
|
|
291
|
+
}
|
|
292
|
+
function formatScore(value) {
|
|
293
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/files.ts
|
|
297
|
+
var projectDir = () => process.env.STUNTDOUBLE_PROJECT_DIR || process.cwd();
|
|
298
|
+
var sdDir = (project) => join(project, ".stuntdouble");
|
|
299
|
+
var slug = z.string().regex(
|
|
300
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
301
|
+
'Use lowercase letters, digits and hyphens, e.g. "guest-checkout"'
|
|
302
|
+
);
|
|
303
|
+
var cloud = z.record(z.string(), z.unknown()).nullable().optional();
|
|
304
|
+
var ConfigSchema = z.strictObject({
|
|
305
|
+
format: z.literal("stuntdouble/1"),
|
|
306
|
+
/** Local runs swap the checklist URL's origin for this one. */
|
|
307
|
+
local_base_url: z.url({ protocol: /^https?$/ }).nullable().optional(),
|
|
308
|
+
headless: z.boolean().default(false),
|
|
309
|
+
/** Whether screenshots go to the model on every action or only from `screenshot`. */
|
|
310
|
+
model_screenshots: z.enum(["on_request", "every_action"]).default("on_request"),
|
|
311
|
+
/** `third_party`: no paying, signing up or secrets on hosts other than the app under test. */
|
|
312
|
+
safety: z.enum(["third_party", "off"]).default("third_party"),
|
|
313
|
+
cloud
|
|
314
|
+
});
|
|
315
|
+
var ACTOR_INSTRUCTIONS_MAX_LENGTH = 1e3;
|
|
316
|
+
var ACTOR_KNOWLEDGE_TITLE_MAX_LENGTH = 120;
|
|
317
|
+
var ACTOR_KNOWLEDGE_CONTENT_MAX_LENGTH = 8e3;
|
|
318
|
+
var ActorSchema = z.strictObject({
|
|
319
|
+
name: z.string().min(1),
|
|
320
|
+
description: z.string().optional(),
|
|
321
|
+
system_prompt: z.string().max(ACTOR_INSTRUCTIONS_MAX_LENGTH).optional(),
|
|
322
|
+
knowledge: z.array(
|
|
323
|
+
z.strictObject({
|
|
324
|
+
kind: z.enum(["expertise", "process", "reference", "account"]).default("reference"),
|
|
325
|
+
title: z.string().min(1).max(ACTOR_KNOWLEDGE_TITLE_MAX_LENGTH),
|
|
326
|
+
content: z.string().min(1).max(ACTOR_KNOWLEDGE_CONTENT_MAX_LENGTH)
|
|
327
|
+
})
|
|
328
|
+
).default([]),
|
|
329
|
+
/** Keep this actor's cookies between runs, in ~/.stuntdouble/profiles/<actor>. */
|
|
330
|
+
persist_login: z.boolean().default(false),
|
|
331
|
+
cloud
|
|
332
|
+
});
|
|
333
|
+
var CheckSchema = z.strictObject({
|
|
334
|
+
key: slug,
|
|
335
|
+
description: z.string().min(1),
|
|
336
|
+
type: z.enum(CHECKLIST_CHECK_TYPES).default("pass_fail"),
|
|
337
|
+
weight: z.number().min(MIN_CHECK_WEIGHT).max(MAX_CHECK_WEIGHT).optional(),
|
|
338
|
+
run_condition: z.enum(CHECKLIST_RUN_CONDITIONS).default("always"),
|
|
339
|
+
on_failure: z.enum(CHECKLIST_FAILURE_ACTIONS).default("continue")
|
|
340
|
+
});
|
|
341
|
+
var ChecklistSchema = z.strictObject({
|
|
342
|
+
name: z.string().min(1).max(200),
|
|
343
|
+
actor: slug,
|
|
344
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
345
|
+
instructions: z.string().min(1),
|
|
346
|
+
checks: z.array(CheckSchema).min(1).refine((checks) => new Set(checks.map((c) => c.key)).size === checks.length, {
|
|
347
|
+
message: "Every check needs its own key"
|
|
348
|
+
}),
|
|
349
|
+
pass_threshold: z.number().min(0).max(MAX_POINTS_SCORE).optional(),
|
|
350
|
+
score_type: z.enum(CHECKLIST_SCORE_TYPES).default("percent"),
|
|
351
|
+
score_unit: z.string().max(MAX_SCORE_UNIT_LENGTH).nullable().optional(),
|
|
352
|
+
score_bands: z.array(
|
|
353
|
+
z.strictObject({
|
|
354
|
+
label: z.string().min(1).max(MAX_SCORE_BAND_LABEL_LENGTH),
|
|
355
|
+
min: z.number().min(0).max(MAX_POINTS_SCORE)
|
|
356
|
+
})
|
|
357
|
+
).max(MAX_SCORE_BANDS).optional(),
|
|
358
|
+
browser_config: z.strictObject({
|
|
359
|
+
preset: z.enum(["desktop", "tablet", "mobile"]).optional(),
|
|
360
|
+
viewport: z.strictObject({
|
|
361
|
+
width: z.int().min(320).max(2560),
|
|
362
|
+
height: z.int().min(320).max(2560)
|
|
363
|
+
}).optional(),
|
|
364
|
+
touch: z.boolean().optional(),
|
|
365
|
+
locale: z.string().optional()
|
|
366
|
+
}).optional(),
|
|
367
|
+
// Cloud run settings, accepted so the file stays a valid hosted input. Unused locally.
|
|
368
|
+
record_session: z.boolean().optional(),
|
|
369
|
+
run_mode: z.enum(["code", "agentic"]).optional(),
|
|
370
|
+
cloud
|
|
371
|
+
});
|
|
372
|
+
var RUN_STATUSES = ["running", "completed", "stopped", "interrupted", "failed"];
|
|
373
|
+
var RESULT_STATUSES = ["passed", "failed", "informational", "error", "skipped"];
|
|
374
|
+
var LocatorSchema = z.strictObject({
|
|
375
|
+
text: z.string().optional(),
|
|
376
|
+
selector: z.string().optional()
|
|
377
|
+
});
|
|
378
|
+
var ResultSchema = z.object({
|
|
379
|
+
check: z.string(),
|
|
380
|
+
status: z.enum(RESULT_STATUSES),
|
|
381
|
+
evidence: z.string(),
|
|
382
|
+
locator: LocatorSchema.optional(),
|
|
383
|
+
screenshot: z.string().optional(),
|
|
384
|
+
at: z.string().optional()
|
|
385
|
+
});
|
|
386
|
+
var RunSchema = z.object({
|
|
387
|
+
format: z.literal("stuntdouble.run/1"),
|
|
388
|
+
id: z.string(),
|
|
389
|
+
runner: z.object({
|
|
390
|
+
kind: z.enum(["local", "cloud"]),
|
|
391
|
+
package: z.string().optional(),
|
|
392
|
+
/** False for a local run: the actor judged its own verdicts. */
|
|
393
|
+
judged: z.boolean(),
|
|
394
|
+
run_id: z.string().optional(),
|
|
395
|
+
url: z.string().optional()
|
|
396
|
+
}),
|
|
397
|
+
status: z.enum(RUN_STATUSES),
|
|
398
|
+
pid: z.number().optional(),
|
|
399
|
+
url: z.string(),
|
|
400
|
+
started_at: z.string(),
|
|
401
|
+
finished_at: z.string().nullable(),
|
|
402
|
+
checklist: z.string(),
|
|
403
|
+
actor: z.string(),
|
|
404
|
+
checklist_snapshot: z.object({ checks: z.array(CheckSchema) }).loose(),
|
|
405
|
+
results: z.array(ResultSchema),
|
|
406
|
+
score: z.object({
|
|
407
|
+
score: z.number().nullable(),
|
|
408
|
+
outcome: z.enum(["passed", "failed", "inconclusive"]),
|
|
409
|
+
// A local run writes all three; a cloud run written by hand may leave them out.
|
|
410
|
+
unit: z.string().nullish(),
|
|
411
|
+
pass_threshold: z.number().nullish(),
|
|
412
|
+
grade: z.string().nullish()
|
|
413
|
+
}).nullable(),
|
|
414
|
+
summary: z.string().nullable(),
|
|
415
|
+
cloud: z.unknown().nullable()
|
|
416
|
+
});
|
|
417
|
+
function describeIssues(file, error) {
|
|
418
|
+
return error.issues.map((issue) => `${file}: ${issue.path.join(".") || "(top level)"}: ${issue.message}`).join("\n");
|
|
419
|
+
}
|
|
420
|
+
async function readJson(path, schema, label = path) {
|
|
421
|
+
let raw;
|
|
422
|
+
try {
|
|
423
|
+
raw = await readFile(path, "utf8");
|
|
424
|
+
} catch {
|
|
425
|
+
throw new Error(`${label}: file not found`);
|
|
426
|
+
}
|
|
427
|
+
let data;
|
|
428
|
+
try {
|
|
429
|
+
data = JSON.parse(raw);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
throw new Error(`${label}: not valid JSON (${err.message})`, { cause: err });
|
|
432
|
+
}
|
|
433
|
+
const parsed = schema.safeParse(data);
|
|
434
|
+
if (!parsed.success) throw new Error(describeIssues(label, parsed.error));
|
|
435
|
+
return parsed.data;
|
|
436
|
+
}
|
|
437
|
+
async function writeJsonAtomic(path, data) {
|
|
438
|
+
await writeFileAtomic(path, `${JSON.stringify(data, null, 2)}
|
|
439
|
+
`);
|
|
440
|
+
}
|
|
441
|
+
async function writeFileAtomic(path, data) {
|
|
442
|
+
await mkdir(dirname(path), { recursive: true });
|
|
443
|
+
await refuseLinks(path);
|
|
444
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
445
|
+
await writeFile(tmp, data, { mode: 384 });
|
|
446
|
+
await rename(tmp, path);
|
|
447
|
+
}
|
|
448
|
+
async function appendJsonl(path, line) {
|
|
449
|
+
await refuseLinks(path);
|
|
450
|
+
await appendFile(path, `${JSON.stringify(line)}
|
|
451
|
+
`);
|
|
452
|
+
}
|
|
453
|
+
async function refuseLinks(path) {
|
|
454
|
+
const parts = path.split(sep);
|
|
455
|
+
const from = parts.lastIndexOf(".stuntdouble");
|
|
456
|
+
if (from < 0) return;
|
|
457
|
+
for (let i = from + 1; i <= parts.length; i++) {
|
|
458
|
+
const info = await lstat(parts.slice(0, i).join(sep)).catch(() => null);
|
|
459
|
+
if (info?.isSymbolicLink())
|
|
460
|
+
throw new Error(`Refusing to write through a symlink: ${parts.slice(0, i).join(sep)}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function isAlive(pid) {
|
|
464
|
+
if (!pid) return false;
|
|
465
|
+
try {
|
|
466
|
+
process.kill(pid, 0);
|
|
467
|
+
return true;
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return err.code === "EPERM";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
var effectiveStatus = (run) => run.status === "running" && !isAlive(run.pid) ? "interrupted" : run.status;
|
|
473
|
+
async function readRuns(root) {
|
|
474
|
+
const ids = await readdir(join(root, "runs")).catch(() => []);
|
|
475
|
+
const entries = await Promise.all(
|
|
476
|
+
ids.filter((id) => !id.startsWith(".")).map(async (id) => {
|
|
477
|
+
try {
|
|
478
|
+
return { id, run: await readJson(join(root, "runs", id, "run.json"), RunSchema) };
|
|
479
|
+
} catch (err) {
|
|
480
|
+
return { id, error: err.message };
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
return entries.sort((a, b) => a.id < b.id ? 1 : -1);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export {
|
|
488
|
+
resolveSkippedChecks,
|
|
489
|
+
resolveHaltedChecks,
|
|
490
|
+
newChecklistScoring,
|
|
491
|
+
scoreChecklistRun,
|
|
492
|
+
describeRunScore,
|
|
493
|
+
projectDir,
|
|
494
|
+
sdDir,
|
|
495
|
+
ConfigSchema,
|
|
496
|
+
ActorSchema,
|
|
497
|
+
ChecklistSchema,
|
|
498
|
+
readJson,
|
|
499
|
+
writeJsonAtomic,
|
|
500
|
+
writeFileAtomic,
|
|
501
|
+
appendJsonl,
|
|
502
|
+
isAlive,
|
|
503
|
+
effectiveStatus,
|
|
504
|
+
readRuns
|
|
505
|
+
};
|
|
506
|
+
//# sourceMappingURL=chunk-JAB46QV7.js.map
|