staysfixed 0.1.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
package/src/run.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine.
|
|
3
|
+
*
|
|
4
|
+
* The CLI and the MCP server both come through here, which is the point: what a
|
|
5
|
+
* person sees when they type `staysfixed check` and what an agent sees when it
|
|
6
|
+
* calls `staysfixed_check` are the same run, decided by the same rules. If the
|
|
7
|
+
* two ever drift apart, the agent starts marking its own homework.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
import { StaysFixedError, messageOf } from './core/errors.js';
|
|
15
|
+
import { ensureDirs, clearResults, resultPicture, safeName } from './core/paths.js';
|
|
16
|
+
import { gitInfo } from './core/git.js';
|
|
17
|
+
import { loadHistory, saveHistory, foldRun, condemned } from './core/history.js';
|
|
18
|
+
import { warn, detail, shortPath } from './core/log.js';
|
|
19
|
+
import { launchApp } from './drive/launch.js';
|
|
20
|
+
import { platformTag } from './drive/find.js';
|
|
21
|
+
import { runPictures } from './picture/run.js';
|
|
22
|
+
import { approveFromResult, listApproved } from './picture/store.js';
|
|
23
|
+
import { loadGuards } from './guard/load.js';
|
|
24
|
+
import { runGuards } from './guard/run.js';
|
|
25
|
+
import { walkApp, writeWalkContactSheet } from './walk/run.js';
|
|
26
|
+
import { listMarkers } from './marker/mark.js';
|
|
27
|
+
import { writeRunReport } from './report/html.js';
|
|
28
|
+
import { printPictureResult, printGuardResult } from './report/console.js';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
/** The version of the tool, read off package.json so it can never drift from what shipped. */
|
|
33
|
+
export const VERSION = /** @type {{version?: string}} */ (require('../package.json')).version ?? '0.0.0';
|
|
34
|
+
|
|
35
|
+
/** How a run stamps itself into pictures, markers and reports. */
|
|
36
|
+
const TOOL = `staysfixed ${VERSION}`;
|
|
37
|
+
|
|
38
|
+
/** The verdict of the last run, parked where `status` and `approve` can read it without re-running anything. */
|
|
39
|
+
const LAST_RUN = 'last-run.json';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {import('./picture/run.js').PictureRunResult} PictureRunResult
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What `projectStatus` answers with. Nothing in here costs more than reading a
|
|
47
|
+
* few small files — `staysfixed status` has to be instant or nobody types it.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {object} StatusReport
|
|
50
|
+
* @property {number} screens Screens described in the config.
|
|
51
|
+
* @property {number} guards Guards found on disk.
|
|
52
|
+
* @property {string|null} guardsError Why the guards could not be counted, in plain language.
|
|
53
|
+
* @property {number} approved Approved pictures on disk.
|
|
54
|
+
* @property {string[]} missingApproved Screens nobody has approved a picture for yet.
|
|
55
|
+
* @property {number} markers Known-good markers written so far.
|
|
56
|
+
* @property {{label: string, at: string}|null} lastMarker The newest marker, ready for the status printer.
|
|
57
|
+
* @property {import('./types.js').RunSummary|null} lastRun
|
|
58
|
+
* @property {string[]} condemned Checks that have flaked past the limit.
|
|
59
|
+
* @property {string} configFile
|
|
60
|
+
* @property {string} root
|
|
61
|
+
* @property {'web'|'electron'} appKind
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// check
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run every net the project has: photograph the screens, then run the guards.
|
|
70
|
+
*
|
|
71
|
+
* Unless `quiet` is set this prints one line per check as it finishes, so a
|
|
72
|
+
* person watching a long run sees it moving. It does not print the closing
|
|
73
|
+
* summary — the caller owns that, so the CLI can add its own next steps.
|
|
74
|
+
*
|
|
75
|
+
* @param {import('./types.js').Project} project
|
|
76
|
+
* @param {{
|
|
77
|
+
* only?: string[],
|
|
78
|
+
* picturesOnly?: boolean,
|
|
79
|
+
* guardsOnly?: boolean,
|
|
80
|
+
* record?: boolean,
|
|
81
|
+
* signal?: AbortSignal,
|
|
82
|
+
* quiet?: boolean,
|
|
83
|
+
* onPicture?: (result: import('./types.js').PictureResult) => void,
|
|
84
|
+
* onGuard?: (result: import('./types.js').GuardResult) => void,
|
|
85
|
+
* writeReport?: boolean,
|
|
86
|
+
* }} [opts]
|
|
87
|
+
* @returns {Promise<import('./types.js').RunSummary>}
|
|
88
|
+
*/
|
|
89
|
+
export async function runCheck(project, opts = {}) {
|
|
90
|
+
const { config, paths } = project;
|
|
91
|
+
const startedAt = new Date();
|
|
92
|
+
const started = Date.now();
|
|
93
|
+
|
|
94
|
+
await ensureDirs(paths);
|
|
95
|
+
// Yesterday's evidence goes in the bin before today's is taken. A stale diff
|
|
96
|
+
// image sitting next to a fresh picture is the most convincing lie this tool
|
|
97
|
+
// could tell, and somebody would act on it.
|
|
98
|
+
await clearResults(paths);
|
|
99
|
+
|
|
100
|
+
const terms = normaliseOnly(opts.only);
|
|
101
|
+
const wantPictures = opts.guardsOnly !== true;
|
|
102
|
+
const wantGuards = opts.picturesOnly !== true;
|
|
103
|
+
|
|
104
|
+
// Guards are loaded before anything is launched, on purpose. A guard with a
|
|
105
|
+
// name nobody can read, or with no run function, is a mistake in the project
|
|
106
|
+
// — catching it now costs two seconds, catching it after a browser and a dev
|
|
107
|
+
// server have started costs two minutes of somebody's afternoon.
|
|
108
|
+
const allGuards = wantGuards ? await loadGuards(project) : [];
|
|
109
|
+
const guards = terms ? allGuards.filter((g) => terms.some((t) => matches(g.name, t))) : allGuards;
|
|
110
|
+
|
|
111
|
+
const allScreens = wantPictures ? config.screens : [];
|
|
112
|
+
const screens = terms ? allScreens.filter((s) => terms.some((t) => matches(s.name, t))) : allScreens;
|
|
113
|
+
|
|
114
|
+
if (terms && screens.length === 0 && guards.length === 0) {
|
|
115
|
+
throw new StaysFixedError(`Nothing here is called ${terms.map((t) => `"${t}"`).join(' or ')}.`, {
|
|
116
|
+
hint: nameHint(config, allGuards),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const onPicture = opts.onPicture ?? (opts.quiet ? undefined : (/** @type {import('./types.js').PictureResult} */ r) => printPictureResult(r));
|
|
121
|
+
const onGuard = opts.onGuard ?? (opts.quiet ? undefined : (/** @type {import('./types.js').GuardResult} */ r) => printGuardResult(r));
|
|
122
|
+
|
|
123
|
+
/** @type {PictureRunResult[]} */
|
|
124
|
+
let pictures = [];
|
|
125
|
+
/** @type {import('./types.js').GuardResult[]} */
|
|
126
|
+
let guardResults = [];
|
|
127
|
+
|
|
128
|
+
// Nothing to look at means nothing to open. Starting a browser to check zero
|
|
129
|
+
// screens is thirty seconds of somebody's life for no answer.
|
|
130
|
+
if (screens.length > 0 || guards.length > 0) {
|
|
131
|
+
await withApp(project, async (app) => {
|
|
132
|
+
if (screens.length > 0) {
|
|
133
|
+
pictures = await runPictures(project, app, {
|
|
134
|
+
only: screens.map((s) => s.name),
|
|
135
|
+
record: opts.record ?? false,
|
|
136
|
+
retries: config.retries,
|
|
137
|
+
tool: TOOL,
|
|
138
|
+
onResult: onPicture,
|
|
139
|
+
signal: opts.signal,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (guards.length > 0) {
|
|
143
|
+
guardResults = await runGuards(project, app, guards, {
|
|
144
|
+
onResult: onGuard,
|
|
145
|
+
signal: opts.signal,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const git = await gitInfo(paths.root);
|
|
152
|
+
const finishedAt = new Date().toISOString();
|
|
153
|
+
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
let condemnedNames = [];
|
|
156
|
+
try {
|
|
157
|
+
const history = await loadHistory(paths.historyFile);
|
|
158
|
+
const result = foldRun(history, foldable(pictures, guardResults), git, finishedAt, config.flakeLimit);
|
|
159
|
+
await saveHistory(paths.historyFile, result.history);
|
|
160
|
+
condemnedNames = condemned(result.history).map((e) => e.name);
|
|
161
|
+
for (const name of result.newlyCondemned) {
|
|
162
|
+
warn(
|
|
163
|
+
`"${name}" has now changed its mind ${config.flakeLimit} times while the code stood still. ` +
|
|
164
|
+
'Fix it or delete it — a check people re-run until it goes green is worse than no check at all.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// The register is a nice-to-have. Losing it must never lose the verdict.
|
|
169
|
+
warn(`The run finished, but the flaky-check register could not be updated. ${messageOf(e)}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const totals = countUp(pictures, guardResults);
|
|
173
|
+
|
|
174
|
+
/** @type {import('./types.js').RunSummary} */
|
|
175
|
+
const summary = {
|
|
176
|
+
id: runId(startedAt),
|
|
177
|
+
startedAt: startedAt.toISOString(),
|
|
178
|
+
durationMs: Date.now() - started,
|
|
179
|
+
pictures,
|
|
180
|
+
guards: guardResults,
|
|
181
|
+
totals,
|
|
182
|
+
// A picture nobody has ever approved is not a pass: the tool has no opinion
|
|
183
|
+
// about whether that screen is right, only a person does. So `new` keeps a
|
|
184
|
+
// run out of the green exactly the way `changed` does.
|
|
185
|
+
ok: totals.changed === 0 && totals.failed === 0 && totals.missing === 0 && totals.new === 0,
|
|
186
|
+
git,
|
|
187
|
+
tool: TOOL,
|
|
188
|
+
platform: platformTag(),
|
|
189
|
+
condemned: condemnedNames,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (opts.writeReport !== false) {
|
|
193
|
+
try {
|
|
194
|
+
const file = await writeRunReport(project, summary);
|
|
195
|
+
detail(`Report written to ${shortPath(file)}`);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
warn(`The run finished, but the report page could not be written. ${messageOf(e)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await fsp.writeFile(lastRunFile(paths), JSON.stringify(summary, null, 2) + '\n');
|
|
203
|
+
} catch (e) {
|
|
204
|
+
warn(`The run finished, but its result could not be saved for \`staysfixed status\`. ${messageOf(e)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return summary;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// one screen
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Open the app, photograph one screen, compare it if there is something to
|
|
216
|
+
* compare against, and shut down again.
|
|
217
|
+
*
|
|
218
|
+
* This is what an agent gets when it wants to look at what it just built. It
|
|
219
|
+
* writes the picture into `results/` like a normal run does, which is what makes
|
|
220
|
+
* `staysfixed approve <name>` work afterwards — the agent takes the photo, a
|
|
221
|
+
* person says yes to it.
|
|
222
|
+
*
|
|
223
|
+
* @param {import('./types.js').Project} project
|
|
224
|
+
* @param {string} screenName
|
|
225
|
+
* @param {{record?: boolean, retries?: number, signal?: AbortSignal, onResult?: (r: import('./types.js').PictureResult) => void}} [opts]
|
|
226
|
+
* @returns {Promise<{png: Buffer, result: import('./types.js').PictureResult, path: string}>}
|
|
227
|
+
*/
|
|
228
|
+
export async function captureOne(project, screenName, opts = {}) {
|
|
229
|
+
const { config, paths } = project;
|
|
230
|
+
const wanted = String(screenName ?? '').trim();
|
|
231
|
+
const screen =
|
|
232
|
+
config.screens.find((s) => s.name === wanted) ??
|
|
233
|
+
config.screens.find((s) => s.name.toLowerCase() === wanted.toLowerCase());
|
|
234
|
+
|
|
235
|
+
if (!screen) {
|
|
236
|
+
throw new StaysFixedError(`There is no screen called "${wanted}" in this project.`, {
|
|
237
|
+
hint: nameHint(config, []),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await ensureDirs(paths);
|
|
242
|
+
|
|
243
|
+
const results = await withApp(project, (app) =>
|
|
244
|
+
runPictures(project, app, {
|
|
245
|
+
only: [screen.name],
|
|
246
|
+
record: opts.record ?? false,
|
|
247
|
+
retries: opts.retries ?? config.retries,
|
|
248
|
+
tool: TOOL,
|
|
249
|
+
onResult: opts.onResult,
|
|
250
|
+
signal: opts.signal,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const result = results[0];
|
|
255
|
+
if (!result) {
|
|
256
|
+
throw new StaysFixedError(`"${screen.name}" was not photographed.`, {
|
|
257
|
+
hint: 'It may be switched off in the config with `skip: true`.',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const file = result.actualPath ?? resultPicture(paths, screen.name).png;
|
|
262
|
+
try {
|
|
263
|
+
return { png: await fsp.readFile(file), result, path: file };
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
throw new StaysFixedError(result.message ?? `"${screen.name}" could not be photographed.`, { cause });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// walk
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Open the real app, walk it end to end, and leave behind one page of photos
|
|
275
|
+
* anyone can open — the thing you look at before you press release.
|
|
276
|
+
*
|
|
277
|
+
* @param {import('./types.js').Project} project
|
|
278
|
+
* @param {{
|
|
279
|
+
* only?: string|string[],
|
|
280
|
+
* record?: boolean,
|
|
281
|
+
* signal?: AbortSignal,
|
|
282
|
+
* onStep?: (update: import('./walk/run.js').WalkProgress) => void,
|
|
283
|
+
* writeReport?: boolean,
|
|
284
|
+
* }} [opts]
|
|
285
|
+
* @returns {Promise<import('./types.js').WalkReport>}
|
|
286
|
+
*/
|
|
287
|
+
export async function runWalk(project, opts = {}) {
|
|
288
|
+
await ensureDirs(project.paths);
|
|
289
|
+
|
|
290
|
+
const report = await withApp(project, (app) =>
|
|
291
|
+
walkApp(project, app, {
|
|
292
|
+
only: opts.only,
|
|
293
|
+
record: opts.record ?? false,
|
|
294
|
+
onStep: opts.onStep,
|
|
295
|
+
signal: opts.signal,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (opts.writeReport === false) return report;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
// Beside the photos, not in the project root: a walkthrough belongs with the
|
|
303
|
+
// pictures it is made of, and both are throwaway evidence.
|
|
304
|
+
const file = await writeWalkContactSheet(report, path.join(report.dir, 'walkthrough.html'));
|
|
305
|
+
return { ...report, reportFile: file };
|
|
306
|
+
} catch (e) {
|
|
307
|
+
warn(`The walkthrough was photographed, but the page showing it could not be written. ${messageOf(e)}`);
|
|
308
|
+
return report;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// status
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* What this project has set up and where it stood after the last run.
|
|
318
|
+
*
|
|
319
|
+
* Deliberately opens nothing: `status` is the question you ask when you are not
|
|
320
|
+
* sure the tool is even wired up, and it has to answer immediately.
|
|
321
|
+
*
|
|
322
|
+
* @param {import('./types.js').Project} project
|
|
323
|
+
* @returns {Promise<StatusReport>}
|
|
324
|
+
*/
|
|
325
|
+
export async function projectStatus(project) {
|
|
326
|
+
const { config, paths } = project;
|
|
327
|
+
|
|
328
|
+
let guardCount = 0;
|
|
329
|
+
/** @type {string|null} */
|
|
330
|
+
let guardsError = null;
|
|
331
|
+
try {
|
|
332
|
+
guardCount = (await loadGuards(project)).length;
|
|
333
|
+
} catch (e) {
|
|
334
|
+
// A broken guard file must not stop somebody finding out what else is here.
|
|
335
|
+
guardsError = messageOf(e);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const approved = await listApproved(paths).catch(() => /** @type {string[]} */ ([]));
|
|
339
|
+
const approvedSet = new Set(approved);
|
|
340
|
+
const missingApproved = config.screens
|
|
341
|
+
.filter((s) => !s.skip && !approvedSet.has(safeName(s.name)))
|
|
342
|
+
.map((s) => s.name);
|
|
343
|
+
|
|
344
|
+
const markers = await listMarkers(project).catch(() => /** @type {import('./types.js').Marker[]} */ ([]));
|
|
345
|
+
const history = await loadHistory(paths.historyFile);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
screens: config.screens.length,
|
|
349
|
+
guards: guardCount,
|
|
350
|
+
guardsError,
|
|
351
|
+
approved: approved.length,
|
|
352
|
+
missingApproved,
|
|
353
|
+
markers: markers.length,
|
|
354
|
+
lastMarker: markers.length > 0 ? { label: markers[0].label, at: markers[0].at } : null,
|
|
355
|
+
lastRun: await readLastRun(paths),
|
|
356
|
+
condemned: condemned(history).map((e) => e.name),
|
|
357
|
+
configFile: paths.configFile,
|
|
358
|
+
root: paths.root,
|
|
359
|
+
appKind: config.app.kind,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// approve
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Promote pictures from the last run to being the approved ones.
|
|
369
|
+
*
|
|
370
|
+
* This is the only door a new look walks through, and a person is always the one
|
|
371
|
+
* holding it open. Names with nothing behind them are refused with a reason
|
|
372
|
+
* rather than quietly ignored, because "approved" printed next to a name that
|
|
373
|
+
* was never actually approved is how trust in the whole tool goes.
|
|
374
|
+
*
|
|
375
|
+
* With no names (or `all`), everything the last run called changed or new is
|
|
376
|
+
* approved together.
|
|
377
|
+
*
|
|
378
|
+
* @param {import('./types.js').Project} project
|
|
379
|
+
* @param {string[]} names
|
|
380
|
+
* @param {{all?: boolean, tool?: string}} [opts]
|
|
381
|
+
* @returns {Promise<{approved: string[], skipped: {name: string, why: string}[]}>}
|
|
382
|
+
*/
|
|
383
|
+
export async function approveScreens(project, names, opts = {}) {
|
|
384
|
+
const { config, paths } = project;
|
|
385
|
+
const last = await readLastRun(paths);
|
|
386
|
+
|
|
387
|
+
let wanted = unique((names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean));
|
|
388
|
+
|
|
389
|
+
if (opts.all === true || wanted.length === 0) {
|
|
390
|
+
if (!last) {
|
|
391
|
+
throw new StaysFixedError('There is nothing to approve — no check has been run in this project yet.', {
|
|
392
|
+
hint: 'Run `staysfixed check` first, look at what it found, then approve what is right.',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
wanted = unique(
|
|
396
|
+
last.pictures.filter((p) => p.status === 'changed' || p.status === 'new').map((p) => p.name),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** @type {string[]} */
|
|
401
|
+
const approved = [];
|
|
402
|
+
/** @type {{name: string, why: string}[]} */
|
|
403
|
+
const skipped = [];
|
|
404
|
+
|
|
405
|
+
const git = await gitInfo(paths.root);
|
|
406
|
+
const tool = opts.tool ?? TOOL;
|
|
407
|
+
|
|
408
|
+
for (const asked of wanted) {
|
|
409
|
+
const screen =
|
|
410
|
+
config.screens.find((s) => s.name === asked) ??
|
|
411
|
+
config.screens.find((s) => s.name.toLowerCase() === asked.toLowerCase());
|
|
412
|
+
const name = screen ? screen.name : asked;
|
|
413
|
+
|
|
414
|
+
if (!(await exists(resultPicture(paths, name).png))) {
|
|
415
|
+
skipped.push({
|
|
416
|
+
name,
|
|
417
|
+
why: screen
|
|
418
|
+
? `There is no picture of "${name}" from the last run, so there is nothing to approve. Run \`staysfixed check\` first.`
|
|
419
|
+
: `Nothing here is called "${name}", and there is no picture of it from the last run either.`,
|
|
420
|
+
});
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
await approveFromResult(paths, name, { git, tool, describe: screen?.describe });
|
|
426
|
+
approved.push(name);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
skipped.push({ name, why: messageOf(e) });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { approved, skipped };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// the plumbing
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Open the app, do the work, and always close it again — a browser or an
|
|
441
|
+
* Electron window left running is a leaked process on somebody's machine, and
|
|
442
|
+
* the next run will fight it for the debug port.
|
|
443
|
+
*
|
|
444
|
+
* @template T
|
|
445
|
+
* @param {import('./types.js').Project} project
|
|
446
|
+
* @param {(app: import('./types.js').LaunchedApp) => Promise<T>} work
|
|
447
|
+
* @returns {Promise<T>}
|
|
448
|
+
*/
|
|
449
|
+
async function withApp(project, work) {
|
|
450
|
+
const app = await launchApp(project);
|
|
451
|
+
try {
|
|
452
|
+
return await work(app);
|
|
453
|
+
} finally {
|
|
454
|
+
try {
|
|
455
|
+
await app.close();
|
|
456
|
+
} catch (e) {
|
|
457
|
+
warn(`The app could not be closed cleanly. ${messageOf(e)}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Flatten both kinds of result into the one shape the flake register folds.
|
|
464
|
+
* @param {PictureRunResult[]} pictures
|
|
465
|
+
* @param {import('./types.js').GuardResult[]} guards
|
|
466
|
+
* @returns {{name: string, kind: 'picture'|'guard', status: import('./types.js').CheckStatus, retriedToPass?: boolean}[]}
|
|
467
|
+
*/
|
|
468
|
+
function foldable(pictures, guards) {
|
|
469
|
+
return [
|
|
470
|
+
...pictures.map((p) => ({
|
|
471
|
+
name: p.name,
|
|
472
|
+
kind: /** @type {'picture'} */ ('picture'),
|
|
473
|
+
status: p.status,
|
|
474
|
+
retriedToPass: p.retriedToPass,
|
|
475
|
+
})),
|
|
476
|
+
...guards.map((g) => ({
|
|
477
|
+
name: g.name,
|
|
478
|
+
kind: /** @type {'guard'} */ ('guard'),
|
|
479
|
+
status: g.status,
|
|
480
|
+
// The guard runner records this the same way the picture runner does; it
|
|
481
|
+
// just is not part of the shared result shape.
|
|
482
|
+
retriedToPass: /** @type {{retriedToPass?: boolean}} */ (g).retriedToPass,
|
|
483
|
+
})),
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @param {PictureRunResult[]} pictures
|
|
489
|
+
* @param {import('./types.js').GuardResult[]} guards
|
|
490
|
+
* @returns {import('./types.js').RunSummary['totals']}
|
|
491
|
+
*/
|
|
492
|
+
function countUp(pictures, guards) {
|
|
493
|
+
const totals = { passed: 0, changed: 0, new: 0, failed: 0, missing: 0, skipped: 0 };
|
|
494
|
+
for (const status of [...pictures.map((p) => p.status), ...guards.map((g) => g.status)]) {
|
|
495
|
+
switch (status) {
|
|
496
|
+
case 'passed':
|
|
497
|
+
totals.passed += 1;
|
|
498
|
+
break;
|
|
499
|
+
case 'changed':
|
|
500
|
+
totals.changed += 1;
|
|
501
|
+
break;
|
|
502
|
+
case 'new':
|
|
503
|
+
totals.new += 1;
|
|
504
|
+
break;
|
|
505
|
+
case 'failed':
|
|
506
|
+
totals.failed += 1;
|
|
507
|
+
break;
|
|
508
|
+
case 'missing':
|
|
509
|
+
totals.missing += 1;
|
|
510
|
+
break;
|
|
511
|
+
case 'skipped':
|
|
512
|
+
totals.skipped += 1;
|
|
513
|
+
break;
|
|
514
|
+
// 'flaky' is what the history says about a check over time, never a
|
|
515
|
+
// verdict one run hands down, so it is not a column here.
|
|
516
|
+
case 'flaky':
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return totals;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A sortable stamp off the local clock, so runs read in order in a folder
|
|
525
|
+
* listing and a person recognises the time they ran it.
|
|
526
|
+
* @param {Date} at
|
|
527
|
+
* @returns {string}
|
|
528
|
+
*/
|
|
529
|
+
function runId(at) {
|
|
530
|
+
const pad = (/** @type {number} */ n) => String(n).padStart(2, '0');
|
|
531
|
+
return (
|
|
532
|
+
`${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}` +
|
|
533
|
+
`-${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @param {string[]|undefined} only
|
|
539
|
+
* @returns {string[]|null}
|
|
540
|
+
*/
|
|
541
|
+
function normaliseOnly(only) {
|
|
542
|
+
if (!only) return null;
|
|
543
|
+
const terms = only.map((t) => String(t ?? '').trim()).filter(Boolean);
|
|
544
|
+
return terms.length > 0 ? terms : null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* `--only sessions` should find "sessions-empty" as well as "sessions", because
|
|
549
|
+
* nobody types a screen name out exactly, and nobody should have to.
|
|
550
|
+
* @param {string} name
|
|
551
|
+
* @param {string} term
|
|
552
|
+
* @returns {boolean}
|
|
553
|
+
*/
|
|
554
|
+
function matches(name, term) {
|
|
555
|
+
const haystack = name.toLowerCase();
|
|
556
|
+
const needle = term.toLowerCase();
|
|
557
|
+
return haystack === needle || haystack.includes(needle);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {import('./types.js').ResolvedConfig} config
|
|
562
|
+
* @param {import('./types.js').Guard[]} guards
|
|
563
|
+
* @returns {string}
|
|
564
|
+
*/
|
|
565
|
+
function nameHint(config, guards) {
|
|
566
|
+
const screens = config.screens.map((s) => s.name);
|
|
567
|
+
const parts = [];
|
|
568
|
+
if (screens.length > 0) parts.push(`Screens: ${screens.join(', ')}.`);
|
|
569
|
+
if (guards.length > 0) parts.push(`Guards: ${guards.map((g) => `"${g.name}"`).join(', ')}.`);
|
|
570
|
+
if (parts.length === 0) return 'This project has no screens and no guards yet.';
|
|
571
|
+
return parts.join(' ');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @param {import('./types.js').ProjectPaths} paths
|
|
576
|
+
* @returns {string}
|
|
577
|
+
*/
|
|
578
|
+
function lastRunFile(paths) {
|
|
579
|
+
return path.join(paths.results, LAST_RUN);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* @param {import('./types.js').ProjectPaths} paths
|
|
584
|
+
* @returns {Promise<import('./types.js').RunSummary|null>}
|
|
585
|
+
*/
|
|
586
|
+
async function readLastRun(paths) {
|
|
587
|
+
try {
|
|
588
|
+
const parsed = JSON.parse(await fsp.readFile(lastRunFile(paths), 'utf8'));
|
|
589
|
+
return parsed && typeof parsed === 'object' ? /** @type {import('./types.js').RunSummary} */ (parsed) : null;
|
|
590
|
+
} catch {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* @param {string} file
|
|
597
|
+
* @returns {Promise<boolean>}
|
|
598
|
+
*/
|
|
599
|
+
async function exists(file) {
|
|
600
|
+
try {
|
|
601
|
+
await fsp.access(file);
|
|
602
|
+
return true;
|
|
603
|
+
} catch {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* @param {string[]} list
|
|
610
|
+
* @returns {string[]}
|
|
611
|
+
*/
|
|
612
|
+
function unique(list) {
|
|
613
|
+
return [...new Set(list)];
|
|
614
|
+
}
|