staysfixed 0.1.0 → 0.2.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 +1 -1
- package/README.md +34 -11
- package/package.json +1 -1
- package/src/cli/check.js +71 -4
- package/src/cli/index.js +109 -6
- package/src/cli/init.js +3 -1
- package/src/cli/walk.js +66 -3
- package/src/core/events.js +171 -0
- package/src/core/paths.js +8 -1
- package/src/drive/page.js +60 -4
- package/src/freeze/fonts.js +131 -41
- package/src/freeze/settle.js +177 -2
- package/src/guard/run.js +41 -2
- package/src/picture/capture.js +59 -5
- package/src/picture/compare.js +60 -0
- package/src/picture/run.js +163 -26
- package/src/picture/store.js +65 -0
- package/src/report/console.js +80 -1
- package/src/run.js +229 -39
- package/src/types.js +68 -0
- package/src/walk/run.js +76 -0
- package/src/watch/index.js +286 -0
- package/src/watch/panel.js +1678 -0
- package/src/watch/place.js +279 -0
- package/src/watch/window.js +1242 -0
package/src/run.js
CHANGED
|
@@ -16,13 +16,14 @@ import { ensureDirs, clearResults, resultPicture, safeName } from './core/paths.
|
|
|
16
16
|
import { gitInfo } from './core/git.js';
|
|
17
17
|
import { loadHistory, saveHistory, foldRun, condemned } from './core/history.js';
|
|
18
18
|
import { warn, detail, shortPath } from './core/log.js';
|
|
19
|
+
import { makeTimings, emitEvent } from './core/events.js';
|
|
19
20
|
import { launchApp } from './drive/launch.js';
|
|
20
21
|
import { platformTag } from './drive/find.js';
|
|
21
22
|
import { runPictures } from './picture/run.js';
|
|
22
23
|
import { approveFromResult, listApproved } from './picture/store.js';
|
|
23
24
|
import { loadGuards } from './guard/load.js';
|
|
24
25
|
import { runGuards } from './guard/run.js';
|
|
25
|
-
import { walkApp, writeWalkContactSheet } from './walk/run.js';
|
|
26
|
+
import { walkApp, writeWalkContactSheet, countWalkSteps } from './walk/run.js';
|
|
26
27
|
import { listMarkers } from './marker/mark.js';
|
|
27
28
|
import { writeRunReport } from './report/html.js';
|
|
28
29
|
import { printPictureResult, printGuardResult } from './report/console.js';
|
|
@@ -83,6 +84,10 @@ const LAST_RUN = 'last-run.json';
|
|
|
83
84
|
* onPicture?: (result: import('./types.js').PictureResult) => void,
|
|
84
85
|
* onGuard?: (result: import('./types.js').GuardResult) => void,
|
|
85
86
|
* writeReport?: boolean,
|
|
87
|
+
* events?: import('./types.js').RunEvents,
|
|
88
|
+
* watching?: boolean,
|
|
89
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
90
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
86
91
|
* }} [opts]
|
|
87
92
|
* @returns {Promise<import('./types.js').RunSummary>}
|
|
88
93
|
*/
|
|
@@ -90,6 +95,12 @@ export async function runCheck(project, opts = {}) {
|
|
|
90
95
|
const { config, paths } = project;
|
|
91
96
|
const startedAt = new Date();
|
|
92
97
|
const started = Date.now();
|
|
98
|
+
const events = opts.events;
|
|
99
|
+
const watching = opts.watching === true;
|
|
100
|
+
// A run always knows where its time went, whether or not anybody asked. It
|
|
101
|
+
// costs two numbers per phase, and the alternative is being unable to answer
|
|
102
|
+
// "why did that take three minutes" without running it all again.
|
|
103
|
+
const timings = opts.timings ?? makeTimings();
|
|
93
104
|
|
|
94
105
|
await ensureDirs(paths);
|
|
95
106
|
// Yesterday's evidence goes in the bin before today's is taken. A stale diff
|
|
@@ -117,6 +128,17 @@ export async function runCheck(project, opts = {}) {
|
|
|
117
128
|
});
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
emitEvent(events, {
|
|
132
|
+
type: 'run:start',
|
|
133
|
+
plan: {
|
|
134
|
+
screens: screens.length,
|
|
135
|
+
guards: guards.length,
|
|
136
|
+
app: describeApp(config.app),
|
|
137
|
+
project: path.basename(paths.root),
|
|
138
|
+
watching,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
120
142
|
const onPicture = opts.onPicture ?? (opts.quiet ? undefined : (/** @type {import('./types.js').PictureResult} */ r) => printPictureResult(r));
|
|
121
143
|
const onGuard = opts.onGuard ?? (opts.quiet ? undefined : (/** @type {import('./types.js').GuardResult} */ r) => printGuardResult(r));
|
|
122
144
|
|
|
@@ -128,24 +150,41 @@ export async function runCheck(project, opts = {}) {
|
|
|
128
150
|
// Nothing to look at means nothing to open. Starting a browser to check zero
|
|
129
151
|
// screens is thirty seconds of somebody's life for no answer.
|
|
130
152
|
if (screens.length > 0 || guards.length > 0) {
|
|
131
|
-
await withApp(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
await withApp(
|
|
154
|
+
project,
|
|
155
|
+
async (app) => {
|
|
156
|
+
if (screens.length > 0) {
|
|
157
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
158
|
+
pictures = await runPictures(project, app, {
|
|
159
|
+
only: screens.map((s) => s.name),
|
|
160
|
+
record: opts.record ?? false,
|
|
161
|
+
retries: config.retries,
|
|
162
|
+
tool: TOOL,
|
|
163
|
+
onResult: onPicture,
|
|
164
|
+
signal: opts.signal,
|
|
165
|
+
events,
|
|
166
|
+
timings,
|
|
167
|
+
// Small pictures cost time to make, so they are only made when there
|
|
168
|
+
// is a window open to show them in.
|
|
169
|
+
thumbnail: Boolean(events && watching),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (guards.length > 0) {
|
|
173
|
+
emitEvent(events, { type: 'phase', message: 'running the guards' });
|
|
174
|
+
const stopGuards = timings.mark('guards');
|
|
175
|
+
try {
|
|
176
|
+
guardResults = await runGuards(project, app, guards, {
|
|
177
|
+
onResult: onGuard,
|
|
178
|
+
signal: opts.signal,
|
|
179
|
+
events,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
stopGuards();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{ events, timings, onApp: opts.onApp },
|
|
187
|
+
);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
const git = await gitInfo(paths.root);
|
|
@@ -171,7 +210,7 @@ export async function runCheck(project, opts = {}) {
|
|
|
171
210
|
|
|
172
211
|
const totals = countUp(pictures, guardResults);
|
|
173
212
|
|
|
174
|
-
/** @type {import('./types.js').RunSummary} */
|
|
213
|
+
/** @type {import('./types.js').RunSummary & {timings?: import('./types.js').Timings}} */
|
|
175
214
|
const summary = {
|
|
176
215
|
id: runId(startedAt),
|
|
177
216
|
startedAt: startedAt.toISOString(),
|
|
@@ -187,6 +226,9 @@ export async function runCheck(project, opts = {}) {
|
|
|
187
226
|
tool: TOOL,
|
|
188
227
|
platform: platformTag(),
|
|
189
228
|
condemned: condemnedNames,
|
|
229
|
+
// Read here rather than at the very end: what follows is writing files, and
|
|
230
|
+
// where the run spent its time is a fact about the run, not about the report.
|
|
231
|
+
timings: timings.get(),
|
|
190
232
|
};
|
|
191
233
|
|
|
192
234
|
if (opts.writeReport !== false) {
|
|
@@ -204,6 +246,10 @@ export async function runCheck(project, opts = {}) {
|
|
|
204
246
|
warn(`The run finished, but its result could not be saved for \`staysfixed status\`. ${messageOf(e)}`);
|
|
205
247
|
}
|
|
206
248
|
|
|
249
|
+
// Last, so anything watching that closes on the verdict does not race the
|
|
250
|
+
// report being written.
|
|
251
|
+
emitEvent(events, { type: 'run:done', summary });
|
|
252
|
+
|
|
207
253
|
return summary;
|
|
208
254
|
}
|
|
209
255
|
|
|
@@ -222,7 +268,15 @@ export async function runCheck(project, opts = {}) {
|
|
|
222
268
|
*
|
|
223
269
|
* @param {import('./types.js').Project} project
|
|
224
270
|
* @param {string} screenName
|
|
225
|
-
* @param {{
|
|
271
|
+
* @param {{
|
|
272
|
+
* record?: boolean,
|
|
273
|
+
* retries?: number,
|
|
274
|
+
* signal?: AbortSignal,
|
|
275
|
+
* onResult?: (r: import('./types.js').PictureResult) => void,
|
|
276
|
+
* events?: import('./types.js').RunEvents,
|
|
277
|
+
* watching?: boolean,
|
|
278
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
279
|
+
* }} [opts]
|
|
226
280
|
* @returns {Promise<{png: Buffer, result: import('./types.js').PictureResult, path: string}>}
|
|
227
281
|
*/
|
|
228
282
|
export async function captureOne(project, screenName, opts = {}) {
|
|
@@ -240,17 +294,47 @@ export async function captureOne(project, screenName, opts = {}) {
|
|
|
240
294
|
|
|
241
295
|
await ensureDirs(paths);
|
|
242
296
|
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
297
|
+
const events = opts.events;
|
|
298
|
+
const watching = opts.watching === true;
|
|
299
|
+
const timings = opts.timings ?? makeTimings();
|
|
300
|
+
|
|
301
|
+
// One screen is still a run as far as anyone watching is concerned, so it
|
|
302
|
+
// describes itself the same way. This is what lets an agent's own capture be
|
|
303
|
+
// watched in the same window as a full check.
|
|
304
|
+
emitEvent(events, {
|
|
305
|
+
type: 'run:start',
|
|
306
|
+
plan: {
|
|
307
|
+
screens: 1,
|
|
308
|
+
guards: 0,
|
|
309
|
+
app: describeApp(config.app),
|
|
310
|
+
project: path.basename(paths.root),
|
|
311
|
+
watching,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const results = await withApp(
|
|
316
|
+
project,
|
|
317
|
+
(app) => {
|
|
318
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
319
|
+
return runPictures(project, app, {
|
|
320
|
+
only: [screen.name],
|
|
321
|
+
record: opts.record ?? false,
|
|
322
|
+
retries: opts.retries ?? config.retries,
|
|
323
|
+
tool: TOOL,
|
|
324
|
+
onResult: opts.onResult,
|
|
325
|
+
signal: opts.signal,
|
|
326
|
+
events,
|
|
327
|
+
timings,
|
|
328
|
+
thumbnail: Boolean(events && watching),
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
{ events, timings },
|
|
252
332
|
);
|
|
253
333
|
|
|
334
|
+
// Said as soon as the app is shut. What is left is reading a file off disk,
|
|
335
|
+
// and a watcher should not be left with a spinner turning through it.
|
|
336
|
+
emitEvent(events, { type: 'run:done' });
|
|
337
|
+
|
|
254
338
|
const result = results[0];
|
|
255
339
|
if (!result) {
|
|
256
340
|
throw new StaysFixedError(`"${screen.name}" was not photographed.`, {
|
|
@@ -281,21 +365,58 @@ export async function captureOne(project, screenName, opts = {}) {
|
|
|
281
365
|
* signal?: AbortSignal,
|
|
282
366
|
* onStep?: (update: import('./walk/run.js').WalkProgress) => void,
|
|
283
367
|
* writeReport?: boolean,
|
|
368
|
+
* events?: import('./types.js').RunEvents,
|
|
369
|
+
* watching?: boolean,
|
|
370
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
371
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
284
372
|
* }} [opts]
|
|
285
373
|
* @returns {Promise<import('./types.js').WalkReport>}
|
|
286
374
|
*/
|
|
287
375
|
export async function runWalk(project, opts = {}) {
|
|
288
376
|
await ensureDirs(project.paths);
|
|
289
377
|
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
378
|
+
const events = opts.events;
|
|
379
|
+
const watching = opts.watching === true;
|
|
380
|
+
// The caller's stopwatch when it brought one — `--profile` reads it back out
|
|
381
|
+
// afterwards, so a walk that quietly kept its own would print all zeros.
|
|
382
|
+
const timings = opts.timings ?? makeTimings();
|
|
383
|
+
|
|
384
|
+
emitEvent(events, {
|
|
385
|
+
type: 'run:start',
|
|
386
|
+
plan: {
|
|
387
|
+
// Counted before anything is opened so the window can draw the whole list
|
|
388
|
+
// straight away. A walk with nothing to walk through says nothing here and
|
|
389
|
+
// fails a moment later, in one place, with a sentence a person can act on.
|
|
390
|
+
screens: countWalk(project.config, opts.only),
|
|
391
|
+
guards: 0,
|
|
392
|
+
app: describeApp(project.config.app),
|
|
393
|
+
project: path.basename(project.paths.root),
|
|
394
|
+
watching,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const report = await withApp(
|
|
399
|
+
project,
|
|
400
|
+
(app) => {
|
|
401
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
402
|
+
return walkApp(project, app, {
|
|
403
|
+
only: opts.only,
|
|
404
|
+
record: opts.record ?? false,
|
|
405
|
+
onStep: opts.onStep,
|
|
406
|
+
signal: opts.signal,
|
|
407
|
+
events,
|
|
408
|
+
thumbnail: Boolean(events && watching),
|
|
409
|
+
timings,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
{ events, timings, onApp: opts.onApp },
|
|
297
413
|
);
|
|
298
414
|
|
|
415
|
+
// A walk has no verdict to hand over — the pictures are the point — so this
|
|
416
|
+
// says only that it is over. Said here, with the app shut and every photo
|
|
417
|
+
// taken; the page that shows them is written after.
|
|
418
|
+
emitEvent(events, { type: 'run:done' });
|
|
419
|
+
|
|
299
420
|
if (opts.writeReport === false) return report;
|
|
300
421
|
|
|
301
422
|
try {
|
|
@@ -441,16 +562,43 @@ export async function approveScreens(project, names, opts = {}) {
|
|
|
441
562
|
* Electron window left running is a leaked process on somebody's machine, and
|
|
442
563
|
* the next run will fight it for the debug port.
|
|
443
564
|
*
|
|
565
|
+
* `onApp` is the one hook in here. It is handed the app the moment it is open
|
|
566
|
+
* and before a single picture is taken, which is what lets the watch panel put
|
|
567
|
+
* itself beside a window that did not exist when the panel opened. It moves
|
|
568
|
+
* windows around a desk; it never touches the page, and the picture comes from
|
|
569
|
+
* the viewport the capture sets, not from the window — so where the window ends
|
|
570
|
+
* up cannot change what was photographed.
|
|
571
|
+
*
|
|
444
572
|
* @template T
|
|
445
573
|
* @param {import('./types.js').Project} project
|
|
446
574
|
* @param {(app: import('./types.js').LaunchedApp) => Promise<T>} work
|
|
575
|
+
* @param {{
|
|
576
|
+
* events?: import('./types.js').RunEvents,
|
|
577
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
578
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
579
|
+
* }} [ctx]
|
|
447
580
|
* @returns {Promise<T>}
|
|
448
581
|
*/
|
|
449
|
-
async function withApp(project, work) {
|
|
450
|
-
|
|
582
|
+
async function withApp(project, work, ctx = {}) {
|
|
583
|
+
emitEvent(ctx.events, { type: 'phase', message: 'opening the app' });
|
|
584
|
+
const stopLaunch = ctx.timings?.mark('launch');
|
|
585
|
+
// Stopped even when the app never opened: a launch that gave up after fifty
|
|
586
|
+
// seconds is exactly the number somebody wants to see.
|
|
587
|
+
const app = await launchApp(project).finally(() => stopLaunch?.());
|
|
588
|
+
if (ctx.onApp) {
|
|
589
|
+
try {
|
|
590
|
+
await ctx.onApp(app);
|
|
591
|
+
} catch (e) {
|
|
592
|
+
// Whatever wanted a look at the app is a spectator. A spectator that
|
|
593
|
+
// trips over must not take the run down with it, and is not worth a
|
|
594
|
+
// warning in the middle of a clean one.
|
|
595
|
+
detail(`Something watching this run could not be shown the app. ${messageOf(e)}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
451
598
|
try {
|
|
452
599
|
return await work(app);
|
|
453
600
|
} finally {
|
|
601
|
+
emitEvent(ctx.events, { type: 'phase', message: 'closing' });
|
|
454
602
|
try {
|
|
455
603
|
await app.close();
|
|
456
604
|
} catch (e) {
|
|
@@ -459,6 +607,48 @@ async function withApp(project, work) {
|
|
|
459
607
|
}
|
|
460
608
|
}
|
|
461
609
|
|
|
610
|
+
/**
|
|
611
|
+
* How many screens a walk will visit, or none when there is nothing to walk.
|
|
612
|
+
*
|
|
613
|
+
* @param {import('./types.js').ResolvedConfig} config
|
|
614
|
+
* @param {string|string[]} [only]
|
|
615
|
+
* @returns {number}
|
|
616
|
+
*/
|
|
617
|
+
function countWalk(config, only) {
|
|
618
|
+
try {
|
|
619
|
+
return countWalkSteps(config, only);
|
|
620
|
+
} catch {
|
|
621
|
+
// Nothing to walk through. The walk itself says so properly, in one place.
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The app being opened, in a few words: what kind it is and which one it is.
|
|
628
|
+
*
|
|
629
|
+
* A watcher shows this at the top of a narrow panel, so the whole path or the
|
|
630
|
+
* whole address would be noise — the name of the binary, or the host being
|
|
631
|
+
* opened, is what a person recognises.
|
|
632
|
+
*
|
|
633
|
+
* @param {import('./types.js').AppConfig} app
|
|
634
|
+
* @returns {string}
|
|
635
|
+
*/
|
|
636
|
+
function describeApp(app) {
|
|
637
|
+
if (app.kind === 'electron') {
|
|
638
|
+
const binary = app.binary ?? app.attach ?? '';
|
|
639
|
+
return binary ? `electron — ${path.basename(binary)}` : 'electron';
|
|
640
|
+
}
|
|
641
|
+
const url = app.url ?? app.attach ?? '';
|
|
642
|
+
if (!url) return 'web';
|
|
643
|
+
try {
|
|
644
|
+
// A web address has no useful last part — "/" is not a name — so the host is
|
|
645
|
+
// what gets shown: "web — localhost:5173".
|
|
646
|
+
return `web — ${new URL(url).host || url}`;
|
|
647
|
+
} catch {
|
|
648
|
+
return `web — ${url}`;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
462
652
|
/**
|
|
463
653
|
* Flatten both kinds of result into the one shape the flake register folds.
|
|
464
654
|
* @param {PictureRunResult[]} pictures
|
package/src/types.js
CHANGED
|
@@ -469,3 +469,71 @@ export {};
|
|
|
469
469
|
* @property {number} markersSearched
|
|
470
470
|
* @property {string} [message]
|
|
471
471
|
*/
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Live events — what a run tells anyone watching, as it happens
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* One thing that happened during a run.
|
|
479
|
+
*
|
|
480
|
+
* The terminal, the watch window and any future listener all read the same stream, so a
|
|
481
|
+
* run only has to describe itself once. Every event carries `at` (milliseconds since the
|
|
482
|
+
* run began) so a watcher can draw a timeline without keeping its own clock.
|
|
483
|
+
*
|
|
484
|
+
* @typedef {object} RunEvent
|
|
485
|
+
* @property {'run:start'|'screen:start'|'screen:shot'|'screen:done'|'guard:start'|'guard:done'|'phase'|'note'|'run:done'} type
|
|
486
|
+
* @property {number} at Milliseconds since the run started.
|
|
487
|
+
* @property {string} [name] Screen or guard name.
|
|
488
|
+
* @property {string} [describe] The plain-language description.
|
|
489
|
+
* @property {number} [index] 1-based position within its phase.
|
|
490
|
+
* @property {number} [total] How many there are in this phase.
|
|
491
|
+
* @property {CheckStatus} [status]
|
|
492
|
+
* @property {number} [durationMs]
|
|
493
|
+
* @property {number} [diffPixels]
|
|
494
|
+
* @property {number} [diffRatio]
|
|
495
|
+
* @property {string} [message]
|
|
496
|
+
* @property {string} [failedAt] The plain-language expectation that failed.
|
|
497
|
+
* @property {string} [because] Why a guard exists.
|
|
498
|
+
* @property {string} [thumbnail] A small JPEG as a data: URI, for watching.
|
|
499
|
+
* @property {string} [approvedThumb]
|
|
500
|
+
* @property {string} [diffThumb]
|
|
501
|
+
* @property {RunSummary} [summary] Only on 'run:done'.
|
|
502
|
+
* @property {{screens: number, guards: number, app: string, project: string, watching: boolean}} [plan]
|
|
503
|
+
* Only on 'run:start'.
|
|
504
|
+
*/
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @typedef {object} RunEvents
|
|
508
|
+
* @property {(event: RunEvent) => void} emit
|
|
509
|
+
* @property {(listener: (event: RunEvent) => void) => () => void} on
|
|
510
|
+
* @property {() => number} elapsed
|
|
511
|
+
* @property {() => RunEvent[]} history Everything so far, so a late listener catches up.
|
|
512
|
+
*/
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @typedef {object} WatchOptions
|
|
516
|
+
* @property {boolean} [enabled]
|
|
517
|
+
* @property {number} [width] Panel width in CSS pixels. Default 460.
|
|
518
|
+
* @property {number} [height] Default: as tall as the app.
|
|
519
|
+
* @property {'right'|'left'} [side] Which side of the app to sit on. Default 'right'.
|
|
520
|
+
* @property {boolean} [keepOpen] Leave the panel up after the run. Default true.
|
|
521
|
+
* @property {boolean} [foreground] Bring the panel to the front. Default false.
|
|
522
|
+
* @property {boolean} [snap] Pin the app to a screen edge and sit flush against it. Default true.
|
|
523
|
+
* @property {'dark'|'light'|'system'} [theme] Default 'dark'. The panel opens on a brand new browser
|
|
524
|
+
* profile, and a fresh profile insists the computer is in light
|
|
525
|
+
* mode however it is really set — so the look is stated, not guessed.
|
|
526
|
+
*/
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Where a run spent its time. Printed by `--profile`, and drawn in the watch window.
|
|
530
|
+
* @typedef {object} Timings
|
|
531
|
+
* @property {number} launch
|
|
532
|
+
* @property {number} steps
|
|
533
|
+
* @property {number} prepare
|
|
534
|
+
* @property {number} settle
|
|
535
|
+
* @property {number} compare
|
|
536
|
+
* @property {number} guards
|
|
537
|
+
* @property {number} other
|
|
538
|
+
* @property {number} total
|
|
539
|
+
*/
|
package/src/walk/run.js
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
import fsp from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { captureScreen } from '../picture/capture.js';
|
|
14
|
+
import { accountForCapture } from '../picture/run.js';
|
|
14
15
|
import { settingsForScreen } from '../core/config.js';
|
|
15
16
|
import { gitInfo } from '../core/git.js';
|
|
16
17
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
17
18
|
import { safeName } from '../core/paths.js';
|
|
19
|
+
import { emitEvent } from '../core/events.js';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Progress handed to `opts.onStep`, once when a step starts and once when it is done.
|
|
@@ -37,12 +39,16 @@ import { safeName } from '../core/paths.js';
|
|
|
37
39
|
* only?: string|string[],
|
|
38
40
|
* signal?: AbortSignal,
|
|
39
41
|
* record?: boolean,
|
|
42
|
+
* events?: import('../types.js').RunEvents,
|
|
43
|
+
* thumbnail?: boolean,
|
|
44
|
+
* timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
|
|
40
45
|
* }} [opts]
|
|
41
46
|
* @returns {Promise<import('../types.js').WalkReport>}
|
|
42
47
|
*/
|
|
43
48
|
export async function walkApp(project, app, opts = {}) {
|
|
44
49
|
const { config, paths } = project;
|
|
45
50
|
const chosen = chooseSteps(config, opts.only);
|
|
51
|
+
const events = opts.events;
|
|
46
52
|
|
|
47
53
|
const id = walkId(new Date());
|
|
48
54
|
const dir = await makeWalkDir(paths.results, id);
|
|
@@ -71,15 +77,43 @@ export async function walkApp(project, app, opts = {}) {
|
|
|
71
77
|
...(screen.describe !== undefined ? { describe: screen.describe } : {}),
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
// A walk goes through the same events as a check, so the live window draws a
|
|
81
|
+
// walkthrough exactly the way it draws a run of picture checks.
|
|
82
|
+
emitEvent(events, {
|
|
83
|
+
type: 'screen:start',
|
|
84
|
+
name: screen.name,
|
|
85
|
+
describe: screen.describe,
|
|
86
|
+
index,
|
|
87
|
+
total: chosen.length,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** @type {{shot?: string}} */
|
|
91
|
+
const thumbs = {};
|
|
74
92
|
const step = await walkOneStep(page, screen, {
|
|
75
93
|
index,
|
|
76
94
|
dir,
|
|
77
95
|
settings: settingsForScreen(config, screen),
|
|
78
96
|
fixturesDir: paths.fixtures,
|
|
79
97
|
record: opts.record ?? false,
|
|
98
|
+
events,
|
|
99
|
+
thumbnail: opts.thumbnail === true,
|
|
100
|
+
thumbs,
|
|
101
|
+
timings: opts.timings,
|
|
80
102
|
});
|
|
81
103
|
steps.push(step);
|
|
82
104
|
|
|
105
|
+
emitEvent(events, {
|
|
106
|
+
type: 'screen:done',
|
|
107
|
+
name: step.name,
|
|
108
|
+
describe: step.describe,
|
|
109
|
+
// A walk has nothing to compare against, so a step either happened or it
|
|
110
|
+
// did not. Anything the app complained about is said in the message.
|
|
111
|
+
status: step.error ? 'failed' : 'passed',
|
|
112
|
+
durationMs: step.durationMs,
|
|
113
|
+
message: stepMessage(step),
|
|
114
|
+
thumbnail: thumbs.shot,
|
|
115
|
+
});
|
|
116
|
+
|
|
83
117
|
opts.onStep?.({
|
|
84
118
|
phase: 'done',
|
|
85
119
|
index,
|
|
@@ -115,6 +149,10 @@ export async function walkApp(project, app, opts = {}) {
|
|
|
115
149
|
* settings: ReturnType<typeof settingsForScreen>,
|
|
116
150
|
* fixturesDir: string,
|
|
117
151
|
* record: boolean,
|
|
152
|
+
* events?: import('../types.js').RunEvents,
|
|
153
|
+
* thumbnail?: boolean,
|
|
154
|
+
* thumbs?: {shot?: string},
|
|
155
|
+
* timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
|
|
118
156
|
* }} ctx
|
|
119
157
|
* @returns {Promise<import('../types.js').WalkStep>}
|
|
120
158
|
*/
|
|
@@ -136,10 +174,16 @@ async function walkOneStep(page, screen, ctx) {
|
|
|
136
174
|
const shot = await captureScreen(page, screen, ctx.settings, {
|
|
137
175
|
fixturesDir: ctx.fixturesDir,
|
|
138
176
|
record: ctx.record,
|
|
177
|
+
thumbnail: ctx.thumbnail === true,
|
|
139
178
|
});
|
|
179
|
+
accountForCapture(ctx.timings, shot);
|
|
140
180
|
await fsp.writeFile(target, shot.png);
|
|
141
181
|
file = target;
|
|
142
182
|
consoleErrors = shot.consoleErrors;
|
|
183
|
+
if (shot.thumbnail) {
|
|
184
|
+
if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
|
|
185
|
+
emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
|
|
186
|
+
}
|
|
143
187
|
} catch (cause) {
|
|
144
188
|
error = messageOf(cause);
|
|
145
189
|
consoleErrors = readConsole(page);
|
|
@@ -173,6 +217,38 @@ async function walkOneStep(page, screen, ctx) {
|
|
|
173
217
|
return step;
|
|
174
218
|
}
|
|
175
219
|
|
|
220
|
+
/**
|
|
221
|
+
* How many screens a walk is about to visit.
|
|
222
|
+
*
|
|
223
|
+
* The engine needs this before it opens anything, so it can tell a watcher how
|
|
224
|
+
* long the list will be. It asks the same function the walk itself asks, because
|
|
225
|
+
* two places counting the same thing differently is how a progress bar starts
|
|
226
|
+
* lying.
|
|
227
|
+
*
|
|
228
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
229
|
+
* @param {string|string[]} [only]
|
|
230
|
+
* @returns {number}
|
|
231
|
+
*/
|
|
232
|
+
export function countWalkSteps(config, only) {
|
|
233
|
+
return chooseSteps(config, only).length;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What to say about a finished step, in plain language, or nothing when it went
|
|
238
|
+
* through cleanly.
|
|
239
|
+
*
|
|
240
|
+
* @param {import('../types.js').WalkStep} step
|
|
241
|
+
* @returns {string|undefined}
|
|
242
|
+
*/
|
|
243
|
+
function stepMessage(step) {
|
|
244
|
+
if (step.error) return step.error;
|
|
245
|
+
const complaints = (step.consoleErrors ?? []).length;
|
|
246
|
+
if (complaints === 0) return undefined;
|
|
247
|
+
return complaints === 1
|
|
248
|
+
? 'The app logged one error while this screen was open.'
|
|
249
|
+
: `The app logged ${complaints} errors while this screen was open.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
176
252
|
/**
|
|
177
253
|
* Which screens the walk visits: `walk.steps` when the project spelled one out,
|
|
178
254
|
* otherwise every screen it already checks, in the order they are written.
|