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.
- package/CHANGELOG.md +159 -3
- package/README.md +611 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +643 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +734 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +931 -0
- package/src/v2/adapters/source.js +1292 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +371 -0
- package/src/v2/check.js +1429 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +670 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1124 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1702 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +500 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +938 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +910 -0
- package/src/v2/run.js +1080 -0
- package/src/v2/sealed.js +568 -0
- package/src/v2/selfcheck.js +729 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +509 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|