staysfixed 0.2.2 → 0.3.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/src/guard/api.js CHANGED
@@ -5,6 +5,14 @@
5
5
  * who wrote it has forgotten the bug. That is why assertions are a sentence plus
6
6
  * a check, and never a bare comparison: `expect('the sidebar is hidden', ...)`
7
7
  * fails with "expected: the sidebar is hidden", which anyone can act on.
8
+ *
9
+ * Those sentences are also the answer to the fairest question anyone asks about
10
+ * this tool: "is it only about how things look?" It is not — a guard drives the
11
+ * app and asserts what it still does — but that was invisible, because a guard
12
+ * reported one line however many things it proved. So every claim, and every
13
+ * action between the claims, now says itself out loud the moment it happens:
14
+ * announced as it starts, settled as it finishes. The list a person watches tick
15
+ * off IS the guard's own words, in the guard's own order.
8
16
  */
9
17
 
10
18
  import { exec } from 'node:child_process';
@@ -28,15 +36,79 @@ const DEFAULT_RUN_TIMEOUT = 60_000;
28
36
  /** Commands can print a lot; 10MB before we cut them off. */
29
37
  const MAX_OUTPUT = 10 * 1024 * 1024;
30
38
 
39
+ /** Longest a selector, path or command is shown before it is cut short. */
40
+ const MAX_LABEL = 80;
41
+
42
+ /**
43
+ * How a step's id says what kind of step it is.
44
+ *
45
+ * An assertion is the point of a guard; opening a page or clicking a button is
46
+ * the setup that gets there. `CheckStep` has no room for that difference — and
47
+ * should not grow one for the sake of a colour — so it rides on the id, which
48
+ * every step needs anyway. Anything watching can draw `claim-` lines loud and
49
+ * `did-` lines quiet; anything that does not care sees a perfectly ordinary list.
50
+ */
51
+ const CLAIM = 'claim';
52
+ const ACTION = 'did';
53
+
54
+ /**
55
+ * What a guard is told to report to, when anyone is collecting.
56
+ * @typedef {object} GuardApiOptions
57
+ * @property {(step: import('../types.js').CheckStep) => void} [onStep]
58
+ * Called as each claim and each action starts, and again as it settles.
59
+ * The two calls carry the same `key`.
60
+ */
61
+
31
62
  /**
32
63
  * Build the object passed to a guard's `run`.
33
64
  *
34
65
  * @param {import('../types.js').PageApi} page
35
66
  * @param {import('../types.js').Project} project
67
+ * @param {GuardApiOptions} [opts]
36
68
  * @returns {import('../types.js').GuardApi}
37
69
  */
