staysfixed 0.3.1 → 0.6.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 (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * A real session, frozen into a journey.
3
+ *
4
+ * The code gives you every door. The suite gives you every path somebody already wrote a
5
+ * test for. This file is for the third case and only the third case: something a person or
6
+ * an agent actually did, that neither of the other two can reach — signing in, dragging a
7
+ * pane onto another one, the four steps that reproduce a bug that was reported once.
8
+ *
9
+ * IT IS AN ESCAPE HATCH, NOT A HABIT. A recorded journey is the weakest of the four sources
10
+ * because it is one path somebody happened to take, and because it goes stale the moment the
11
+ * interface it describes moves. Anything reachable from the code or from the suite should
12
+ * come from there instead, and `index.js` sorts them in that order for exactly this reason.
13
+ *
14
+ * WHAT A RECORDING MUST NOT KEEP. A real session contains a real password. It contains the
15
+ * token that came back, the card number somebody typed, the one-time code that arrived on a
16
+ * phone. All of it goes through {@link redact} before it reaches a file, and the count of
17
+ * what was hidden is written into the journey — because a redaction nobody can see is
18
+ * indistinguishable from a recording that never captured anything.
19
+ *
20
+ * WHAT A RECORDING MUST NOT DO. It must not keep a sleep. A recorded "waited 1,400ms" is a
21
+ * timing on one machine on one afternoon, and replaying it is how a journey becomes flaky
22
+ * on somebody else's laptop. Waits are recorded as `settle` — carry on when the thing being
23
+ * watched stops changing — which is the one algorithm in this repository that works on every
24
+ * platform because it only ever needs a picture.
25
+ */
26
+
27
+ import fsp from 'node:fs/promises';
28
+ import path from 'node:path';
29
+
30
+ /** @typedef {import('../types.js').Journey} Journey */
31
+ /** @typedef {import('../types.js').JourneyStep} JourneyStep */
32
+ /** @typedef {import('../types.js').Surface} Surface */
33
+ /** @typedef {import('../types.js').Channel} Channel */
34
+
35
+ /** The shape of a journeys file on disk. Version it, because a format nobody versioned is a format nobody can change. */
36
+ export const JOURNEY_FILE_VERSION = 2;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Keeping secrets out
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Field names whose value never goes into a file. Generous on purpose: hiding one field too
44
+ * many costs a little detail in a report, and hiding one too few writes somebody's password
45
+ * into a repository.
46
+ */
47
+ export const SECRET_NAMES =
48
+ /(pass|pwd|secret|token|api[-_]?key|apikey|auth|bearer|session|cookie|otp|2fa|mfa|pin|cvv|cvc|card|iban|ssn|private[-_]?key|credential)/i;
49
+
50
+ /** Values that are obviously a secret whatever the field is called. */
51
+ const SECRET_VALUES = [
52
+ { pattern: /^eyJ[A-Za-z0-9_-]{10,}\./, what: 'a signed token' },
53
+ { pattern: /^(sk|pk|rk)[-_][A-Za-z0-9]{16,}/, what: 'an API key' },
54
+ { pattern: /^gh[pousr]_[A-Za-z0-9]{20,}/, what: 'a GitHub token' },
55
+ { pattern: /^[A-Fa-f0-9]{40,}$/, what: 'a long hex secret' },
56
+ { pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, what: 'a private key' },
57
+ ];
58
+
59
+ /** What replaces anything hidden. Fixed text, so two recordings of the same flow still match. */
60
+ export const HIDDEN = '<hidden>';
61
+
62
+ /**
63
+ * @typedef {object} Redaction
64
+ * @property {unknown} value The value with anything secret taken out.
65
+ * @property {number} hidden How many values were hidden.
66
+ * @property {string[]} what Plain English, one line per kind of thing hidden.
67
+ */
68
+
69
+ /**
70
+ * Take the secrets out of anything about to be written down.
71
+ *
72
+ * @param {unknown} value
73
+ * @param {{name?: string}} [context] The field name this value sat under, when there is one.
74
+ * @returns {Redaction}
75
+ */
76
+ export function redact(value, context = {}) {
77
+ /** @type {string[]} */
78
+ const what = [];
79
+ let hidden = 0;
80
+
81
+ /**
82
+ * @param {unknown} node
83
+ * @param {string|undefined} name
84
+ * @param {number} depth
85
+ * @returns {unknown}
86
+ */
87
+ const walk = (node, name, depth) => {
88
+ if (depth > 12) return node;
89
+ if (typeof node === 'string') {
90
+ // Already hidden, by the recorder or by an earlier save. Counting it again would
91
+ // report more secrets than there were, and a count nobody can trust is not a count.
92
+ if (node === HIDDEN) return node;
93
+ if (name && SECRET_NAMES.test(name)) {
94
+ hidden++;
95
+ what.push(`the value of "${name}"`);
96
+ return HIDDEN;
97
+ }
98
+ for (const rule of SECRET_VALUES) {
99
+ if (rule.pattern.test(node)) {
100
+ hidden++;
101
+ what.push(rule.what);
102
+ return HIDDEN;
103
+ }
104
+ }
105
+ return node;
106
+ }
107
+ if (Array.isArray(node)) return node.map((item) => walk(item, name, depth + 1));
108
+ if (node && typeof node === 'object') {
109
+ /** @type {Record<string, unknown>} */
110
+ const out = {};
111
+ for (const [key, item] of Object.entries(node)) out[key] = walk(item, key, depth + 1);
112
+ return out;
113
+ }
114
+ if (name && SECRET_NAMES.test(name) && node !== null && node !== undefined) {
115
+ hidden++;
116
+ what.push(`the value of "${name}"`);
117
+ return HIDDEN;
118
+ }
119
+ return node;
120
+ };
121
+
122
+ return { value: walk(value, context.name, 0), hidden, what: [...new Set(what)] };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Noise, and what to do with it
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Acts that are somebody's hand moving, not somebody doing something. A recorded session is
131
+ * mostly these, and keeping them turns a four-step journey into four hundred steps that
132
+ * describe a mouse.
133
+ */
134
+ const JUST_MOVEMENT = new Set(['move', 'mousemove', 'hover', 'scroll', 'focus', 'blur', 'resize']);
135
+
136
+ /** Acts where the last one wins: ten keystrokes into one box are one thing that happened. */
137
+ const COLLAPSES = new Set(['type', 'fill', 'set', 'select', 'input']);
138
+
139
+ /**
140
+ * One thing that happened, as a driver reports it. Deliberately loose — every driver has its
141
+ * own vocabulary, and this file's job is to take whichever one it is given.
142
+ *
143
+ * @typedef {object} SessionEvent
144
+ * @property {string} act 'click', 'type', 'navigate', 'run', 'invoke', 'wait'…
145
+ * @property {string} [target] What it happened to, said the way a person would:
146
+ * 'the Save button', not a CSS selector.
147
+ * @property {unknown} [value]
148
+ * @property {number} [at] Milliseconds since the recording started.
149
+ * @property {string} [note]
150
+ * @property {Record<string, unknown>} [detail]
151
+ */
152
+
153
+ /**
154
+ * Turn what a driver saw into steps worth replaying.
155
+ *
156
+ * Three rules, and each one exists because of a specific way recorded journeys go bad:
157
+ * movement is dropped, because a mouse path is not a journey; consecutive typing into the
158
+ * same place is collapsed to the final value, because a replay of keystrokes is a replay of
159
+ * a person's speed; and every wait becomes `settle`, because a recorded sleep is a timing
160
+ * from one machine that will be wrong on the next one.
161
+ *
162
+ * @param {SessionEvent[]} events
163
+ * @returns {{steps: JourneyStep[], dropped: number, collapsed: number, hidden: number, hiddenWhat: string[]}}
164
+ */
165
+ export function stepsFromEvents(events) {
166
+ /** @type {JourneyStep[]} */
167
+ const steps = [];
168
+ let dropped = 0;
169
+ let collapsed = 0;
170
+ let hidden = 0;
171
+ /** @type {Set<string>} */
172
+ const hiddenWhat = new Set();
173
+
174
+ for (const event of events) {
175
+ const act = String(event.act ?? '').trim();
176
+ if (act === '') { dropped++; continue; }
177
+ if (JUST_MOVEMENT.has(act)) { dropped++; continue; }
178
+
179
+ if (act === 'wait' || act === 'sleep' || act === 'pause') {
180
+ const previous = steps[steps.length - 1];
181
+ if (previous?.act === 'settle') { dropped++; continue; }
182
+ steps.push({ act: 'settle', note: 'Carry on when it stops changing. The recording had a pause here; the pause itself is not kept.' });
183
+ continue;
184
+ }
185
+
186
+ /** @type {JourneyStep} */
187
+ const step = { act };
188
+ if (event.target !== undefined) step.target = event.target;
189
+ if (event.note !== undefined) step.note = event.note;
190
+ if (event.detail !== undefined) {
191
+ const cleaned = redact(event.detail);
192
+ hidden += cleaned.hidden;
193
+ for (const item of cleaned.what) hiddenWhat.add(item);
194
+ step.detail = cleaned.value;
195
+ }
196
+ if (event.value !== undefined) {
197
+ const cleaned = redact(event.value, { name: typeof event.target === 'string' ? event.target : undefined });
198
+ hidden += cleaned.hidden;
199
+ for (const item of cleaned.what) hiddenWhat.add(item);
200
+ step.value = cleaned.value;
201
+ }
202
+
203
+ const previous = steps[steps.length - 1];
204
+ if (previous && COLLAPSES.has(act) && previous.act === act && previous.target === step.target) {
205
+ steps[steps.length - 1] = step;
206
+ collapsed++;
207
+ continue;
208
+ }
209
+ if (previous && previous.act === act && previous.target === step.target && sameJson(previous.value, step.value)) {
210
+ dropped++;
211
+ continue;
212
+ }
213
+ steps.push(step);
214
+ }
215
+
216
+ return { steps, dropped, collapsed, hidden, hiddenWhat: [...hiddenWhat] };
217
+ }
218
+
219
+ /**
220
+ * @param {unknown} a
221
+ * @param {unknown} b
222
+ */
223
+ function sameJson(a, b) {
224
+ if (a === b) return true;
225
+ try {
226
+ return JSON.stringify(a) === JSON.stringify(b);
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Recording
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * @typedef {object} Recorder
238
+ * @property {(act: string, detail?: Omit<SessionEvent, 'act'>) => Recorder} did
239
+ * Write down one thing that happened. Chains, so a driver can call it inline.
240
+ * @property {(text: string) => Recorder} note A sentence for whoever reads the journey.
241
+ * @property {() => number} count Events recorded so far, before cleaning.
242
+ * @property {(finish?: {describe?: string, irreversible?: boolean}) => RecordedJourney} stop
243
+ */
244
+
245
+ /**
246
+ * A journey with the story of how it was recorded attached.
247
+ *
248
+ * @typedef {Journey & {recorded: RecordingNotes}} RecordedJourney
249
+ */
250
+
251
+ /**
252
+ * @typedef {object} RecordingNotes
253
+ * @property {string} at ISO time the recording finished.
254
+ * @property {number} events Raw events the driver reported.
255
+ * @property {number} kept Steps that survived cleaning.
256
+ * @property {number} dropped
257
+ * @property {number} collapsed
258
+ * @property {number} hidden Values taken out because they were secret.
259
+ * @property {string[]} hiddenWhat What kind of thing was hidden, in plain English.
260
+ * @property {string} [by] Who or what was driving: a person, an agent, a script.
261
+ */
262
+
263
+ /**
264
+ * Start recording a session.
265
+ *
266
+ * @param {object} spec
267
+ * @param {string} spec.name File-safe. It becomes a folder name.
268
+ * @param {string} [spec.describe] One plain sentence. Filled in at `stop` if not here.
269
+ * @param {Surface} [spec.surface]
270
+ * @param {Channel[]} [spec.channels]
271
+ * @param {string} [spec.by] 'a person', 'the agent', 'the pairing script'.
272
+ * @returns {Recorder}
273
+ */
274
+ export function startRecording(spec) {
275
+ /** @type {SessionEvent[]} */
276
+ const events = [];
277
+ const startedAt = Date.now();
278
+ /** @type {string[]} */
279
+ const notes = [];
280
+
281
+ /** @type {Recorder} */
282
+ const recorder = {
283
+ did(act, detail = {}) {
284
+ events.push({ ...detail, act, at: Date.now() - startedAt });
285
+ return recorder;
286
+ },
287
+ note(text) {
288
+ notes.push(text);
289
+ return recorder;
290
+ },
291
+ count() {
292
+ return events.length;
293
+ },
294
+ stop(finish = {}) {
295
+ const cleaned = stepsFromEvents(events);
296
+ const describe =
297
+ finish.describe ??
298
+ spec.describe ??
299
+ (cleaned.steps.length > 0
300
+ ? `a recorded session: ${cleaned.steps.map((s) => s.act).slice(0, 4).join(', then ')}`
301
+ : 'a recorded session with nothing in it');
302
+ /** @type {RecordedJourney} */
303
+ const journey = {
304
+ name: spec.name,
305
+ describe,
306
+ source: 'recorded',
307
+ surface: spec.surface ?? 'library',
308
+ from: spec.by ? `a session driven by ${spec.by}` : 'a recorded session',
309
+ channels: spec.channels ?? ['meaning', 'results', 'complaints', 'effects'],
310
+ steps: cleaned.steps,
311
+ recorded: {
312
+ at: new Date().toISOString(),
313
+ events: events.length,
314
+ kept: cleaned.steps.length,
315
+ dropped: cleaned.dropped,
316
+ collapsed: cleaned.collapsed,
317
+ hidden: cleaned.hidden,
318
+ hiddenWhat: cleaned.hiddenWhat,
319
+ by: spec.by,
320
+ },
321
+ };
322
+ if (finish.irreversible) journey.irreversible = true;
323
+ if (notes.length > 0) journey.steps?.push({ act: 'note', note: notes.join(' ') });
324
+ return journey;
325
+ },
326
+ };
327
+ return recorder;
328
+ }
329
+
330
+ /**
331
+ * Record a session somebody drives inside a function, and get the journey back.
332
+ *
333
+ * @param {Parameters<typeof startRecording>[0]} spec
334
+ * @param {(recorder: Recorder) => Promise<void>|void} drive
335
+ * @returns {Promise<RecordedJourney>}
336
+ */
337
+ export async function recordSession(spec, drive) {
338
+ const recorder = startRecording(spec);
339
+ try {
340
+ await drive(recorder);
341
+ } catch (error) {
342
+ recorder.note(`The session stopped early: ${error instanceof Error ? error.message : String(error)}`);
343
+ }
344
+ return recorder.stop();
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Files
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * @typedef {object} JourneyFile
353
+ * @property {number} staysfixed Format version.
354
+ * @property {'journeys'} kind
355
+ * @property {string} savedAt
356
+ * @property {string} [product]
357
+ * @property {string} [note]
358
+ * @property {Journey[]} journeys
359
+ */
360
+
361
+ /**
362
+ * Write journeys to a file, with the secrets taken out on the way.
363
+ *
364
+ * The redaction happens HERE as well as while recording, because a journey can be built by
365
+ * hand, edited afterwards, or produced by an agent that exploring a gap — and every one of
366
+ * those routes ends at this function.
367
+ *
368
+ * @param {string} file
369
+ * @param {Journey[]} journeys
370
+ * @param {{product?: string, note?: string}} [meta]
371
+ * @returns {Promise<{file: string, journeys: number, hidden: number}>}
372
+ */
373
+ export async function saveJourneys(file, journeys, meta = {}) {
374
+ const cleaned = redact(journeys);
375
+ /** @type {JourneyFile} */
376
+ const payload = {
377
+ staysfixed: JOURNEY_FILE_VERSION,
378
+ kind: 'journeys',
379
+ savedAt: new Date().toISOString(),
380
+ product: meta.product,
381
+ note: meta.note,
382
+ journeys: /** @type {Journey[]} */ (cleaned.value),
383
+ };
384
+ await fsp.mkdir(path.dirname(path.resolve(file)), { recursive: true });
385
+ await fsp.writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
386
+ return { file, journeys: payload.journeys.length, hidden: cleaned.hidden };
387
+ }
388
+
389
+ /**
390
+ * Read journeys back, and say plainly what is wrong with them rather than throwing.
391
+ *
392
+ * @param {string} file
393
+ * @returns {Promise<{journeys: Journey[], problems: string[], savedAt?: string}>}
394
+ */
395
+ export async function loadJourneys(file) {
396
+ /** @type {string} */
397
+ let text;
398
+ try {
399
+ text = await fsp.readFile(file, 'utf8');
400
+ } catch (error) {
401
+ return { journeys: [], problems: [`${file} could not be opened: ${error instanceof Error ? error.message : String(error)}`] };
402
+ }
403
+ /** @type {any} */
404
+ let parsed;
405
+ try {
406
+ parsed = JSON.parse(text);
407
+ } catch (error) {
408
+ return { journeys: [], problems: [`${file} is not readable as JSON: ${error instanceof Error ? error.message : String(error)}`] };
409
+ }
410
+
411
+ /** @type {string[]} */
412
+ const problems = [];
413
+ const list = Array.isArray(parsed) ? parsed : parsed?.journeys;
414
+ if (!Array.isArray(list)) {
415
+ return { journeys: [], problems: [`${file} does not contain a list of journeys.`] };
416
+ }
417
+ if (!Array.isArray(parsed) && parsed.staysfixed !== JOURNEY_FILE_VERSION) {
418
+ problems.push(
419
+ `${file} was written by a different version of this format (${parsed.staysfixed ?? 'none'}, this is ${JOURNEY_FILE_VERSION}). It was read anyway.`,
420
+ );
421
+ }
422
+
423
+ /** @type {Journey[]} */
424
+ const journeys = [];
425
+ list.forEach((/** @type {any} */ journey, index) => {
426
+ if (!journey || typeof journey !== 'object') {
427
+ problems.push(`Journey ${index + 1} in ${file} is not an object.`);
428
+ return;
429
+ }
430
+ if (typeof journey.name !== 'string' || journey.name.trim() === '') {
431
+ problems.push(`Journey ${index + 1} in ${file} has no name.`);
432
+ return;
433
+ }
434
+ if (!journey.source) journey.source = 'recorded';
435
+ if (!journey.describe) journey.describe = `the recorded journey "${journey.name}"`;
436
+ if (!journey.from) journey.from = file;
437
+ journeys.push(/** @type {Journey} */ (journey));
438
+ });
439
+
440
+ return { journeys, problems, savedAt: typeof parsed?.savedAt === 'string' ? parsed.savedAt : undefined };
441
+ }
442
+
443
+ /**
444
+ * Read every journeys file in a folder. Missing folder is not a problem — most projects
445
+ * have never recorded anything, and that is a normal state, not an error.
446
+ *
447
+ * @param {string} dir
448
+ * @returns {Promise<{journeys: Journey[], problems: string[], files: string[]}>}
449
+ */
450
+ export async function loadJourneyFolder(dir) {
451
+ /** @type {string[]} */
452
+ let entries;
453
+ try {
454
+ entries = await fsp.readdir(dir);
455
+ } catch {
456
+ return { journeys: [], problems: [], files: [] };
457
+ }
458
+ /** @type {Journey[]} */
459
+ const journeys = [];
460
+ /** @type {string[]} */
461
+ const problems = [];
462
+ /** @type {string[]} */
463
+ const files = [];
464
+ for (const entry of entries.sort()) {
465
+ if (!entry.endsWith('.json')) continue;
466
+ const full = path.join(dir, entry);
467
+ const read = await loadJourneys(full);
468
+ journeys.push(...read.journeys);
469
+ problems.push(...read.problems);
470
+ if (read.journeys.length > 0) files.push(full);
471
+ }
472
+ return { journeys, problems, files };
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Is this thing replayable?
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /** Things inside a recorded step that will not mean the same thing tomorrow. */
480
+ const WONT_REPLAY = [
481
+ { pattern: /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/, why: 'it has a date and time in it, which will not be the same tomorrow' },
482
+ { pattern: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'it has a one-off id in it, which will be a different id next time' },
483
+ { pattern: /:\d{4,5}\b/, why: 'it has a port number in it, which the next run may not get' },
484
+ { pattern: /\/(var\/folders|tmp)\//, why: 'it points into a temporary folder that will not exist next time' },
485
+ ];
486
+
487
+ /**
488
+ * What would stop this recorded journey being replayable, in plain English.
489
+ *
490
+ * Recorded journeys rot, and they rot quietly: the ids and timestamps captured on the
491
+ * afternoon somebody made the recording go stale, and the replay then fails for a reason
492
+ * that has nothing to do with the product. Saying it here, once, at the point the journey is
493
+ * saved, is much cheaper than finding out inside a failing check three weeks later.
494
+ *
495
+ * @param {Journey} journey
496
+ * @returns {string[]}
497
+ */
498
+ export function whatWillNotReplay(journey) {
499
+ /** @type {string[]} */
500
+ const problems = [];
501
+ const steps = journey.steps ?? [];
502
+ if (steps.length === 0) problems.push('It has no steps, so replaying it would do nothing.');
503
+ for (const [index, step] of steps.entries()) {
504
+ let text;
505
+ try {
506
+ text = JSON.stringify(step);
507
+ } catch {
508
+ problems.push(`Step ${index + 1} cannot be written down, so it cannot be replayed.`);
509
+ continue;
510
+ }
511
+ for (const rule of WONT_REPLAY) {
512
+ if (rule.pattern.test(text)) problems.push(`Step ${index + 1} may not replay: ${rule.why}.`);
513
+ }
514
+ }
515
+ return [...new Set(problems)];
516
+ }