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.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. 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, a number from the second real segment on.
10
- * `index` counts as `path.split("/")` does, so the first real segment of a
11
- * leading-slash path is 1 (see the note on numeric first segments below).
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
- return NUM_RE.test(seg) && index > 1;
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
@@ -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
- function issueMessage(issue) {
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
- /** Validate one flow file's text. Every mistake names the file and the field. */
209
- export function parseFlow(text, file) {
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 = flowSchema.safeParse(json);
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
  /**
@@ -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
- const facts = (f) => ({
206
- tag: f.tagName.toLowerCase(),
207
- type: f.tagName.toLowerCase() === "input" ? String(f.type || "text").toLowerCase() : "",
208
- disabled: f.matches(":disabled"),
209
- readOnly: f.readOnly === true,
210
- visible: visible(f),
211
- filled: typeof f.value === "string" && f.value !== "",
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 as an expression over a node expression, for a page-level evaluate. */
292
- export function formProbeExpression(nodeExpression) {
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
+ }
@@ -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.seconds}s`,
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}`] : []),
@@ -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 UUID, a long hex
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
@@ -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 (`${why} (one-time, about ${APPROX_DISK_MB[target]} MB on disk). Run this once, then attach again:\n` +
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
+ }
@@ -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;