scenescout 3.15.0 → 3.17.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/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
|
@@ -4,16 +4,41 @@ const HEX_RE = /^[0-9a-f]{12,}$/i;
|
|
|
4
4
|
const NUM_RE = /^\d+$/;
|
|
5
5
|
/** Query params that name a UI state (a different screen), not transient data. */
|
|
6
6
|
const UI_STATE_PARAM_RE = /^(tab|view|mode|step|panel|section)$/i;
|
|
7
|
+
/** Letters, digits and single `-`/`_` separators: the alphabet a code-shaped record id is written in. */
|
|
8
|
+
const CODE_ALPHABET_RE = /^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$/;
|
|
9
|
+
/** An upper-case prefix, then digits: `WID-2025-001`, `INV2024`, `PO_77`. */
|
|
10
|
+
const CODE_PREFIX_RE = /^[A-Z]{1,6}[-_]?\d[A-Za-z0-9_-]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a segment reads as a code-shaped record id (`WID-2025-001`,
|
|
13
|
+
* `A1B2C3`) rather than a word or a slug. Every record such an id addresses
|
|
14
|
+
* would otherwise be its own route. Words are kept: a slug whose digits are a
|
|
15
|
+
* small part of it (`how-to-2024-guide`, `iphone-15-pro`), a short one with a
|
|
16
|
+
* digit or two (`v2`, `2fa`, `q3`), and anything outside the id alphabet.
|
|
17
|
+
* An upper-case prefix followed by digits is an id with two digits or more;
|
|
18
|
+
* otherwise it takes three digits making up at least half the characters.
|
|
19
|
+
*/
|
|
20
|
+
function isCodeId(seg) {
|
|
21
|
+
if (!CODE_ALPHABET_RE.test(seg))
|
|
22
|
+
return false;
|
|
23
|
+
const digits = (seg.match(/\d/g) ?? []).length;
|
|
24
|
+
if (CODE_PREFIX_RE.test(seg) && digits >= 2)
|
|
25
|
+
return true;
|
|
26
|
+
const chars = seg.replace(/[-_]/g, "").length;
|
|
27
|
+
return digits >= 3 && digits * 2 >= chars;
|
|
28
|
+
}
|
|
7
29
|
/**
|
|
8
30
|
* Whether a path segment is an id that route identity collapses to `:id`: a
|
|
9
|
-
* UUID or long hex string anywhere,
|
|
10
|
-
* `index` counts as `path.split("/")`
|
|
11
|
-
* leading-slash path is 1 (see the note
|
|
31
|
+
* UUID or long hex string anywhere, and from the second real segment on, a
|
|
32
|
+
* number or a code-shaped id (isCodeId). `index` counts as `path.split("/")`
|
|
33
|
+
* does, so the first real segment of a leading-slash path is 1 (see the note
|
|
34
|
+
* on numeric first segments below).
|
|
12
35
|
*/
|
|
13
36
|
export function isIdSegment(seg, index) {
|
|
14
37
|
if (UUID_RE.test(seg) || HEX_RE.test(seg))
|
|
15
38
|
return true;
|
|
16
|
-
|
|
39
|
+
if (index <= 1)
|
|
40
|
+
return false;
|
|
41
|
+
return NUM_RE.test(seg) || isCodeId(seg);
|
|
17
42
|
}
|
|
18
43
|
/**
|
|
19
44
|
* Normalize a URL into a route-class identity:
|
|
@@ -62,6 +87,39 @@ export function normalizePath(rawUrl) {
|
|
|
62
87
|
normalized += `?${kept.sort().join("&")}`;
|
|
63
88
|
return normalized;
|
|
64
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether refs a snapshot took at `from` may still be used at `to`: the same
|
|
92
|
+
* origin and the exact same path (hash-route path included), differing at
|
|
93
|
+
* most in query parameters that are not UI state, or in an in-page fragment.
|
|
94
|
+
* A search box's "Clear" or a filter that rewrites only `?q=` leaves the same
|
|
95
|
+
* screen with the same controls; a tab parameter, another path or an id in the
|
|
96
|
+
* path does not. A record chosen by a query parameter (`?id=6`) does keep them,
|
|
97
|
+
* so the action checks the element it finds (test id and name) before acting:
|
|
98
|
+
* a list that now shows other rows under the same paths cannot hand it another one.
|
|
99
|
+
*/
|
|
100
|
+
export function refsSurviveUrlChange(from, to) {
|
|
101
|
+
if (from === to)
|
|
102
|
+
return true;
|
|
103
|
+
let a;
|
|
104
|
+
let b;
|
|
105
|
+
try {
|
|
106
|
+
a = new URL(from);
|
|
107
|
+
b = new URL(to);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (a.origin !== b.origin || a.pathname !== b.pathname)
|
|
113
|
+
return false;
|
|
114
|
+
const hashPath = (u) => (u.hash.startsWith("#/") ? u.hash.split("?")[0] : "");
|
|
115
|
+
if (hashPath(a) !== hashPath(b))
|
|
116
|
+
return false;
|
|
117
|
+
return normalizePath(from) === normalizePath(to);
|
|
118
|
+
}
|
|
119
|
+
/** A route without its UI-state query: the screen whose tabs are its variants ("/things/:id?tab=history" → "/things/:id"). */
|
|
120
|
+
export function routeBase(route) {
|
|
121
|
+
return route.split("?")[0];
|
|
122
|
+
}
|
|
65
123
|
/**
|
|
66
124
|
* Paths that are not UI pages, however they entered the route list: API
|
|
67
125
|
* endpoints and file downloads.
|
|
@@ -89,6 +147,36 @@ export function elementKey(el) {
|
|
|
89
147
|
const name = el.name.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 60);
|
|
90
148
|
return `${el.role}:${name}`;
|
|
91
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Element keys made unique within one snapshot: the first holder of a base key
|
|
152
|
+
* keeps it, later ones get `~1`, `~2` in document order.
|
|
153
|
+
*/
|
|
154
|
+
export function ordinalKeys(baseKeys) {
|
|
155
|
+
const counts = new Map();
|
|
156
|
+
return baseKeys.map((base) => {
|
|
157
|
+
const count = counts.get(base) ?? 0;
|
|
158
|
+
counts.set(base, count + 1);
|
|
159
|
+
return count === 0 ? base : `${base}~${count}`;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Each key the earlier name rule gave a control that the current rule names
|
|
164
|
+
* differently, as current key → earlier key, from both rules' base keys in
|
|
165
|
+
* document order. Ordinals are worked out under each rule separately, so when
|
|
166
|
+
* one of two unnamed image buttons gains a name, the other's move from `~1`
|
|
167
|
+
* to the bare key is listed too. Only keys in `tracked` are listed: an
|
|
168
|
+
* element coverage does not count needs no alias.
|
|
169
|
+
*/
|
|
170
|
+
export function keyAliases(bases) {
|
|
171
|
+
const now = ordinalKeys(bases.map((b) => b.base));
|
|
172
|
+
const before = ordinalKeys(bases.map((b) => b.prior));
|
|
173
|
+
const out = {};
|
|
174
|
+
bases.forEach((b, i) => {
|
|
175
|
+
if (b.tracked && now[i] !== before[i])
|
|
176
|
+
out[now[i]] = before[i];
|
|
177
|
+
});
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
92
180
|
/**
|
|
93
181
|
* Fingerprint a UI state: normalized route + hash of the set of interactable
|
|
94
182
|
* element classes. Same page with different data → same fingerprint; a page
|
package/dist/engine/flow.js
CHANGED
|
@@ -190,13 +190,14 @@ const flowSchema = z
|
|
|
190
190
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["steps", 0, "action"], message: 'must be "navigate": a flow starts from a page it names' });
|
|
191
191
|
}
|
|
192
192
|
});
|
|
193
|
-
/** `steps[2].target`, the way a person would look for it in the file. */
|
|
194
|
-
function fieldPath(p) {
|
|
193
|
+
/** `steps[2].target`, the way a person would look for it in the file. Shared with the baselines' targets file (baseline.ts). */
|
|
194
|
+
export function fieldPath(p) {
|
|
195
195
|
if (p.length === 0)
|
|
196
196
|
return "(the whole file)";
|
|
197
197
|
return p.map((part, i) => (typeof part === "number" ? `[${part}]` : i === 0 ? part : `.${part}`)).join("");
|
|
198
198
|
}
|
|
199
|
-
|
|
199
|
+
/** One schema problem as a person reads it. Shared with the baselines' targets file (baseline.ts). */
|
|
200
|
+
export function issueMessage(issue) {
|
|
200
201
|
if (issue.code === "unrecognized_keys")
|
|
201
202
|
return `unknown field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
|
|
202
203
|
if (issue.code === "invalid_union_discriminator")
|
|
@@ -205,8 +206,12 @@ function issueMessage(issue) {
|
|
|
205
206
|
return "is required";
|
|
206
207
|
return issue.message;
|
|
207
208
|
}
|
|
208
|
-
/**
|
|
209
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Parse a JSON file a person wrote against its schema. Every mistake names the
|
|
211
|
+
* file and the field, up to five of them. Shared by flows and the baselines'
|
|
212
|
+
* targets file (baseline.ts), so both read their errors the same way.
|
|
213
|
+
*/
|
|
214
|
+
export function parseJsonFile(text, file, schema) {
|
|
210
215
|
let json;
|
|
211
216
|
try {
|
|
212
217
|
json = JSON.parse(text);
|
|
@@ -214,12 +219,19 @@ export function parseFlow(text, file) {
|
|
|
214
219
|
catch (err) {
|
|
215
220
|
return { ok: false, error: `${file}: not valid JSON (${err instanceof Error ? err.message : String(err)})` };
|
|
216
221
|
}
|
|
217
|
-
const parsed =
|
|
222
|
+
const parsed = schema.safeParse(json);
|
|
218
223
|
if (!parsed.success) {
|
|
219
224
|
const issues = parsed.error.issues.slice(0, 5).map((i) => `${fieldPath(i.path)} ${issueMessage(i)}`);
|
|
220
225
|
const more = parsed.error.issues.length > 5 ? ` (and ${parsed.error.issues.length - 5} more)` : "";
|
|
221
226
|
return { ok: false, error: `${file}: ${issues.join("; ")}${more}` };
|
|
222
227
|
}
|
|
228
|
+
return { ok: true, data: parsed.data };
|
|
229
|
+
}
|
|
230
|
+
/** Validate one flow file's text. Every mistake names the file and the field. */
|
|
231
|
+
export function parseFlow(text, file) {
|
|
232
|
+
const parsed = parseJsonFile(text, file, flowSchema);
|
|
233
|
+
if (!parsed.ok)
|
|
234
|
+
return parsed;
|
|
223
235
|
return { ok: true, flow: { name: parsed.data.name ?? file.replace(/\.json$/i, ""), file, steps: parsed.data.steps } };
|
|
224
236
|
}
|
|
225
237
|
/**
|
package/dist/engine/forms.js
CHANGED
|
@@ -124,6 +124,13 @@ export const TEXT_ENTRY_TYPES = new Set([
|
|
|
124
124
|
"week",
|
|
125
125
|
"time",
|
|
126
126
|
]);
|
|
127
|
+
/**
|
|
128
|
+
* Text-entry types an app commonly fills in itself before anyone types (a
|
|
129
|
+
* date or time set to now). Left at that value, such a field counts as
|
|
130
|
+
* blank for the empty submit (FieldFacts.filled). Free text is not here: a
|
|
131
|
+
* pre-filled text field is an edit form holding its record.
|
|
132
|
+
*/
|
|
133
|
+
export const APP_FILLED_TYPES = new Set(["date", "datetime-local", "month", "week", "time"]);
|
|
127
134
|
/**
|
|
128
135
|
* Input types in which Enter submits the form (the HTML standard's implicit
|
|
129
136
|
* submission). Not checkboxes, radios or buttons: Enter does nothing there.
|
|
@@ -202,14 +209,34 @@ export function isNavigationTeardown(message) {
|
|
|
202
209
|
const FORM_HELPERS_SRC = `
|
|
203
210
|
const visible = ${VISIBLE_SRC};
|
|
204
211
|
const xpathOf = ${XPATH_OF_SRC};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// Each field's value when the inventory first read it: what the app put
|
|
213
|
+
// there before the session typed anything. Kept in the page, by element,
|
|
214
|
+
// under a registry symbol the app has no reason to touch.
|
|
215
|
+
const initialKey = Symbol.for("scenescout.form-initial-values");
|
|
216
|
+
const initials = window[initialKey] || (window[initialKey] = new WeakMap());
|
|
217
|
+
// remember: the inventory records a field's first value; the probe at
|
|
218
|
+
// submit time only compares, so a field first met at the submit counts as
|
|
219
|
+
// filled whenever it holds anything. Only date and time fields: an app
|
|
220
|
+
// pre-fills those with "now" on a create form, while a pre-filled text
|
|
221
|
+
// field is an edit form's record, and saving that unchanged is not the
|
|
222
|
+
// empty submit.
|
|
223
|
+
// Held equal to APP_FILLED_TYPES by memory-test; a literal, not a
|
|
224
|
+
// JSON.stringify, so this script is built from constants alone.
|
|
225
|
+
const pickerTypes = ["date", "datetime-local", "month", "week", "time"];
|
|
226
|
+
const facts = (f, remember) => {
|
|
227
|
+
const value = typeof f.value === "string" ? f.value : "";
|
|
228
|
+
const type = f.tagName.toLowerCase() === "input" ? String(f.type || "text").toLowerCase() : "";
|
|
229
|
+
const picker = pickerTypes.includes(type);
|
|
230
|
+
if (picker && remember && !initials.has(f)) initials.set(f, value);
|
|
231
|
+
return {
|
|
232
|
+
tag: f.tagName.toLowerCase(),
|
|
233
|
+
type,
|
|
234
|
+
disabled: f.matches(":disabled"),
|
|
235
|
+
readOnly: f.readOnly === true,
|
|
236
|
+
visible: visible(f),
|
|
237
|
+
filled: value !== "" && !(picker && value === initials.get(f)),
|
|
238
|
+
};
|
|
239
|
+
};
|
|
213
240
|
// The type PROPERTY: a button with no type, or an unknown one, reports "submit".
|
|
214
241
|
const isSubmit = (el) => {
|
|
215
242
|
const tag = el.tagName.toLowerCase();
|
|
@@ -219,11 +246,11 @@ const FORM_HELPERS_SRC = `
|
|
|
219
246
|
// Each DISTINCT set of field facts once. The rules ask whether any field is
|
|
220
247
|
// text entry and whether every one is blank, which repeats do not change,
|
|
221
248
|
// and a form of 2000 identical rows would otherwise ship 2000 objects back.
|
|
222
|
-
const fieldFactsOf = (form) => {
|
|
249
|
+
const fieldFactsOf = (form, remember) => {
|
|
223
250
|
const seen = new Map();
|
|
224
251
|
for (const c of Array.from(form.elements)) {
|
|
225
252
|
if (!/^(input|textarea|select)$/i.test(c.tagName) || c.type === "hidden") continue;
|
|
226
|
-
const f = facts(c);
|
|
253
|
+
const f = facts(c, remember);
|
|
227
254
|
const id = f.tag + "|" + f.type + "|" + f.disabled + "|" + f.readOnly + "|" + f.visible + "|" + f.filled;
|
|
228
255
|
if (!seen.has(id)) seen.set(id, f);
|
|
229
256
|
}
|
|
@@ -258,7 +285,7 @@ export const FORMS_INVENTORY_SCRIPT = `(() => {
|
|
|
258
285
|
out.push({
|
|
259
286
|
attrs: attrsOf(form),
|
|
260
287
|
submit: xpathOf(submits[0]),
|
|
261
|
-
fields: fieldFactsOf(form),
|
|
288
|
+
fields: fieldFactsOf(form, true),
|
|
262
289
|
submitDisabled: submits.every((s) => s.matches(":disabled")),
|
|
263
290
|
});
|
|
264
291
|
}
|
|
@@ -282,16 +309,14 @@ export const FORM_PROBE_BODY = `
|
|
|
282
309
|
submit: xpathOf(submits[0]),
|
|
283
310
|
submitTestid: submits[0].getAttribute("data-testid"),
|
|
284
311
|
submitName: accessibleName(submits[0]),
|
|
285
|
-
fields: fieldFactsOf(form),
|
|
286
|
-
self: facts(node),
|
|
312
|
+
fields: fieldFactsOf(form, false),
|
|
313
|
+
self: facts(node, false),
|
|
287
314
|
selfIsSubmit: isSubmit(node),
|
|
288
315
|
defaultDisabled: submits[0].matches(":disabled"),
|
|
289
316
|
};
|
|
290
317
|
`;
|
|
291
|
-
/** The probe
|
|
292
|
-
export
|
|
293
|
-
return `((node) => {${FORM_PROBE_BODY}})(${nodeExpression})`;
|
|
294
|
-
}
|
|
318
|
+
/** The probe over the focused element, as an expression for a page-level evaluate. */
|
|
319
|
+
export const FORM_PROBE_OF_ACTIVE_ELEMENT = `((node) => {${FORM_PROBE_BODY}})(document.activeElement)`;
|
|
295
320
|
/**
|
|
296
321
|
* The scout_coverage lines for forms no session has submitted empty this
|
|
297
322
|
* run. Empty when there is nothing to say.
|
|
@@ -301,7 +326,145 @@ export function formatNeverSubmittedEmpty(forms) {
|
|
|
301
326
|
return [];
|
|
302
327
|
return [
|
|
303
328
|
"Forms never submitted empty this run (submit each once with every text field blank: a submit that silently does nothing — no request, no message — hides there):",
|
|
304
|
-
...forms.slice(0, 15).map((f) => ` ${f.route} ${f.key}`),
|
|
329
|
+
...forms.slice(0, 15).map((f) => ` ${f.route} ${f.key}${f.seenBy && f.seenBy.length > 0 ? ` (seen by ${f.seenBy.join(", ")})` : ""}`),
|
|
305
330
|
...(forms.length > 15 ? [` … +${forms.length - 15} more`] : []),
|
|
306
331
|
];
|
|
307
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Which option of a dropdown a requested value means, decided before any pick
|
|
335
|
+
* so a value that names none fails at once instead of waiting out the action
|
|
336
|
+
* limit. Tried in order, first tier with a match wins:
|
|
337
|
+
* 1. an option whose value is exactly the request;
|
|
338
|
+
* 2. an option whose label is exactly the request;
|
|
339
|
+
* 3. a value or label equal to it ignoring case and surrounding space;
|
|
340
|
+
* 4. a label that starts with it, ignoring case ("Low" for "Low — minor impact").
|
|
341
|
+
* In tiers 1 and 2 the first match is taken, as the browser takes it. Tiers 3
|
|
342
|
+
* and 4 are guesses, so they must find exactly one option; two or more is
|
|
343
|
+
* refused, naming them. A disabled option is refused: no user can choose it.
|
|
344
|
+
*/
|
|
345
|
+
export function matchOption(options, requested) {
|
|
346
|
+
const indexed = options.map((option, index) => ({ option, index }));
|
|
347
|
+
const fold = (s) => s.trim().toLowerCase();
|
|
348
|
+
const want = fold(requested);
|
|
349
|
+
const tiers = [
|
|
350
|
+
{ hits: indexed.filter(({ option }) => option.value === requested), guess: false },
|
|
351
|
+
{ hits: indexed.filter(({ option }) => option.label === requested), guess: false },
|
|
352
|
+
{ hits: indexed.filter(({ option }) => fold(option.value) === want || fold(option.label) === want), guess: true },
|
|
353
|
+
{ hits: want === "" ? [] : indexed.filter(({ option }) => fold(option.label).startsWith(want)), guess: true },
|
|
354
|
+
];
|
|
355
|
+
for (const { hits, guess } of tiers) {
|
|
356
|
+
if (hits.length === 0)
|
|
357
|
+
continue;
|
|
358
|
+
if (guess && hits.length > 1) {
|
|
359
|
+
return { refused: `${JSON.stringify(requested)} matches more than one option: ${listOptions(hits.map((h) => h.option))}. Pass one of the values.` };
|
|
360
|
+
}
|
|
361
|
+
const hit = hits[0];
|
|
362
|
+
if (hit.option.disabled)
|
|
363
|
+
return { refused: `option ${describeOption(hit.option)} is disabled, so it cannot be chosen.` };
|
|
364
|
+
return hit;
|
|
365
|
+
}
|
|
366
|
+
return { refused: `no option matches ${JSON.stringify(requested)}; options: ${listOptions(options)}.` };
|
|
367
|
+
}
|
|
368
|
+
function describeOption(o) {
|
|
369
|
+
const cap = (t) => (t.length > 60 ? `${t.slice(0, 59)}…` : t);
|
|
370
|
+
const shown = o.value === "" ? '""' : cap(o.value);
|
|
371
|
+
return o.label && o.label !== o.value ? `${shown} (${JSON.stringify(cap(o.label))})` : shown;
|
|
372
|
+
}
|
|
373
|
+
/** The options as a refusal lists them: value, then label where it differs; at most 15. */
|
|
374
|
+
function listOptions(options) {
|
|
375
|
+
if (options.length === 0)
|
|
376
|
+
return "none";
|
|
377
|
+
const shown = options.slice(0, 15).map((o) => describeOption(o) + (o.disabled ? " [disabled]" : ""));
|
|
378
|
+
return shown.join(", ") + (options.length > 15 ? `, … +${options.length - 15} more` : "");
|
|
379
|
+
}
|
|
380
|
+
/** The format each date and time input type takes, with an example, for a refusal. */
|
|
381
|
+
const DATE_FORMATS = {
|
|
382
|
+
date: "YYYY-MM-DD (e.g. 2026-09-20)",
|
|
383
|
+
"datetime-local": "YYYY-MM-DDTHH:MM (e.g. 2026-09-20T10:00)",
|
|
384
|
+
time: "HH:MM or HH:MM:SS, 24-hour (e.g. 14:30)",
|
|
385
|
+
month: "YYYY-MM (e.g. 2026-09)",
|
|
386
|
+
week: "YYYY-Www (e.g. 2026-W38)",
|
|
387
|
+
};
|
|
388
|
+
function realDate(y, m, d) {
|
|
389
|
+
const t = new Date(Date.UTC(y, m - 1, d));
|
|
390
|
+
return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
|
|
391
|
+
}
|
|
392
|
+
function realTime(h, min, sec) {
|
|
393
|
+
return h < 24 && min < 60 && (sec === null || sec < 60);
|
|
394
|
+
}
|
|
395
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
396
|
+
/** ISO 8601 week of a calendar date, as `YYYY-Www`. */
|
|
397
|
+
function isoWeek(y, m, d) {
|
|
398
|
+
const t = new Date(Date.UTC(y, m - 1, d));
|
|
399
|
+
const day = t.getUTCDay() || 7;
|
|
400
|
+
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
401
|
+
const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1);
|
|
402
|
+
const week = Math.ceil(((t.getTime() - yearStart) / 86_400_000 + 1) / 7);
|
|
403
|
+
return `${t.getUTCFullYear()}-W${pad(week)}`;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* A value for a date or time input (`date`, `datetime-local`, `time`, `month`,
|
|
407
|
+
* `week`), in the one format the browser accepts there. Anything else fails
|
|
408
|
+
* the fill with a bare "Malformed value", so an obvious near miss is put in
|
|
409
|
+
* that format — a date into a date-and-time field gets T00:00, a space between
|
|
410
|
+
* date and time becomes "T", a date into a month field keeps its month — and
|
|
411
|
+
* anything else is refused, naming the format. Any other type, and an empty
|
|
412
|
+
* value (which clears the field), pass through unchanged.
|
|
413
|
+
*/
|
|
414
|
+
export function normaliseDateValue(type, text) {
|
|
415
|
+
const format = DATE_FORMATS[type];
|
|
416
|
+
if (!format || text === "")
|
|
417
|
+
return { value: text };
|
|
418
|
+
const v = text.trim();
|
|
419
|
+
const refused = { refused: `a ${type} field takes ${format}; ${JSON.stringify(text)} is not in that form.` };
|
|
420
|
+
const changed = (value) => (value === text ? { value } : { value, note: `entered as ${value}, the form a ${type} field takes` });
|
|
421
|
+
const dateRe = /^(\d{4})-(\d{1,2})-(\d{1,2})/;
|
|
422
|
+
const timeRe = /^(\d{1,2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?$/;
|
|
423
|
+
const timeOf = (s) => {
|
|
424
|
+
const t = timeRe.exec(s);
|
|
425
|
+
if (!t)
|
|
426
|
+
return null;
|
|
427
|
+
const [h, min, sec] = [Number(t[1]), Number(t[2]), t[3] === undefined ? null : Number(t[3])];
|
|
428
|
+
if (!realTime(h, min, sec))
|
|
429
|
+
return null;
|
|
430
|
+
return `${pad(h)}:${pad(min)}${sec === null ? "" : `:${pad(sec)}${t[4] ?? ""}`}`;
|
|
431
|
+
};
|
|
432
|
+
if (type === "time") {
|
|
433
|
+
const t = timeOf(v);
|
|
434
|
+
return t ? changed(t) : refused;
|
|
435
|
+
}
|
|
436
|
+
if (type === "week") {
|
|
437
|
+
const w = /^(\d{4})-[Ww](\d{1,2})$/.exec(v);
|
|
438
|
+
if (w) {
|
|
439
|
+
const week = Number(w[2]);
|
|
440
|
+
return week >= 1 && week <= 53 ? changed(`${w[1]}-W${pad(week)}`) : refused;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (type === "month") {
|
|
444
|
+
const m = /^(\d{4})-(\d{1,2})$/.exec(v);
|
|
445
|
+
if (m)
|
|
446
|
+
return Number(m[2]) >= 1 && Number(m[2]) <= 12 ? changed(`${m[1]}-${pad(Number(m[2]))}`) : refused;
|
|
447
|
+
}
|
|
448
|
+
const d = dateRe.exec(v);
|
|
449
|
+
if (!d)
|
|
450
|
+
return refused;
|
|
451
|
+
const [y, mo, day] = [Number(d[1]), Number(d[2]), Number(d[3])];
|
|
452
|
+
if (!realDate(y, mo, day))
|
|
453
|
+
return refused;
|
|
454
|
+
const date = `${d[1]}-${pad(mo)}-${pad(day)}`;
|
|
455
|
+
const rest = v.slice(d[0].length);
|
|
456
|
+
// What may follow the date: nothing, or a time after "T" or a space.
|
|
457
|
+
const time = rest === "" ? "" : /^[T ]/.test(rest) ? timeOf(rest.slice(1)) : null;
|
|
458
|
+
if (time === null)
|
|
459
|
+
return refused;
|
|
460
|
+
switch (type) {
|
|
461
|
+
case "date":
|
|
462
|
+
return changed(date);
|
|
463
|
+
case "month":
|
|
464
|
+
return changed(date.slice(0, 7));
|
|
465
|
+
case "week":
|
|
466
|
+
return changed(isoWeek(y, mo, day));
|
|
467
|
+
default:
|
|
468
|
+
return changed(`${date}T${time || "00:00"}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
package/dist/engine/journey.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* are table-tested here instead of being reachable only through a browser.
|
|
8
8
|
*/
|
|
9
9
|
import { normalizePath } from "./fingerprint.js";
|
|
10
|
+
import { IDLE_GAP_MS, sayDuration } from "./pace.js";
|
|
10
11
|
/** More distinct screens than this for one task is worth questioning. */
|
|
11
12
|
const MANY_SCREENS = 4;
|
|
12
13
|
/** More interactions than this for one task is worth questioning. */
|
|
@@ -53,12 +54,39 @@ export function measureJourney(log, completed) {
|
|
|
53
54
|
verdict.push(`✓ efficient — direct path, no backtracking, proportionate interaction count`);
|
|
54
55
|
return { interactions, navigations, routeSeq, distinctScreens, backtracks, shortcuts, policyBlocks, refusals, verdict };
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The time a journey took, measured as pace.ts measures a run: the gaps
|
|
59
|
+
* between consecutive moments (the start, each action of this session, the
|
|
60
|
+
* end) summed, leaving out any gap over IDLE_GAP_MS. A journey left open
|
|
61
|
+
* across a long pause between agent turns reported its whole wall-clock
|
|
62
|
+
* span — 23660 s for ten interactions — as the user's time on the task.
|
|
63
|
+
*/
|
|
64
|
+
export function journeyTime(startMs, actionMs, endMs, idleMs = IDLE_GAP_MS) {
|
|
65
|
+
const moments = [startMs, ...actionMs.filter((t) => Number.isFinite(t) && t >= startMs && t <= endMs).sort((a, b) => a - b), endMs];
|
|
66
|
+
let activeMs = 0;
|
|
67
|
+
let idleGaps = 0;
|
|
68
|
+
for (let i = 1; i < moments.length; i++) {
|
|
69
|
+
const gap = moments[i] - moments[i - 1];
|
|
70
|
+
if (gap > idleMs)
|
|
71
|
+
idleGaps += 1;
|
|
72
|
+
else
|
|
73
|
+
activeMs += gap;
|
|
74
|
+
}
|
|
75
|
+
return { activeMs, wallMs: Math.max(0, endMs - startMs), idleGaps };
|
|
76
|
+
}
|
|
77
|
+
/** The time figure a journey's result quotes: active seconds, and what was left out when anything was. */
|
|
78
|
+
export function sayJourneyTime(t) {
|
|
79
|
+
const active = `${Math.round(t.activeMs / 1000)}s`;
|
|
80
|
+
if (t.idleGaps === 0)
|
|
81
|
+
return active;
|
|
82
|
+
return `${active} active (${t.idleGaps} idle gap${t.idleGaps === 1 ? "" : "s"} over ${sayDuration(IDLE_GAP_MS)} excluded; ${sayDuration(t.wallMs)} on the clock)`;
|
|
83
|
+
}
|
|
56
84
|
/** The tool result for a finished journey. */
|
|
57
85
|
export function formatJourney(j, m) {
|
|
58
86
|
return [
|
|
59
87
|
`JOURNEY ${j.completed ? "COMPLETED" : "ABANDONED"} — "${j.goal}"`,
|
|
60
88
|
``,
|
|
61
|
-
`Interaction cost: ${m.interactions} interactions · ${m.navigations} navigations · ${m.distinctScreens} distinct screens · ${j.
|
|
89
|
+
`Interaction cost: ${m.interactions} interactions · ${m.navigations} navigations · ${m.distinctScreens} distinct screens · ${sayJourneyTime(j.time)}`,
|
|
62
90
|
`Path: ${m.routeSeq.slice(0, 12).join(" → ")}${m.routeSeq.length > 12 ? " → …" : ""}`,
|
|
63
91
|
...(m.policyBlocks > 0 || m.refusals > 0 ? [`Policy: ${m.policyBlocks} write-policy blocks, ${m.refusals} refusals (tester safety, not app defects)`] : []),
|
|
64
92
|
...(j.note ? [`Note: ${j.note}`] : []),
|
package/dist/engine/lane.js
CHANGED
|
@@ -39,6 +39,8 @@ export const LANE_CONVENTION_MAX = 160;
|
|
|
39
39
|
export const LANE_NAME_MAX = 40;
|
|
40
40
|
/** A route as the lane saw it, query string included. */
|
|
41
41
|
export const LANE_ROUTE_MAX = 200;
|
|
42
|
+
/** The id scout_finding returned: ten characters today, with room to spare so a longer id never refuses a report. */
|
|
43
|
+
export const LANE_FINDING_MAX = 64;
|
|
42
44
|
export const LANE_SEVERITIES = ["high", "medium", "low"];
|
|
43
45
|
/** The same set scout_finding accepts, so a lane can report every finding it filed. */
|
|
44
46
|
export const LANE_CATEGORIES = FINDING_CATEGORIES;
|
|
@@ -78,6 +80,13 @@ export const LaneDecision = z
|
|
|
78
80
|
*/
|
|
79
81
|
// Any string here: its length is checked only on a worth_a_look (below), because on every other verdict it is ignored.
|
|
80
82
|
convention: z.string().nullable().optional(),
|
|
83
|
+
/**
|
|
84
|
+
* The id scout_finding returned when the lane filed this observation. It
|
|
85
|
+
* ties the decision to its finding exactly, where matching the evidence
|
|
86
|
+
* text misses a lane that reworded it between filing and reporting.
|
|
87
|
+
* Optional, so a reply written before the field existed still parses.
|
|
88
|
+
*/
|
|
89
|
+
finding: z.string().min(1).max(LANE_FINDING_MAX).nullable().optional(),
|
|
81
90
|
})
|
|
82
91
|
.strict()
|
|
83
92
|
.superRefine((d, ctx) => {
|
|
@@ -297,9 +306,10 @@ export function laneReportInstruction(lane) {
|
|
|
297
306
|
const LANE_RUBRIC = [
|
|
298
307
|
"Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine; anything outside it is discarded unread, so put nothing there you want kept.",
|
|
299
308
|
`Shape: {"lane":<your lane name>,"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
|
|
300
|
-
`Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>,"convention":<string or null>}.`,
|
|
309
|
+
`Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>,"convention":<string or null>,"finding":<the id scout_finding returned for it, or null>}.`,
|
|
301
310
|
`A "defect" must carry a severity and a category. "evidence" is a signature, not a sentence: at most ${LANE_EVIDENCE_MAX} characters. "confidence" is how sure you are of the verdict, calibrated: 0.5 means a coin flip, 0.95 means you would bet on it.`,
|
|
302
311
|
`"worth_a_look" is for an observation that is real but is a defect only under a convention of the project you cannot see (a spacing scale, how navigation links are styled, whether controls carry test ids, whether the app targets touch screens): it must name that convention in "convention", one line of at most ${LANE_CONVENTION_MAX} characters, such as "a 4px spacing scale". On every other verdict leave "convention" null or out: it is ignored there. It is not for "I could not tell": that is "unsure".`,
|
|
312
|
+
`"finding" is the id scout_finding gave the observation when you filed it, at most ${LANE_FINDING_MAX} characters: it ties the decision to its finding exactly, so name it on every defect you filed. If scout_finding said your filing was merged into a finding that is a different bug, file it again as it says and name the id it gives then.`,
|
|
303
313
|
`"routes" lists the routes you covered, each at most ${LANE_ROUTE_MAX} characters. "blocked_by" is one line of at most ${LANE_BLOCKED_BY_MAX} characters saying what stopped you: required when the status is "blocked", allowed with "partial", null with "complete"; the detail belongs in a finding. The lane name is at most ${LANE_NAME_MAX} characters. At most ${LANE_MAX_ITEMS} decisions and ${LANE_MAX_ITEMS} routes. Unknown keys are refused.`,
|
|
304
314
|
"The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
|
|
305
315
|
];
|
|
@@ -376,8 +386,8 @@ export function isTemplateSegment(seg) {
|
|
|
376
386
|
/**
|
|
377
387
|
* Whether two paths name the same route. A template segment on either side
|
|
378
388
|
* stands for ONE segment that is an id by route identity's own rule
|
|
379
|
-
* (`isIdSegment`: a number after the first segment, a
|
|
380
|
-
* string), or for another template. Never for a word: `/users/{id}` is not
|
|
389
|
+
* (`isIdSegment`: a number or a code-shaped id after the first segment, a
|
|
390
|
+
* UUID, a long hex string), or for another template. Never for a word: `/users/{id}` is not
|
|
381
391
|
* `/users/me`, `/things/:id` is not `/things/export`, and a path of three stars does
|
|
382
392
|
* not stand for every three-segment path. Same number of segments, every literal
|
|
383
393
|
* equal (case-insensitive), so a template never absorbs a slash or an empty
|
package/dist/engine/launch.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* For anyone who installed from npm and never ran the setup step, the first
|
|
5
5
|
* attach fails because the browser was never downloaded — and Playwright reports
|
|
6
6
|
* that as a multi-line box of text with a path in it. That is the single most
|
|
7
|
-
* likely first-run failure, so it gets one plain instruction instead.
|
|
7
|
+
* likely first-run failure, so it gets one plain instruction instead. Before
|
|
8
|
+
* that, readyBrowser downloads the missing build itself where the setting and
|
|
9
|
+
* the environment allow, so a first attach needs no terminal step.
|
|
8
10
|
*/
|
|
9
|
-
import { APPROX_DISK_MB, launchTarget } from "../browsers.js";
|
|
11
|
+
import { APPROX_DISK_MB, attachDownloadLine, BROWSER_DOWNLOAD_ENV, browserDownloadDecision, launchTarget, } from "../browsers.js";
|
|
10
12
|
/** Playwright's wording when the browser binary is not on disk. */
|
|
11
13
|
const MISSING_BROWSER_RE = /Executable doesn't exist|playwright install|browserType\.launch:.*(not found|ENOENT)/i;
|
|
12
14
|
export function isMissingBrowser(message) {
|
|
@@ -20,13 +22,50 @@ export function explainLaunchFailure(message, reaped, need = { engine: "chromium
|
|
|
20
22
|
const why = target === "chromium" && need.headed
|
|
21
23
|
? "A headed run needs the full Chromium browser, which has not been downloaded (the headless shell alone cannot open a window)"
|
|
22
24
|
: `The ${target} build has not been downloaded yet`;
|
|
23
|
-
return
|
|
24
|
-
` npx -y scenescout install --browser-only --browsers ${target}\n` +
|
|
25
|
-
`(from a clone: node dist/cli.js install --browser-only --browsers ${target}). ` +
|
|
26
|
-
`On Linux, if system libraries are missing: npx playwright install --with-deps ${target}`);
|
|
25
|
+
return `${why} (one-time, about ${APPROX_DISK_MB[target]} MB on disk). ${installByHand(target)}`;
|
|
27
26
|
}
|
|
28
27
|
const firstLine = message.split("\n")[0];
|
|
29
28
|
return (`browser launch failed twice (${firstLine})` +
|
|
30
29
|
(reaped > 0 ? ` — ${reaped} orphaned browser process(es) were reaped between attempts` : "") +
|
|
31
30
|
`. Check disk space, then run \`scenescout doctor\` to verify the setup.`);
|
|
32
31
|
}
|
|
32
|
+
/** The commands that download one build by hand, ending the message that names them. */
|
|
33
|
+
function installByHand(target) {
|
|
34
|
+
return (`Run this once, then attach again:\n` +
|
|
35
|
+
` npx -y scenescout install --browser-only --browsers ${target}\n` +
|
|
36
|
+
`(from a clone: node dist/cli.js install --browser-only --browsers ${target}). ` +
|
|
37
|
+
`On Linux, if system libraries are missing: npx playwright install --with-deps ${target}`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Make sure the build an attach is about to launch is on disk, downloading it
|
|
41
|
+
* the first time when browserDownloadDecision allows. Returns a line for the
|
|
42
|
+
* attach's answer when it downloaded, null when the build was already there,
|
|
43
|
+
* and throws with the command to run by hand when it will not or could not.
|
|
44
|
+
*/
|
|
45
|
+
export async function readyBrowser(need, deps) {
|
|
46
|
+
const target = launchTarget(need.engine, need.headed);
|
|
47
|
+
if ((await deps.present())[target].installed)
|
|
48
|
+
return null;
|
|
49
|
+
const decision = browserDownloadDecision(deps.env);
|
|
50
|
+
if (decision === "refuse") {
|
|
51
|
+
throw new Error(`The ${target} build has not been downloaded, and ${BROWSER_DOWNLOAD_ENV}=off keeps SceneScout from downloading it. ${installByHand(target)}`);
|
|
52
|
+
}
|
|
53
|
+
if (decision === "tell") {
|
|
54
|
+
throw new Error(`The ${target} build has not been downloaded. In CI SceneScout downloads a browser only when asked: add the install step below to the job before the tests, ` +
|
|
55
|
+
`or set ${BROWSER_DOWNLOAD_ENV}=on to let the attach download it. ${installByHand(target)}`);
|
|
56
|
+
}
|
|
57
|
+
const now = deps.now ?? Date.now;
|
|
58
|
+
deps.say(attachDownloadLine(target));
|
|
59
|
+
const began = now();
|
|
60
|
+
const result = await deps.download([target]);
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
throw new Error(`The test browser could not be downloaded${result.detail ? ` (${result.detail})` : ""}. Check the network or proxy, or download it by hand. ${installByHand(target)}`);
|
|
63
|
+
}
|
|
64
|
+
// The installer's exit code is not the build: look again before saying it is there.
|
|
65
|
+
if (!(await deps.present())[target].installed) {
|
|
66
|
+
throw new Error(`The download finished, but the ${target} build is still not where Playwright looks for it. ${installByHand(target)}`);
|
|
67
|
+
}
|
|
68
|
+
const line = `The test browser is ready (${target}, downloaded once in ${Math.round((now() - began) / 1000)} s; later tests start straight away).`;
|
|
69
|
+
deps.say(line);
|
|
70
|
+
return line;
|
|
71
|
+
}
|
package/dist/engine/limits.js
CHANGED
|
@@ -134,6 +134,13 @@ export function limitHint(kind, ms) {
|
|
|
134
134
|
* line; the call log below it is kept. An error that already carries a hint is
|
|
135
135
|
* returned as it is.
|
|
136
136
|
*/
|
|
137
|
+
/** An error's first line, without the driver's "page.goto: " prefix: what a report shows of it. */
|
|
138
|
+
export function firstLineOf(err) {
|
|
139
|
+
return (err instanceof Error ? err.message : String(err))
|
|
140
|
+
.split("\n")[0]
|
|
141
|
+
.replace(/^[a-z]+\.[a-zA-Z]+: /, "")
|
|
142
|
+
.trim();
|
|
143
|
+
}
|
|
137
144
|
export function explainTimeout(err, kind, ms) {
|
|
138
145
|
if (!(err instanceof Error) || !isTimeoutMessage(err.message) || err.message.includes(HINT_MARK))
|
|
139
146
|
return err;
|