38
- export function makeGuardApi(page, project) {
70
+ export function makeGuardApi(page, project, opts = {}) {
39
71
  const root = project.paths.root;
72
+ const onStep = typeof opts.onStep === 'function' ? opts.onStep : null;
73
+ let counted = 0;
74
+
75
+ /**
76
+ * Hand one step out, and never let that matter.
77
+ *
78
+ * Reporting is a convenience laid over a check; a listener that throws must not
79
+ * change whether a bug that was fixed is still fixed.
80
+ *
81
+ * @param {import('../types.js').CheckStep} step
82
+ * @returns {void}
83
+ */
84
+ function tell(step) {
85
+ if (!onStep) return;
86
+ try {
87
+ onStep(step);
88
+ } catch {
89
+ // Watching is never worth a guard.
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Say a thing has started, and hand back the way to say how it went.
95
+ *
96
+ * When nobody is collecting this allocates nothing and returns a function that
97
+ * does nothing, so a guard run with no watcher costs exactly what it did before.
98
+ *
99
+ * @param {string} kind CLAIM or ACTION.
100
+ * @param {string} label Plain language, already final — the same words settle it.
101
+ * @returns {(state: import('../types.js').CheckStep['state'], detail?: string) => void}
102
+ */
103
+ function announce(kind, label) {
104
+ if (!onStep) return () => {};
105
+ counted += 1;
106
+ const key = `${kind}-${counted}`;
107
+ tell({ key, label, state: 'running' });
108
+ return (state, detail) => {
109
+ tell(detail ? { key, label, detail, state } : { key, label, state });
110
+ };
111
+ }
40
112
 
41
113
  return {
42
114
  page,
@@ -46,16 +118,30 @@ export function makeGuardApi(page, project) {
46
118
  * @param {string} to
47
119
  * @returns {Promise<void>}
48
120
  */
49
- open(to) {
50
- return page.goto(to);
121
+ async open(to) {
122
+ const settle = announce(ACTION, `opened ${short(to)}`);
123
+ try {
124
+ await page.goto(to);
125
+ } catch (error) {
126
+ settle('bad', reasonOf(error));
127
+ throw error;
128
+ }
129
+ settle('ok');
51
130
  },
52
131
 
53
132
  /**
54
133
  * @param {string} selector
55
134
  * @returns {Promise<void>}
56
135
  */
57
- click(selector) {
58
- return page.click(selector);
136
+ async click(selector) {
137
+ const settle = announce(ACTION, `clicked ${short(selector)}`);
138
+ try {
139
+ await page.click(selector);
140
+ } catch (error) {
141
+ settle('bad', reasonOf(error));
142
+ throw error;
143
+ }
144
+ settle('ok');
59
145
  },
60
146
 
61
147
  /**
@@ -75,18 +161,32 @@ export function makeGuardApi(page, project) {
75
161
  });
76
162
  }
77
163
 
164
+ // The claim goes out before it is checked, not after. A person watching
165
+ // sees what is being asked while it is being asked — which is the whole
166
+ // difference between a list that ticks and a table that appears.
167
+ const settle = announce(CLAIM, claim.trim());
168
+
78
169
  let result;
79
170
  try {
80
171
  result = await check();
81
172
  } catch (cause) {
82
173
  // A nested expectation already reads well — do not bury it in another layer.
83
- if (cause instanceof ExpectationFailed) throw cause;
84
- throw new Error(`while checking '${claim}': ${cause instanceof Error ? cause.message : String(cause)}`, {
174
+ if (cause instanceof ExpectationFailed) {
175
+ settle('bad', cause.claim === claim.trim() ? undefined : `inside it: ${cause.claim}`);
176
+ throw cause;
177
+ }
178
+ const why = cause instanceof Error ? cause.message : String(cause);
179
+ settle('bad', firstLine(why));
180
+ throw new Error(`while checking '${claim}': ${why}`, {
85
181
  cause,
86
182
  });
87
183
  }
88
184
 
89
- if (isNegative(result)) throw new ExpectationFailed(claim);
185
+ if (isNegative(result)) {
186
+ settle('bad', 'this is not true any more');
187
+ throw new ExpectationFailed(claim);
188
+ }
189
+ settle('ok');
90
190
  },
91
191
 
92
192
  /**
@@ -94,15 +194,17 @@ export function makeGuardApi(page, project) {
94
194
  * must still succeed, a file that must still be generated — live here.
95
195
  *
96
196
  * A non-zero exit is returned, never thrown: whether it means failure is the
97
- * guard's decision, not ours.
197
+ * guard's decision, not ours. The step says so the same way — a command that
198
+ * came back unhappy is worth noticing, and is still not a verdict.
98
199
  *
99
200
  * @param {string} cmd
100
201
  * @param {{cwd?: string, timeoutMs?: number}} [runOpts]
101
202
  * @returns {Promise<{code: number, stdout: string, stderr: string}>}
102
203
  */
103
- run(cmd, runOpts = {}) {
204
+ async run(cmd, runOpts = {}) {
104
205
  const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
105
206
  const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
207
+ const settle = announce(ACTION, `ran ${short(cmd)}`);
106
208
 
107
209
  /** @type {Promise<{code: number, stdout: string, stderr: string}>} */
108
210
  const finished = new Promise((resolve) => {
@@ -129,7 +231,13 @@ export function makeGuardApi(page, project) {
129
231
  },
130
232
  );
131
233
  });
132
- return finished;
234
+
235
+ const outcome = await finished;
236
+ if (outcome.code === 0) settle('ok', 'finished cleanly, code 0');
237
+ else if (outcome.code === 124) {
238
+ settle('warn', `stopped after ${humanTime(timeoutMs)}, code 124`);
239
+ } else settle('warn', `came back with code ${outcome.code}`);
240
+ return outcome;
133
241
  },
134
242
 
135
243
  /**
@@ -149,20 +257,30 @@ export function makeGuardApi(page, project) {
149
257
  });
150
258
  }
151
259
 
260
+ const settle = announce(ACTION, `read ${short(relative)}`);
261
+ /** @type {string} */
262
+ let text;
152
263
  try {
153
- return await fsp.readFile(full, 'utf8');
264
+ text = await fsp.readFile(full, 'utf8');
154
265
  } catch (cause) {
155
266
  const code = /** @type {any} */ (cause)?.code;
156
267
  if (code === 'ENOENT') {
268
+ settle('bad', 'there is no such file');
157
269
  throw new StaysFixedError(`There is no file called "${relative}" in the project.`, { cause });
158
270
  }
159
271
  if (code === 'EISDIR') {
272
+ settle('bad', 'that is a folder, not a file');
160
273
  throw new StaysFixedError(`"${relative}" is a folder, not a file.`, { cause });
161
274
  }
275
+ settle('bad', reasonOf(cause));
162
276
  throw new StaysFixedError(`Could not read "${relative}": ${cause instanceof Error ? cause.message : String(cause)}`, {
163
277
  cause,
164
278
  });
165
279
  }
280
+
281
+ const lines = text === '' ? 0 : text.split('\n').length;
282
+ settle('ok', `${count(lines)} ${lines === 1 ? 'line' : 'lines'}`);
283
+ return text;
166
284
  },
167
285
  };
168
286
  }
@@ -186,6 +304,44 @@ function isNegative(value) {
186
304
  return false;
187
305
  }
188
306
 
307
+ /**
308
+ * A selector, path or command, short enough to read in a list.
309
+ *
310
+ * @param {string} text
311
+ * @returns {string}
312
+ */
313
+ function short(text) {
314
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
315
+ if (one === '') return 'nothing';
316
+ return one.length > MAX_LABEL ? `${one.slice(0, MAX_LABEL - 1)}…` : one;
317
+ }
318
+
319
+ /**
320
+ * @param {unknown} error
321
+ * @returns {string}
322
+ */
323
+ function reasonOf(error) {
324
+ return firstLine(error instanceof Error ? error.message : String(error));
325
+ }
326
+
327
+ /**
328
+ * @param {string} text
329
+ * @returns {string}
330
+ */
331
+ function firstLine(text) {
332
+ const line = String(text ?? '').split('\n')[0].trim();
333
+ if (line === '') return 'it did not say why';
334
+ return line.length > 120 ? `${line.slice(0, 119)}…` : line;
335
+ }
336
+
337
+ /**
338
+ * @param {number} n
339
+ * @returns {string}
340
+ */
341
+ function count(n) {
342
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : String(n);
343
+ }
344
+
189
345
  /**
190
346
  * @param {number} ms
191
347
  * @returns {string}
package/src/guard/run.js CHANGED
@@ -6,6 +6,14 @@
6
6
  * from the same clean state, a guard that needs a second go is recorded as
7
7
  * wobbly rather than green, and the failure message carries the story of the
8
8
  * original bug so nobody has to go looking for it.
9
+ *
10
+ * A guard also has to be watchable while it happens. It is the part of this tool
11
+ * that checks behaviour rather than looks — it drives the app and asserts what it
12
+ * still does — and reporting it as a single line hid every one of those
13
+ * assertions. So each claim and each action is passed straight out as it starts
14
+ * and again as it settles, and the whole list travels with the result. A guard
15
+ * that fails on its fifth claim still shows the four that held: "these are fine,
16
+ * this one is not" is most of the value of running it at all.
9
17
  */
10
18
 
11
19
  import { makeGuardApi, ExpectationFailed } from './api.js';
@@ -14,8 +22,14 @@ import { emitEvent } from '../core/events.js';
14
22
 
15
23
  const DEFAULT_TIMEOUT = 30_000;
16
24
 
25
+ /** How the runner names its own first step — the clean start every guard gets. */
26
+ const FRESH_KEY = 'fresh';
27
+
17
28
  /**
18
- * @typedef {import('../types.js').GuardResult & {retriedToPass?: boolean}} GuardRunResult
29
+ * @typedef {import('../types.js').GuardResult & {
30
+ * retriedToPass?: boolean,
31
+ * checks?: import('../types.js').CheckStep[],
32
+ * }} GuardRunResult
19
33
  */
20
34
 
21
35
  /**
@@ -25,6 +39,8 @@ const DEFAULT_TIMEOUT = 30_000;
25
39
  * @property {string} [failedAt]
26
40
  */
27
41
 
42
+ /** @typedef {(step: import('../types.js').CheckStep) => void} StepSink */
43
+
28
44
  /**
29
45
  * Run every guard against an app that is already open.
30
46
  *
@@ -91,10 +107,31 @@ export async function runGuards(project, app, guards, opts = {}) {
91
107
  /** @type {AttemptOutcome} */
92
108
  let outcome = { ok: false, message: 'This guard did not run.' };
93
109
  let attempts = 0;
110
+ /** @type {import('../types.js').CheckStep[]} */
111
+ let checks = [];
94
112
 
95
113
  while (attempts < retries + 1) {
96
114
  attempts += 1;
97
- outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs);
115
+ // A second go starts the list again. What a person needs to see is what the
116
+ // verdict was actually made on, and that is the last attempt — the earlier
117
+ // one is already recorded, more usefully, as "it only passed on try 2".
118
+ const attempt = attempts;
119
+ /** @type {import('../types.js').CheckStep[]} */
120
+ const collected = [];
121
+ checks = collected;
122
+
123
+ /** @type {StepSink} */
124
+ const onStep = (step) => {
125
+ // Keys are unique inside one attempt; a retry re-announces the same
126
+ // claims, and a watcher must not mistake the second run of a claim for
127
+ // the settling of the first.
128
+ const stamped =
129
+ attempt > 1 && step.key ? { ...step, key: `try${attempt}-${step.key}` } : step;
130
+ record(collected, stamped);
131
+ emitEvent(events, { type: 'guard:step', name: guard.name, step: stamped });
132
+ };
133
+
134
+ outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep);
98
135
  if (outcome.ok) break;
99
136
  if (opts.signal?.aborted) break;
100
137
  }
@@ -108,6 +145,7 @@ export async function runGuards(project, app, guards, opts = {}) {
108
145
  durationMs: Date.now() - startedAt,
109
146
  attempts,
110
147
  };
148
+ if (checks.length > 0) result.checks = checks;
111
149
 
112
150
  if (outcome.ok) {
113
151
  // Passing only on the second go is not passing. The flake register picks
@@ -127,6 +165,29 @@ export async function runGuards(project, app, guards, opts = {}) {
127
165
  return results;
128
166
  }
129
167
 
168
+ /**
169
+ * Put one step into the list it belongs to.
170
+ *
171
+ * A step is said twice — once as it starts, once as it finishes — and the list
172
+ * should hold one line per thing, not two. The settled version replaces the
173
+ * running one in place, so the order stays the order it happened in.
174
+ *
175
+ * @param {import('../types.js').CheckStep[]} into
176
+ * @param {import('../types.js').CheckStep} step
177
+ * @returns {void}
178
+ */
179
+ function record(into, step) {
180
+ if (step.key) {
181
+ for (let i = 0; i < into.length; i++) {
182
+ if (into[i].key === step.key) {
183
+ into[i] = step;
184
+ return;
185
+ }
186
+ }
187
+ }
188
+ into.push(step);
189
+ }
190
+
130
191
  /**
131
192
  * @param {import('../types.js').RunEvents|undefined} events
132
193
  * @param {GuardRunResult} result
@@ -141,6 +202,10 @@ function emitGuardDone(events, result) {
141
202
  message: result.message,
142
203
  failedAt: result.failedAt,
143
204
  because: result.because,
205
+ // Everything this guard actually asserted, in its own words and its own
206
+ // order — so a listener that arrived late, or one that only keeps the
207
+ // verdicts, still has the working.
208
+ checks: result.checks,
144
209
  });
145
210
  }
146
211
 
@@ -152,12 +217,26 @@ function emitGuardDone(events, result) {
152
217
  * @param {import('../types.js').Guard} guard
153
218
  * @param {string|undefined} baseUrl
154
219
  * @param {number} timeoutMs
220
+ * @param {StepSink} [onStep]
155
221
  * @returns {Promise<AttemptOutcome>}
156
222
  */
157
- async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
223
+ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
158
224
  /** @type {ReturnType<typeof setTimeout>|undefined} */
159
225
  let timer;
160
226
 
227
+ /**
228
+ * @param {import('../types.js').CheckStep} step
229
+ * @returns {void}
230
+ */
231
+ const tell = (step) => {
232
+ if (!onStep) return;
233
+ try {
234
+ onStep(step);
235
+ } catch {
236
+ // Watching a guard must never be able to fail one.
237
+ }
238
+ };
239
+
161
240
  try {
162
241
  await Promise.race([
163
242
  (async () => {
@@ -171,10 +250,29 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
171
250
  // own, which is the single most confusing shape a failure can take. So an
172
251
  // Electron window is reloaded instead. Its main process keeps whatever it
173
252
  // was holding; only the screen goes back to how it opened.
174
- if (baseUrl) await app.page.goto(baseUrl);
175
- else await resetWindow(app);
253
+ const fresh = 'started from a clean screen';
254
+ tell({ key: FRESH_KEY, label: fresh, state: 'running' });
255
+ try {
256
+ if (baseUrl) await app.page.goto(baseUrl);
257
+ else await resetWindow(app);
258
+ } catch (error) {
259
+ tell({
260
+ key: FRESH_KEY,
261
+ label: fresh,
262
+ detail: error instanceof Error ? error.message : String(error),
263
+ state: 'bad',
264
+ });
265
+ throw error;
266
+ }
267
+ tell({
268
+ key: FRESH_KEY,
269
+ label: fresh,
270
+ detail: baseUrl ? 'back to the front door' : 'the window was reloaded',
271
+ state: 'ok',
272
+ });
273
+
176
274
  clearConsole(app);
177
- await guard.run(makeGuardApi(app.page, project));
275
+ await guard.run(makeGuardApi(app.page, project, onStep ? { onStep } : {}));
178
276
  })(),
179
277
  new Promise((_resolve, reject) => {
180
278
  timer = setTimeout(() => {