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.
- package/CHANGELOG.md +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/types.js +1 -1
- 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
|
+
}
|