staysfixed 0.11.1 → 0.13.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 (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * Making a recording: open the product, follow what a person does, and keep only what repeats.
3
+ *
4
+ * WHY THIS EXISTS AT ALL. Everything else this tool checks it worked out by READING —
5
+ * routes out of the source, exported names out of a package, screens out of a router. That
6
+ * finds what the code SAYS it does, and it is free and exact, and it is blind to one thing:
7
+ * the way a person actually uses the product. The four screens somebody opens every
8
+ * morning, in that order, with that data, are nowhere in the source; the source only knows
9
+ * that those four doors exist, the same as the two hundred nobody has touched since they
10
+ * were written. A recorded session is the only channel that can learn it, and once learned
11
+ * it is checked on every run for ever.
12
+ *
13
+ * WHAT THIS FILE IS NOT. It is not a second walker. The two walks that decide whether a
14
+ * recording is worth keeping go through `walkerFor` in src/v2/check.js — the very same
15
+ * adapters, scratch copies and normalisation a later `staysfixed check` will use — because a
16
+ * recording judged by a simpler walker than the one that will later walk it is a recording
17
+ * accepted on evidence nobody will ever collect again.
18
+ *
19
+ * THE ONE RULE THAT KEEPS THIS HONEST: A RECORDING THAT DOES NOT REPEAT IS REJECTED AT
20
+ * BIRTH. Every session is walked twice against the same build before a single byte of it
21
+ * reaches a file, and anything that differs between those two walks is not a step, it is
22
+ * noise. A recording accepted without that check would inject a flapping journey into every
23
+ * later run, and version 1 already proved where that ends: a flaky check does not get fixed,
24
+ * it gets ignored, and a tool nobody trusts is worse than no tool because somebody believed
25
+ * it once.
26
+ *
27
+ * A NAVIGATION THAT FOLLOWS A CLICK IS NOT A STEP. This is the sharpest trap in the whole
28
+ * feature and it is worth reading twice. A person clicks "Open the orders list" and the
29
+ * browser goes to /orders. Writing both down — click the link, then open /orders — produces
30
+ * a journey that opens /orders whether or not the link works, so somebody breaks the button,
31
+ * the replay walks straight past it to the right page, and the run comes back clean. That is
32
+ * a false all-clear, which is the one answer this tool may never give. So a navigation that
33
+ * arrives right after something the person did is dropped: it is the RESULT of the act, and
34
+ * the act is the step.
35
+ */
36
+
37
+ import fsp from 'node:fs/promises';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+
41
+ import { StaysFixedError, EXIT } from '../../core/errors.js';
42
+ import { say, ok, fail, blank, heading, paint, setLogLevel } from '../../core/log.js';
43
+ import { loadPlaywright, openWindow } from '../adapters/web-driver.js';
44
+ import { webAdapter } from '../adapters/web.js';
45
+ import { settingsFor, walkerFor } from '../check.js';
46
+ import { checkReproducible } from './index.js';
47
+ import { HIDDEN, RECORDINGS_DIR, saveJourneys, stepsFromEvents, whatWillNotReplay } from './record.js';
48
+
49
+ /** @typedef {import('../types.js').Journey} Journey */
50
+ /** @typedef {import('./index.js').GatheredJourney} GatheredJourney */
51
+ /** @typedef {import('../types.js').JourneyStep} JourneyStep */
52
+ /** @typedef {import('../types.js').Capture} Capture */
53
+ /** @typedef {import('./record.js').SessionEvent} SessionEvent */
54
+
55
+ /** How long a recording may run before it stops itself, when nobody says otherwise. */
56
+ export const DEFAULT_RECORD_MS = 10 * 60 * 1000;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Watching a browser
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The script that rides inside the page and reports what somebody did.
64
+ *
65
+ * IT DESCRIBES THINGS BY WHAT THEY MEAN, NOT BY WHERE THEY SIT IN THE MARKUP. A recorded
66
+ * click on `div > div:nth-child(3) > button` breaks the first time anybody moves a wrapper,
67
+ * and then the journey fails for a reason that has nothing to do with the product. A click
68
+ * on "the button called Save" survives every restyle and every rearrangement, and when it
69
+ * DOES stop matching, that is a real fact about the product: the button a person uses every
70
+ * morning is no longer called what it was called. That is the same thing the rest of this
71
+ * tool compares — the roles, names and states a screen reader would read — so a recording
72
+ * and a check disagree about nothing.
73
+ *
74
+ * It reports through a function this tool hands the page. Nothing is stored in the page and
75
+ * nothing is read back out of it: a page that navigates loses everything it was holding, and
76
+ * a recording that loses the first half of itself at the first click is worse than none.
77
+ */
78
+ export const WATCHER_SCRIPT = `(() => {
79
+ if (window.__staysfixedFollowing) return;
80
+ window.__staysfixedFollowing = true;
81
+
82
+ var say = function (event) {
83
+ try { if (window.__staysfixedSaw) window.__staysfixedSaw(event); } catch (e) {}
84
+ };
85
+
86
+ var clean = function (text) {
87
+ return String(text == null ? '' : text).replace(/\\s+/g, ' ').trim().slice(0, 80);
88
+ };
89
+
90
+ var roleOf = function (el) {
91
+ var explicit = el.getAttribute ? el.getAttribute('role') : null;
92
+ if (explicit) return clean(explicit);
93
+ var tag = (el.tagName || '').toLowerCase();
94
+ if (tag === 'a' && el.hasAttribute && el.hasAttribute('href')) return 'link';
95
+ if (tag === 'button') return 'button';
96
+ if (tag === 'select') return 'combobox';
97
+ if (tag === 'textarea') return 'textbox';
98
+ if (tag === 'summary') return 'button';
99
+ if (tag === 'input') {
100
+ var type = String(el.getAttribute('type') || 'text').toLowerCase();
101
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') return 'button';
102
+ if (type === 'checkbox') return 'checkbox';
103
+ if (type === 'radio') return 'radio';
104
+ if (type === 'search') return 'searchbox';
105
+ return 'textbox';
106
+ }
107
+ return '';
108
+ };
109
+
110
+ var labelFor = function (el) {
111
+ try {
112
+ if (!el.id || !document.querySelector) return '';
113
+ var found = document.querySelector('label[for="' + String(el.id).replace(/["\\\\]/g, '\\\\$&') + '"]');
114
+ return found ? clean(found.textContent) : '';
115
+ } catch (e) { return ''; }
116
+ };
117
+
118
+ var nameOf = function (el) {
119
+ var attr = el.getAttribute ? (el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('alt')) : '';
120
+ if (attr) return clean(attr);
121
+ var labelled = labelFor(el);
122
+ if (labelled) return labelled;
123
+ var tag = (el.tagName || '').toLowerCase();
124
+ if (tag === 'input') {
125
+ var type = String(el.getAttribute('type') || 'text').toLowerCase();
126
+ if (type === 'submit' || type === 'button' || type === 'reset') return clean(el.value);
127
+ var placeholder = el.getAttribute('placeholder');
128
+ if (placeholder) return clean(placeholder);
129
+ return clean(el.getAttribute('name') || '');
130
+ }
131
+ return clean(el.innerText || el.textContent || '');
132
+ };
133
+
134
+ var idOf = function (el) {
135
+ var id = el.getAttribute ? el.getAttribute('id') : '';
136
+ return id && /^[A-Za-z][A-Za-z0-9_-]*$/.test(id) ? id : '';
137
+ };
138
+
139
+ // What to aim a replay at, best first. Meaning beats markup; an id beats a guess; a bare
140
+ // tag name is the last resort and it is said out loud in the note so nobody mistakes it
141
+ // for a considered choice.
142
+ var describe = function (el) {
143
+ var role = roleOf(el);
144
+ var name = nameOf(el);
145
+ if (role && name) {
146
+ return {
147
+ target: 'role=' + role + '[name=' + JSON.stringify(name) + ']',
148
+ plain: 'the ' + role + ' called "' + name + '"',
149
+ };
150
+ }
151
+ var id = idOf(el);
152
+ if (id) return { target: '#' + id, plain: 'the thing called #' + id };
153
+ if (name) return { target: 'text=' + JSON.stringify(name), plain: '"' + name + '"' };
154
+ var tag = (el.tagName || 'element').toLowerCase();
155
+ return { target: tag, plain: 'the first ' + tag + ' on the page' };
156
+ };
157
+
158
+ // The thing a person meant to click, not the pixel they hit. Clicking a word inside a
159
+ // button reports the span the word is in, and a journey aimed at that span fails the
160
+ // moment anybody wraps the label differently.
161
+ var acted = function (el) {
162
+ for (var n = 0; el && n < 6; n += 1) {
163
+ var tag = (el.tagName || '').toLowerCase();
164
+ var role = el.getAttribute ? el.getAttribute('role') : null;
165
+ if (tag === 'a' || tag === 'button' || tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'summary') return el;
166
+ if (role === 'button' || role === 'link' || role === 'tab' || role === 'menuitem' || role === 'option') return el;
167
+ el = el.parentElement;
168
+ n += 1;
169
+ }
170
+ return null;
171
+ };
172
+
173
+ document.addEventListener('click', function (e) {
174
+ var el = acted(e.target) || e.target;
175
+ if (!el || !el.tagName) return;
176
+ var said = describe(el);
177
+ say({ act: 'click', target: said.target, note: 'click ' + said.plain });
178
+ }, true);
179
+
180
+ document.addEventListener('change', function (e) {
181
+ var el = e.target;
182
+ if (!el || !el.tagName) return;
183
+ var said = describe(el);
184
+ var type = String((el.getAttribute && el.getAttribute('type')) || '').toLowerCase();
185
+ if (type === 'checkbox' || type === 'radio') {
186
+ say({ act: 'click', target: said.target, note: 'click ' + said.plain });
187
+ return;
188
+ }
189
+ say({ act: 'type', target: said.target, value: String(el.value == null ? '' : el.value), note: 'type into ' + said.plain });
190
+ }, true);
191
+
192
+ document.addEventListener('keydown', function (e) {
193
+ if (e.key !== 'Enter') return;
194
+ var el = e.target;
195
+ if (!el || !el.tagName) return;
196
+ var tag = String(el.tagName).toLowerCase();
197
+ if (tag !== 'input' && tag !== 'textarea') return;
198
+ say({ act: 'press', target: 'Enter', note: 'press Enter' });
199
+ }, true);
200
+ })()`;
201
+
202
+ /**
203
+ * Open the product and write down what somebody does in it.
204
+ *
205
+ * @param {object} opts
206
+ * @param {string} opts.url Where the product is, right now.
207
+ * @param {string} opts.scratchDir A folder this may fill with a browser profile.
208
+ * @param {string} [opts.projectRoot] Which project's copy of Playwright to use.
209
+ * @param {boolean} [opts.headed] Show the window. True for a person, false in a test.
210
+ * @param {number} [opts.forMs] Stop after this long whatever happens.
211
+ * @param {(page: any) => Promise<void>} [opts.drive]
212
+ * Something other than a person's hands. The events it produces are the same real browser
213
+ * events a person's clicks produce, which is what makes a test of this worth anything.
214
+ * @param {(message: string) => void} [opts.log]
215
+ * @param {AbortSignal} [opts.signal]
216
+ * @returns {Promise<{events: SessionEvent[], why: string}>}
217
+ */
218
+ export async function followASession(opts) {
219
+ const playwright = await loadPlaywright({ projectRoot: opts.projectRoot });
220
+ if (!playwright.ok) {
221
+ throw new StaysFixedError(`A session cannot be recorded here: ${playwright.why}`, {
222
+ hint: playwright.howToGet ? `Run: ${playwright.howToGet}` : undefined,
223
+ });
224
+ }
225
+
226
+ /** @type {SessionEvent[]} */
227
+ const events = [];
228
+ const startedAt = Date.now();
229
+ /** @param {SessionEvent} event */
230
+ const saw = (event) => {
231
+ events.push({ ...event, at: Date.now() - startedAt });
232
+ };
233
+
234
+ const window = await openWindow({
235
+ chromium: playwright.chromium,
236
+ executable: playwright.executable,
237
+ scratchDir: opts.scratchDir,
238
+ headed: opts.headed !== false,
239
+ label: 'recording',
240
+ });
241
+
242
+ /** @type {string} */
243
+ let why = 'The window was closed.';
244
+ try {
245
+ await window.context.exposeBinding('__staysfixedSaw', (/** @type {any} */ _source, /** @type {any} */ event) => {
246
+ if (event && typeof event === 'object') saw(/** @type {SessionEvent} */ (event));
247
+ });
248
+ await window.context.addInitScript(WATCHER_SCRIPT);
249
+
250
+ // Every address the browser lands on, whoever asked for it. Which of these survives as a
251
+ // step is decided later, in `webStepsFrom`, and the rule there is the one that keeps a
252
+ // broken button catchable — read the note at the top of this file.
253
+ window.page.on('framenavigated', (/** @type {any} */ frame) => {
254
+ if (frame !== window.page.mainFrame()) return;
255
+ saw({ act: 'navigate', target: String(frame.url()) });
256
+ });
257
+
258
+ await window.page.goto(opts.url, { waitUntil: 'load', timeout: 30000 });
259
+ opts.log?.(`Recording. Do the thing you want checked for ever, then close the window.`);
260
+
261
+ if (opts.drive) {
262
+ await opts.drive(window.page);
263
+ why = 'The session was driven to the end of what it was asked to do.';
264
+ } else {
265
+ why = await waitForTheEnd(window, { forMs: opts.forMs ?? DEFAULT_RECORD_MS, signal: opts.signal });
266
+ }
267
+ // A click that navigates is still settling when the person closes the window, and the
268
+ // address it settled on is the last thing the recording needs. Measured 2026-08-31: without
269
+ // this the final navigation was missed about one run in three on a fast local server.
270
+ await window.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
271
+ } finally {
272
+ await window.close().catch(() => {});
273
+ }
274
+
275
+ return { events, why };
276
+ }
277
+
278
+ /**
279
+ * Wait for the person to finish: they close the window, they press Ctrl-C, or the time runs out.
280
+ *
281
+ * @param {{context: any, page: any}} window
282
+ * @param {{forMs: number, signal?: AbortSignal}} opts
283
+ * @returns {Promise<string>} plain English: what ended it
284
+ */
285
+ function waitForTheEnd(window, opts) {
286
+ return new Promise((resolve) => {
287
+ let done = false;
288
+ /** @param {string} why */
289
+ const finish = (why) => {
290
+ if (done) return;
291
+ done = true;
292
+ clearTimeout(timer);
293
+ resolve(why);
294
+ };
295
+ const timer = setTimeout(
296
+ () => finish(`The recording stopped itself after ${Math.round(opts.forMs / 1000)} seconds, which is as long as one is allowed to run.`),
297
+ opts.forMs,
298
+ );
299
+ // Unref'd on purpose: a recording that ended because the window closed must not hold the
300
+ // process open for the rest of its time budget.
301
+ if (typeof timer.unref === 'function') timer.unref();
302
+ window.page.on('close', () => finish('The page was closed.'));
303
+ window.context.on('close', () => finish('The window was closed.'));
304
+ opts.signal?.addEventListener('abort', () => finish('You stopped the recording.'), { once: true });
305
+ });
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // From what happened to steps somebody can walk
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * How long after an act a navigation still counts as that act's doing.
314
+ *
315
+ * Generous, because the alternative is dangerous in one direction only. Treating a
316
+ * click's own navigation as a step of its own writes a `goto` that walks past a broken
317
+ * button; treating a person's deliberate second address as part of the click loses one
318
+ * step, which shows up immediately as a journey that does not reach where they went.
319
+ */
320
+ const CAUSED_BY_THE_LAST_ACT_MS = 8000;
321
+
322
+ /** Acts that make a page move on their own. A navigation just after one of these is its result. */
323
+ const MAKES_THE_PAGE_MOVE = new Set(['click', 'press', 'type']);
324
+
325
+ /**
326
+ * Turn a watched browser session into steps the web adapter already knows how to walk.
327
+ *
328
+ * The vocabulary is version 1's — `goto`, `click`, `type` with `text`, `press` — and that is
329
+ * deliberate: it is what `runStep` in src/v2/adapters/web-driver.js reads, so a recording
330
+ * needs no new code anywhere on the walking side, and a project that already writes screens
331
+ * by hand can read a recording and recognise every line of it.
332
+ *
333
+ * @param {SessionEvent[]} events
334
+ * @param {{baseUrl?: string}} [opts]
335
+ * @returns {{steps: JourneyStep[], dropped: number, collapsed: number, hidden: number, hiddenWhat: string[], acts: number}}
336
+ */
337
+ export function webStepsFrom(events, opts = {}) {
338
+ const kept = dropNavigationsCausedByAnAct(events);
339
+ // The cleaning is `record.js`'s, unchanged and on purpose: a mouse path is not a journey,
340
+ // ten keystrokes into one box are one thing that happened, and a recorded pause is a timing
341
+ // from one machine that will be wrong on the next one. That reasoning is written down once,
342
+ // where it belongs, and this file does not get a second opinion about it.
343
+ const cleaned = stepsFromEvents(kept);
344
+
345
+ /** @type {JourneyStep[]} */
346
+ const steps = [];
347
+ let acts = 0;
348
+ for (const step of cleaned.steps) {
349
+ const target = typeof step.target === 'string' ? step.target : '';
350
+ if (step.act === 'navigate') {
351
+ const where = addressToWalk(target, opts.baseUrl);
352
+ steps.push({ act: 'open', goto: where, note: `open ${where}` });
353
+ continue;
354
+ }
355
+ if (step.act === 'click') {
356
+ steps.push({ act: 'click', click: target, note: step.note ?? `click ${target}` });
357
+ acts += 1;
358
+ continue;
359
+ }
360
+ if (step.act === 'type') {
361
+ steps.push({ act: 'type', type: target, text: String(step.value ?? ''), note: step.note ?? `type into ${target}` });
362
+ acts += 1;
363
+ continue;
364
+ }
365
+ if (step.act === 'press') {
366
+ steps.push({ act: 'press', press: target || 'Enter', note: step.note ?? 'press Enter' });
367
+ acts += 1;
368
+ continue;
369
+ }
370
+ // Anything else is kept exactly as the cleaner left it. A step this file does not
371
+ // recognise is not a step to throw away silently — the walk says out loud when it meets a
372
+ // word it does not know, and that sentence is worth more than a quiet deletion here.
373
+ steps.push(step);
374
+ }
375
+ return { steps, dropped: cleaned.dropped, collapsed: cleaned.collapsed, hidden: cleaned.hidden, hiddenWhat: cleaned.hiddenWhat, acts };
376
+ }
377
+
378
+ /**
379
+ * Drop every navigation that was somebody's click arriving, and keep the ones they asked for.
380
+ *
381
+ * READ THE NOTE AT THE TOP OF THIS FILE BEFORE CHANGING THIS. A `goto` written down after a
382
+ * click re-opens the page that click was supposed to reach, so a broken button lands on the
383
+ * right page anyway and the check comes back clean about a product that no longer works.
384
+ *
385
+ * @param {SessionEvent[]} events
386
+ * @returns {SessionEvent[]}
387
+ */
388
+ export function dropNavigationsCausedByAnAct(events) {
389
+ /** @type {SessionEvent[]} */
390
+ const kept = [];
391
+ /** @type {SessionEvent|null} */
392
+ let lastAct = null;
393
+ for (const event of events) {
394
+ if (event.act === 'navigate') {
395
+ const since = (event.at ?? 0) - (lastAct?.at ?? 0);
396
+ if (lastAct && since <= CAUSED_BY_THE_LAST_ACT_MS) continue;
397
+ kept.push(event);
398
+ continue;
399
+ }
400
+ if (MAKES_THE_PAGE_MOVE.has(String(event.act))) lastAct = event;
401
+ kept.push(event);
402
+ }
403
+ return kept;
404
+ }
405
+
406
+ /**
407
+ * The address as a journey should keep it: the path, not the whole URL.
408
+ *
409
+ * A recorded `http://127.0.0.1:53119/orders` is a fact about one afternoon — the port is
410
+ * handed out fresh on every boot, so the journey would open nothing on the next run, and
411
+ * `whatWillNotReplay` says so by name. The path survives, and the web adapter puts it back
412
+ * on whichever address the app came up at this time.
413
+ *
414
+ * @param {string} url
415
+ * @param {string} [baseUrl]
416
+ * @returns {string}
417
+ */
418
+ export function addressToWalk(url, baseUrl) {
419
+ try {
420
+ const there = new URL(url);
421
+ if (!baseUrl) return `${there.pathname}${there.search}${there.hash}`;
422
+ const own = new URL(baseUrl);
423
+ if (there.origin !== own.origin) return there.toString();
424
+ return `${there.pathname}${there.search}${there.hash}`;
425
+ } catch {
426
+ return url;
427
+ }
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Does it do the same thing twice?
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /** A walk that met something it could not do. The reason word is what the adapter wrote down. */
435
+ const COULD_NOT_WALK_IT = /\((timed out|crashed|missing tool|refused)\)/;
436
+
437
+ /**
438
+ * @typedef {object} Acceptance
439
+ * @property {boolean} accepted
440
+ * @property {string} how Plain English: what was actually done to decide.
441
+ * @property {string[]} why Why it was refused. Empty when it was accepted.
442
+ * @property {GatheredJourney} journey With `reproducible` filled in when it was accepted.
443
+ */
444
+
445
+ /**
446
+ * Walk a fresh recording twice against the same build, and only then say it may be kept.
447
+ *
448
+ * Three ways a recording fails here, and each one is a real thing that happens:
449
+ * - IT DESCRIBES NOTHING THAT WILL BE TRUE TOMORROW. A port, a one-off id, a timestamp,
450
+ * a path into a temporary folder. Said by `whatWillNotReplay`, before anything is run.
451
+ * - IT COULD NOT BE WALKED EVEN ONCE. A step aimed at something that is not there any
452
+ * more, or was never there under that name. Both walks fail the same way, so the repeat
453
+ * check alone would call that steady — which is why the walks are read for holes as
454
+ * well as compared with each other.
455
+ * - IT ARGUES WITH ITSELF. Two walks of identical bytes disagreed about what exists. That
456
+ * is `checkReproducible`, and it is the same front-door rule every other journey source
457
+ * is held to.
458
+ *
459
+ * @param {object} opts
460
+ * @param {GatheredJourney} opts.journey
461
+ * @param {(req: any) => Promise<Capture>} opts.walk
462
+ * @param {import('../types.js').BuildFingerprint} opts.build
463
+ * @param {(message: string) => void} [opts.log]
464
+ * @returns {Promise<Acceptance>}
465
+ */
466
+ export async function acceptIfItRepeats(opts) {
467
+ /** @type {string[]} */
468
+ const why = [];
469
+
470
+ const willNotReplay = whatWillNotReplay(opts.journey);
471
+ if (willNotReplay.length > 0) {
472
+ return {
473
+ accepted: false,
474
+ how: 'It was read before it was walked, and it holds something that will not mean the same thing tomorrow.',
475
+ why: willNotReplay,
476
+ journey: opts.journey,
477
+ };
478
+ }
479
+
480
+ /** @type {Capture[]} */
481
+ const seen = [];
482
+ opts.log?.('Walking it twice against this build, to prove it does the same thing both times.');
483
+ const verdict = await checkReproducible([opts.journey], {
484
+ build: opts.build,
485
+ walk: async (req) => {
486
+ const capture = await opts.walk(req);
487
+ seen.push(capture);
488
+ return capture;
489
+ },
490
+ log: opts.log,
491
+ });
492
+
493
+ for (const capture of seen) {
494
+ for (const observation of capture.observations) {
495
+ const refusedWhy = observation.meta?.refusedWhy;
496
+ if (typeof refusedWhy === 'string' && COULD_NOT_WALK_IT.test(refusedWhy)) {
497
+ why.push(`Walking it did not get through the steps: ${observation.meta?.describe ?? refusedWhy}`);
498
+ }
499
+ }
500
+ for (const hole of capture.coverage?.gaps ?? []) why.push(`${hole.what} ${hole.why}`);
501
+ }
502
+ for (const rejection of verdict.rejected) why.push(rejection.why);
503
+
504
+ const unique = [...new Set(why)];
505
+ if (unique.length > 0) {
506
+ return {
507
+ accepted: false,
508
+ how: 'It was walked twice against the same build, exactly as a later check would walk it.',
509
+ why: unique,
510
+ journey: opts.journey,
511
+ };
512
+ }
513
+ return {
514
+ accepted: true,
515
+ how: verdict.kept[0]?.reproducible?.how ?? 'It was walked twice against the same build and did the same thing both times.',
516
+ why: [],
517
+ journey: verdict.kept[0] ?? opts.journey,
518
+ };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // The whole thing, end to end
523
+ // ---------------------------------------------------------------------------
524
+
525
+ /**
526
+ * @typedef {object} RecordingResult
527
+ * @property {boolean} accepted
528
+ * @property {string} name
529
+ * @property {string} [file] Where it was written. Absent when it was refused.
530
+ * @property {GatheredJourney} journey
531
+ * @property {string} how What was done to decide, in plain English.
532
+ * @property {string[]} why Why it was refused. Empty when it was kept.
533
+ * @property {string[]} notes Anything worth saying either way.
534
+ * @property {number} events Raw things the browser reported.
535
+ * @property {number} hidden Values taken out because they were secret.
536
+ */
537
+
538
+ /**
539
+ * Record one session against this project's web app, prove it repeats, and write it down.
540
+ *
541
+ * The app is booted the same way a check boots it — the web adapter's own `prepare`, into a
542
+ * scratch copy of the project, on a port nobody else is on. Recording against the copy the
543
+ * person happens to have running would capture whatever state that copy is in, and the first
544
+ * replay in a clean copy would then disagree with it for reasons that are nobody's fault.
545
+ *
546
+ * @param {object} opts
547
+ * @param {string} [opts.cwd]
548
+ * @param {string} opts.name What to call it. Lowercase letters, numbers and dashes.
549
+ * @param {string} [opts.describe] One plain sentence. Worked out from the steps if absent.
550
+ * @param {string} [opts.at] The address to record against, instead of booting.
551
+ * @param {boolean} [opts.headed] Show the window. True unless a test says otherwise.
552
+ * @param {number} [opts.forMs]
553
+ * @param {(page: any) => Promise<void>} [opts.drive]
554
+ * @param {(message: string) => void} [opts.log]
555
+ * @param {AbortSignal} [opts.signal]
556
+ * @returns {Promise<RecordingResult>}
557
+ */
558
+ export async function recordAJourney(opts) {
559
+ const name = slug(opts.name);
560
+ if (name === '') {
561
+ throw new StaysFixedError('A recording needs a name, and it becomes a file name and the head of every address the journey produces.', {
562
+ hint: 'Try: staysfixed record signing-in',
563
+ });
564
+ }
565
+ const log = opts.log ?? (() => {});
566
+ const { root, config } = await settingsFor({ cwd: opts.cwd });
567
+ const webConfig = { ...(config.web ?? {}) };
568
+ if (opts.at) webConfig.url = opts.at;
569
+
570
+ const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-record-'));
571
+ /** @type {string[]} */
572
+ const notes = [];
573
+ /** @type {(() => Promise<void>)[]} */
574
+ const cleanUps = [async () => fsp.rm(scratch, { recursive: true, force: true }).catch(() => {})];
575
+
576
+ try {
577
+ const where = await bootTheProduct({ root, scratch, config: webConfig, log, cleanUps });
578
+ log(`Opening ${where.baseUrl}.`);
579
+ const session = await followASession({
580
+ url: where.baseUrl,
581
+ scratchDir: scratch,
582
+ projectRoot: root,
583
+ headed: opts.headed,
584
+ forMs: opts.forMs,
585
+ drive: opts.drive,
586
+ log,
587
+ signal: opts.signal,
588
+ });
589
+ notes.push(session.why);
590
+
591
+ const built = webStepsFrom(session.events, { baseUrl: where.baseUrl });
592
+ if (built.acts === 0) {
593
+ return {
594
+ accepted: false,
595
+ name,
596
+ journey: journeyFrom({ name, describe: opts.describe, steps: built.steps, built }),
597
+ how: 'The session was watched from the moment the product opened until the window closed.',
598
+ why: [
599
+ 'Nothing was done in it. The product was opened and nothing was clicked, typed or pressed, so there is no journey here — replaying it would only open the front page, which reading the code already does for free.',
600
+ ],
601
+ notes,
602
+ events: session.events.length,
603
+ hidden: built.hidden,
604
+ };
605
+ }
606
+ if (built.hidden > 0) {
607
+ notes.push(
608
+ `${built.hidden} ${built.hidden === 1 ? 'value was' : 'values were'} taken out because ${built.hidden === 1 ? 'it was' : 'they were'} secret (${built.hiddenWhat.join(', ')}). ` +
609
+ `A replay types "${HIDDEN}" into those boxes rather than the real thing, so a recording of a sign-in does not sign in — put the value in your settings and point the step at it instead.`,
610
+ );
611
+ }
612
+
613
+ const journey = journeyFrom({ name, describe: opts.describe, steps: built.steps, built });
614
+ // The same settings the recording was made against, `--at` included, so the two walks
615
+ // that decide its fate open exactly what the person was looking at.
616
+ const walker = await walkerFor({ cwd: root, config: { ...config, web: webConfig } });
617
+ cleanUps.push(walker.close);
618
+ const verdict = await acceptIfItRepeats({
619
+ journey,
620
+ walk: walker.walk,
621
+ build: { id: `recording-${name}`, product: String(config.product ?? path.basename(root)), surface: 'web' },
622
+ log,
623
+ });
624
+
625
+ if (!verdict.accepted) {
626
+ return { accepted: false, name, journey: verdict.journey, how: verdict.how, why: verdict.why, notes, events: session.events.length, hidden: built.hidden };
627
+ }
628
+
629
+ const file = path.join(root, RECORDINGS_DIR, `${name}.json`);
630
+ const written = await saveJourneys(file, [verdict.journey], {
631
+ product: String(config.product ?? path.basename(root)),
632
+ note: 'Recorded sessions. Commit these: they are the promise, not the evidence. `staysfixed check --journeys recorded` walks them.',
633
+ });
634
+ return {
635
+ accepted: true,
636
+ name,
637
+ file: written.file,
638
+ journey: verdict.journey,
639
+ how: verdict.how,
640
+ why: [],
641
+ notes,
642
+ events: session.events.length,
643
+ hidden: built.hidden,
644
+ };
645
+ } finally {
646
+ for (const done of cleanUps.reverse()) await done().catch(() => {});
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Boot this project's web app so there is something to record against.
652
+ *
653
+ * @param {object} opts
654
+ * @param {string} opts.root
655
+ * @param {string} opts.scratch
656
+ * @param {Record<string, any>} opts.config
657
+ * @param {(message: string) => void} opts.log
658
+ * @param {(() => Promise<void>)[]} opts.cleanUps
659
+ * @returns {Promise<{baseUrl: string}>}
660
+ */
661
+ async function bootTheProduct(opts) {
662
+ const address = opts.config.url ?? opts.config.baseUrl ?? null;
663
+ if (!opts.config.start && !address) {
664
+ throw new StaysFixedError('There is nothing to record against: this project has no command that starts its web app and no address it is already running at.', {
665
+ hint: 'Put {"start": "npm run dev"} under "web" in your settings — it should listen on the PORT it is given — or pass --at http://localhost:3000 for something already running.',
666
+ });
667
+ }
668
+ if (!opts.config.start && address) {
669
+ // Recording against an app somebody else started is allowed, and it is worth one line.
670
+ // Nothing here can put that app back the way it was found, so a recording made against it
671
+ // carries whatever state it happened to be in.
672
+ opts.log(`Recording against the app already running at ${address}. Whatever state that app is in is the state this recording will expect to find.`);
673
+ return { baseUrl: String(address) };
674
+ }
675
+
676
+ opts.log('Starting your app in a copy of this project, so nothing you have open is touched.');
677
+ const prepared = await webAdapter.prepare(
678
+ { id: `record-${Date.now().toString(36)}`, label: 'the build you have', role: 'candidate', root: opts.root, gitSha: null },
679
+ {
680
+ scratchDir: path.join(opts.scratch, 'boot'),
681
+ evidenceDir: path.join(opts.scratch, 'evidence'),
682
+ config: opts.config,
683
+ seed: 20260829,
684
+ clock: '2026-08-29T09:00:00.000Z',
685
+ log: opts.log,
686
+ },
687
+ );
688
+ opts.cleanUps.push(async () => prepared.dispose());
689
+ if (!prepared.ready) throw new StaysFixedError(`Your app could not be started, so there is nothing to record against: ${prepared.why}`);
690
+ const baseUrl = prepared.facts?.baseUrl;
691
+ if (typeof baseUrl !== 'string' || baseUrl === '') {
692
+ throw new StaysFixedError('Your app started but never said what address it came up at, so nothing could be opened.');
693
+ }
694
+ return { baseUrl };
695
+ }
696
+
697
+ /**
698
+ * @param {{name: string, describe?: string, steps: JourneyStep[], built: ReturnType<typeof webStepsFrom>}} input
699
+ * @returns {Journey}
700
+ */
701
+ function journeyFrom(input) {
702
+ const doing = input.steps
703
+ .filter((step) => step.act !== 'open')
704
+ .map((step) => String(step.note ?? step.act))
705
+ .slice(0, 4);
706
+ return {
707
+ name: input.name,
708
+ describe: input.describe ?? (doing.length > 0 ? `a recorded session: ${doing.join(', then ')}` : 'a recorded session with nothing in it'),
709
+ source: 'recorded',
710
+ surface: 'web',
711
+ from: 'a session somebody performed',
712
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
713
+ steps: input.steps,
714
+ };
715
+ }
716
+
717
+ /**
718
+ * A name that can be a file, a folder and the head of an address.
719
+ * @param {string} wanted
720
+ * @returns {string}
721
+ */
722
+ export function slug(wanted) {
723
+ return String(wanted ?? '')
724
+ .toLowerCase()
725
+ .replace(/[^a-z0-9]+/g, '-')
726
+ .replace(/^-+|-+$/g, '')
727
+ .slice(0, 60);
728
+ }
729
+
730
+ // ---------------------------------------------------------------------------
731
+ // The command
732
+ // ---------------------------------------------------------------------------
733
+
734
+ /**
735
+ * `staysfixed record`, in the shape src/cli/index.js merges.
736
+ *
737
+ * @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
738
+ */
739
+ export const RECORD_COMMANDS = {
740
+ record: {
741
+ summary: 'Do the thing you care about once, and have it checked for ever.',
742
+ usage: 'staysfixed record <name> [--at <url>] [--describe "<what it does>"] [--for <seconds>] [--json]',
743
+ describe:
744
+ 'Opens your product, follows what you do in it, and writes it down as a journey every\nlater check walks. Close the window when you are done.\n\nThis is the one thing reading your source cannot do. The code says which doors exist;\nit never says which four you open every morning, in which order, with what in the\nboxes. A recording is how the tool learns that, and it only has to be told once.\n\nBefore a recording is kept it is walked TWICE against the same build, and anything\nthat differs between those two walks is thrown away rather than saved — a journey that\nargues with itself would go red for no reason on somebody else\'s laptop, and a check\nnobody trusts is worse than no check at all. If it does not repeat, it is refused and you\nare told why.\n\nPasswords, tokens, card numbers and one-time codes are taken out on the way to the\nfile, and the count of what was hidden is written into it. Recordings belong in git:\nthey are the promise, not the evidence.\n\nToday this records a WEB app, in a browser of the tool\'s own. Checking a recording\nis not limited that way - `--journeys recorded` walks whatever surface the file names -\nbut nothing yet follows your hands around a desktop or a phone, and saying so is better\nthan opening a browser at a product that is not one.',
745
+ options: [
746
+ ['--at <url>', 'Record against something already running at this address instead of starting your app.'],
747
+ ['--describe "<text>"', 'One plain sentence saying what this session does. Worked out from the steps if you leave it out.'],
748
+ ['--for <seconds>', 'Stop recording after this long, whatever happens. Ten minutes by default.'],
749
+ ['--json', 'The whole answer as one JSON object and nothing else. For agents.'],
750
+ ],
751
+ examples: [
752
+ 'staysfixed record signing-in',
753
+ 'staysfixed record the-morning-round --describe "the four screens I open every morning"',
754
+ 'staysfixed record checkout --at http://localhost:3000',
755
+ ],
756
+ spec: { booleans: ['json'], strings: ['at', 'describe', 'for'] },
757
+ load: async () => ({ run }),
758
+ },
759
+ };
760
+
761
+ /**
762
+ * @param {import('../../cli/index.js').CliContext} ctx
763
+ * @returns {Promise<number>}
764
+ */
765
+ export async function run(ctx) {
766
+ const asJson = ctx.bool('json');
767
+ // Nothing meant for a person may reach standard output when an agent asked for JSON. One
768
+ // stray sentence in front of the object is a parse error rather than a warning.
769
+ if (asJson) setLogLevel({ quiet: true });
770
+
771
+ const wanted = ctx.args[0];
772
+ if (!wanted) {
773
+ throw new StaysFixedError('A recording needs a name, so that later runs can say which session found something.', {
774
+ hint: 'Try: staysfixed record signing-in',
775
+ });
776
+ }
777
+
778
+ const stop = new AbortController();
779
+ const onInterrupt = () => stop.abort();
780
+ process.once('SIGINT', onInterrupt);
781
+
782
+ /** @type {RecordingResult} */
783
+ let result;
784
+ try {
785
+ result = await recordAJourney({
786
+ cwd: ctx.cwd,
787
+ name: wanted,
788
+ describe: ctx.str('describe'),
789
+ at: ctx.str('at'),
790
+ forMs: seconds(ctx.str('for')),
791
+ log: (message) => {
792
+ if (!asJson) say(paint.grey(` ${message}`));
793
+ },
794
+ signal: stop.signal,
795
+ });
796
+ } finally {
797
+ process.off('SIGINT', onInterrupt);
798
+ }
799
+
800
+ if (asJson) {
801
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
802
+ return result.accepted ? EXIT.ok : EXIT.error;
803
+ }
804
+
805
+ blank();
806
+ if (!result.accepted) {
807
+ fail(`"${result.name}" was not kept.`);
808
+ say(` ${result.how}`);
809
+ for (const line of result.why) say(paint.grey(` ${line}`));
810
+ blank();
811
+ say('Nothing was written. A recording that does not repeat would go red on somebody else\'s machine for no reason, and this tool would stop being believed.');
812
+ for (const note of result.notes) say(paint.grey(` ${note}`));
813
+ return EXIT.error;
814
+ }
815
+
816
+ ok(`"${result.name}" was recorded and kept.`);
817
+ say(` ${result.journey.describe}`);
818
+ say(paint.grey(` ${result.how}`));
819
+ heading('The steps');
820
+ for (const step of result.journey.steps ?? []) say(` ${step.note ?? step.act}`);
821
+ for (const note of result.notes) say(paint.grey(` ${note}`));
822
+ blank();
823
+ say(`Written to ${result.file}. Commit it: it is the promise, not the evidence.`);
824
+ say('From now on: `staysfixed check --journeys recorded` walks it.');
825
+ return EXIT.ok;
826
+ }
827
+
828
+ /**
829
+ * @param {string|undefined} value
830
+ * @returns {number|undefined}
831
+ */
832
+ function seconds(value) {
833
+ if (value === undefined) return undefined;
834
+ const n = Number(value);
835
+ if (!Number.isFinite(n) || n <= 0) {
836
+ throw new StaysFixedError(`"--for ${value}" is not a number of seconds.`, { hint: 'Try: --for 120' });
837
+ }
838
+ return n * 1000;
839
+ }