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
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything Stays Fixed says in a terminal.
|
|
3
|
+
*
|
|
4
|
+
* The reader is not always a programmer, and never wants to be one at 1am, so
|
|
5
|
+
* there are no stack traces, no check ids and no "assertion failed" here. Each
|
|
6
|
+
* line says what happened and, when something is wrong, what to do about it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
say,
|
|
11
|
+
detail,
|
|
12
|
+
warn,
|
|
13
|
+
fail,
|
|
14
|
+
ok,
|
|
15
|
+
blank,
|
|
16
|
+
heading,
|
|
17
|
+
table,
|
|
18
|
+
paint,
|
|
19
|
+
mark,
|
|
20
|
+
duration,
|
|
21
|
+
shortPath,
|
|
22
|
+
isVerbose,
|
|
23
|
+
} from '../core/log.js';
|
|
24
|
+
import { isExpected, messageOf } from '../core/errors.js';
|
|
25
|
+
import { wobbly } from '../core/history.js';
|
|
26
|
+
|
|
27
|
+
/** Names line up in a column so the eye can scan the outcomes. */
|
|
28
|
+
const NAME_WIDTH = 30;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The symbol column is padded, not the name: without colour `mark.pass` is two
|
|
32
|
+
* characters wide and everything after it would sit one space out of line.
|
|
33
|
+
* @param {string} symbol
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function sym(symbol) {
|
|
37
|
+
return symbol.padEnd(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How many approve commands to spell out before summarising the rest. */
|
|
41
|
+
const MAX_COMMANDS = 10;
|
|
42
|
+
|
|
43
|
+
/** How many touched files a trace prints before summarising the rest. */
|
|
44
|
+
const MAX_TRACE_FILES = 12;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thousands separators, always. "1024 pixels" reads as noise; "1,024" reads as a number.
|
|
48
|
+
* @param {number} n
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function countText(n) {
|
|
52
|
+
const v = Number(n);
|
|
53
|
+
if (!Number.isFinite(v)) return '0';
|
|
54
|
+
return Math.round(v).toLocaleString('en-US');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A total a person would actually say out loud. Never a clock range.
|
|
59
|
+
* @param {number} ms
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function plainTime(ms) {
|
|
63
|
+
const v = Number(ms);
|
|
64
|
+
if (!Number.isFinite(v) || v < 0) return 'no time at all';
|
|
65
|
+
if (v < 1500) return 'under a second';
|
|
66
|
+
if (v < 60_000) return `about ${Math.round(v / 1000)} seconds`;
|
|
67
|
+
const minutes = Math.round(v / 60_000);
|
|
68
|
+
if (minutes === 1) return 'about a minute';
|
|
69
|
+
if (minutes < 60) return `about ${minutes} minutes`;
|
|
70
|
+
const hours = Math.round(minutes / 60);
|
|
71
|
+
return hours === 1 ? 'about an hour' : `about ${hours} hours`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {number} n
|
|
76
|
+
* @param {string} one
|
|
77
|
+
* @param {string} many
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function plural(n, one, many) {
|
|
81
|
+
return n === 1 ? one : many;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} iso
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function ago(iso) {
|
|
89
|
+
const then = Date.parse(String(iso));
|
|
90
|
+
if (Number.isNaN(then)) return 'at an unknown time';
|
|
91
|
+
const minutes = Math.round((Date.now() - then) / 60_000);
|
|
92
|
+
if (minutes < 1) return 'just now';
|
|
93
|
+
if (minutes < 60) return `${countText(minutes)} ${plural(minutes, 'minute', 'minutes')} ago`;
|
|
94
|
+
const hours = Math.round(minutes / 60);
|
|
95
|
+
if (hours < 24) return `${countText(hours)} ${plural(hours, 'hour', 'hours')} ago`;
|
|
96
|
+
const days = Math.round(hours / 24);
|
|
97
|
+
if (days <= 30) return `${countText(days)} ${plural(days, 'day', 'days')} ago`;
|
|
98
|
+
return `on ${new Date(then).toISOString().slice(0, 10)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} s
|
|
103
|
+
* @param {number} max
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function shorten(s, max) {
|
|
107
|
+
const text = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
108
|
+
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The counts every verdict is built from. Taken from the results themselves
|
|
113
|
+
* rather than `totals`, so the sentence can never disagree with the list above it.
|
|
114
|
+
* @param {import('../types.js').RunSummary} run
|
|
115
|
+
*/
|
|
116
|
+
function tally(run) {
|
|
117
|
+
const pictures = run.pictures ?? [];
|
|
118
|
+
const guards = run.guards ?? [];
|
|
119
|
+
return {
|
|
120
|
+
pictures: pictures.length,
|
|
121
|
+
guards: guards.length,
|
|
122
|
+
changed: pictures.filter((p) => p.status === 'changed').length,
|
|
123
|
+
fresh: pictures.filter((p) => p.status === 'new').length,
|
|
124
|
+
missing: pictures.filter((p) => p.status === 'missing').length,
|
|
125
|
+
broken: pictures.filter((p) => p.status === 'failed').length,
|
|
126
|
+
wobbled: pictures.filter((p) => p.status === 'flaky').length,
|
|
127
|
+
guardsFailed: guards.filter((g) => g.status === 'failed').length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The one-line verdict, in the words a human would use.
|
|
133
|
+
* @param {import('../types.js').RunSummary} run
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function verdictFor(run) {
|
|
137
|
+
const t = tally(run);
|
|
138
|
+
/** @type {{n: number, text: string}[]} */
|
|
139
|
+
const parts = [];
|
|
140
|
+
if (t.guardsFailed === 1) parts.push({ n: 1, text: '1 guard failed — a bug that was already fixed is back.' });
|
|
141
|
+
else if (t.guardsFailed > 1) parts.push({ n: t.guardsFailed, text: `${countText(t.guardsFailed)} guards failed — bugs that were already fixed are back.` });
|
|
142
|
+
if (t.changed === 1) parts.push({ n: 1, text: '1 thing changed. Look at it before you ship.' });
|
|
143
|
+
else if (t.changed > 1) parts.push({ n: t.changed, text: `${countText(t.changed)} things changed. Look at them before you ship.` });
|
|
144
|
+
if (t.fresh === 1) parts.push({ n: 1, text: '1 new screen is waiting for a person to approve it.' });
|
|
145
|
+
else if (t.fresh > 1) parts.push({ n: t.fresh, text: `${countText(t.fresh)} new screens are waiting for a person to approve them.` });
|
|
146
|
+
if (t.missing === 1) parts.push({ n: 1, text: '1 approved picture has gone missing.' });
|
|
147
|
+
else if (t.missing > 1) parts.push({ n: t.missing, text: `${countText(t.missing)} approved pictures have gone missing.` });
|
|
148
|
+
if (t.broken === 1) parts.push({ n: 1, text: '1 screen could not be photographed at all.' });
|
|
149
|
+
else if (t.broken > 1) parts.push({ n: t.broken, text: `${countText(t.broken)} screens could not be photographed at all.` });
|
|
150
|
+
if (parts.length === 0 && t.wobbled > 0) {
|
|
151
|
+
parts.push({ n: t.wobbled, text: `${countText(t.wobbled)} ${plural(t.wobbled, 'check', 'checks')} could not make up ${plural(t.wobbled, 'its', 'their')} mind.` });
|
|
152
|
+
}
|
|
153
|
+
if (parts.length === 0) return 'Everything that worked still works.';
|
|
154
|
+
// Two sentences is as much as anyone reads standing up; the table underneath
|
|
155
|
+
// still names every single one, so nothing is hidden by shortening this.
|
|
156
|
+
if (parts.length <= 2) return parts.map((p) => p.text).join(' ');
|
|
157
|
+
const rest = parts.slice(2).reduce((sum, p) => sum + p.n, 0);
|
|
158
|
+
return `${parts[0].text} ${parts[1].text} And ${countText(rest)} other ${plural(rest, 'screen needs', 'screens need')} a look.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {import('../types.js').RunSummary} run
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function allClear(run) {
|
|
166
|
+
const t = tally(run);
|
|
167
|
+
return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed === 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {import('../types.js').PictureResult} p
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function pictureOutcome(p) {
|
|
175
|
+
switch (p.status) {
|
|
176
|
+
case 'passed':
|
|
177
|
+
return 'still the same';
|
|
178
|
+
case 'changed':
|
|
179
|
+
return `looks different — ${countText(p.diffPixels ?? 0)} ${plural(p.diffPixels ?? 0, 'pixel', 'pixels')} changed`;
|
|
180
|
+
case 'new':
|
|
181
|
+
return 'nobody has approved this picture yet';
|
|
182
|
+
case 'missing':
|
|
183
|
+
return 'the approved picture is gone';
|
|
184
|
+
case 'failed':
|
|
185
|
+
return p.message || 'could not be photographed';
|
|
186
|
+
case 'flaky':
|
|
187
|
+
return 'changed its mind between tries';
|
|
188
|
+
case 'skipped':
|
|
189
|
+
return 'left out on purpose';
|
|
190
|
+
default:
|
|
191
|
+
return String(p.status);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {import('../types.js').PictureResult} p
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function whereToLook(p) {
|
|
200
|
+
const file = p.diffPath || p.actualPath || p.approvedPath;
|
|
201
|
+
return file ? shortPath(file) : '';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One line as each screen finishes.
|
|
206
|
+
* @param {import('../types.js').PictureResult} r
|
|
207
|
+
* @returns {void}
|
|
208
|
+
*/
|
|
209
|
+
export function printPictureResult(r) {
|
|
210
|
+
const time = paint.grey(duration(r.durationMs ?? 0));
|
|
211
|
+
const line = `${r.name.padEnd(NAME_WIDTH)} ${pictureOutcome(r)}`;
|
|
212
|
+
switch (r.status) {
|
|
213
|
+
case 'passed':
|
|
214
|
+
say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${paint.grey('still the same')} ${time}`);
|
|
215
|
+
break;
|
|
216
|
+
case 'changed':
|
|
217
|
+
say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
|
|
218
|
+
if (r.approvedSize && r.size && (r.approvedSize.width !== r.size.width || r.approvedSize.height !== r.size.height)) {
|
|
219
|
+
say(paint.red(` it is a different size now: ${r.approvedSize.width}×${r.approvedSize.height} ${mark.arrow} ${r.size.width}×${r.size.height}`));
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case 'new':
|
|
223
|
+
say(`${paint.yellow(sym(mark.warn))} ${paint.yellow(line)} ${time}`);
|
|
224
|
+
say(paint.yellow(` look at it, then run: staysfixed approve ${r.name}`));
|
|
225
|
+
break;
|
|
226
|
+
case 'missing':
|
|
227
|
+
say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
|
|
228
|
+
say(paint.red(` take a new one and approve it: staysfixed approve ${r.name}`));
|
|
229
|
+
break;
|
|
230
|
+
case 'failed':
|
|
231
|
+
say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
|
|
232
|
+
break;
|
|
233
|
+
case 'flaky':
|
|
234
|
+
say(`${paint.yellow(sym(mark.warn))} ${paint.yellow(line)} ${time}`);
|
|
235
|
+
break;
|
|
236
|
+
case 'skipped':
|
|
237
|
+
say(`${paint.grey(sym(mark.info))} ${paint.grey(line)}`);
|
|
238
|
+
break;
|
|
239
|
+
default:
|
|
240
|
+
say(`${sym(mark.info)} ${line} ${time}`);
|
|
241
|
+
}
|
|
242
|
+
for (const message of r.consoleErrors ?? []) detail(` the app logged: ${shorten(message, 160)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One line per guard; a failure also tells the story of the bug it watches.
|
|
247
|
+
* @param {import('../types.js').GuardResult} r
|
|
248
|
+
* @returns {void}
|
|
249
|
+
*/
|
|
250
|
+
export function printGuardResult(r) {
|
|
251
|
+
const time = paint.grey(duration(r.durationMs ?? 0));
|
|
252
|
+
const name = r.name.padEnd(NAME_WIDTH);
|
|
253
|
+
if (r.status === 'passed') {
|
|
254
|
+
say(`${paint.green(sym(mark.pass))} ${name} ${paint.grey('still holds')} ${time}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (r.status === 'skipped') {
|
|
258
|
+
say(`${paint.grey(sym(mark.info))} ${paint.grey(`${name} left out on purpose`)}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
say(`${paint.red(sym(mark.fail))} ${paint.red(`${name} ${r.message || 'this one is broken again'}`)} ${time}`);
|
|
262
|
+
if (r.failedAt) say(paint.red(` expected: ${r.failedAt}`));
|
|
263
|
+
if (r.because) say(paint.grey(` why this guard exists: ${r.because}`));
|
|
264
|
+
if (r.file) detail(` ${shortPath(r.file)}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The closing block of `staysfixed check`.
|
|
269
|
+
* @param {import('../types.js').RunSummary} run
|
|
270
|
+
* @param {import('../types.js').Project} [project]
|
|
271
|
+
* @returns {void}
|
|
272
|
+
*/
|
|
273
|
+
export function printRunSummary(run, project) {
|
|
274
|
+
const pictures = run.pictures ?? [];
|
|
275
|
+
const guards = run.guards ?? [];
|
|
276
|
+
const verdict = verdictFor(run);
|
|
277
|
+
|
|
278
|
+
blank();
|
|
279
|
+
if (allClear(run)) ok(verdict);
|
|
280
|
+
else fail(verdict);
|
|
281
|
+
|
|
282
|
+
const counted = [];
|
|
283
|
+
if (pictures.length) counted.push(`${countText(pictures.length)} ${plural(pictures.length, 'screen', 'screens')}`);
|
|
284
|
+
if (guards.length) counted.push(`${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}`);
|
|
285
|
+
if (counted.length) say(paint.grey(` ${counted.join(', ')}, ${plainTime(run.durationMs ?? 0)}.`));
|
|
286
|
+
|
|
287
|
+
/** @type {string[][]} */
|
|
288
|
+
const rows = [];
|
|
289
|
+
for (const p of pictures) {
|
|
290
|
+
if (p.status === 'passed' || p.status === 'skipped') continue;
|
|
291
|
+
rows.push([p.name, pictureOutcome(p), paint.grey(whereToLook(p))]);
|
|
292
|
+
}
|
|
293
|
+
for (const g of guards) {
|
|
294
|
+
if (g.status === 'passed' || g.status === 'skipped') continue;
|
|
295
|
+
const what = g.failedAt ? `expected: ${g.failedAt}` : g.message || 'this one is broken again';
|
|
296
|
+
rows.push([g.name, what, paint.grey(g.file ? shortPath(g.file) : '')]);
|
|
297
|
+
}
|
|
298
|
+
if (rows.length) {
|
|
299
|
+
heading('What is not right');
|
|
300
|
+
table([[paint.grey('name'), paint.grey('what happened'), paint.grey('where to look')], ...rows], { indent: 2 });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const stubborn = run.condemned ?? [];
|
|
304
|
+
if (stubborn.length) {
|
|
305
|
+
heading(paint.red('These checks keep changing their mind'));
|
|
306
|
+
for (const name of stubborn) say(paint.red(` ${mark.warn} ${name}`));
|
|
307
|
+
say(paint.grey(' Fix them or delete them. A check that cannot make up its mind is never worth tolerating.'));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const needApproval = pictures.filter((p) => p.status === 'changed' || p.status === 'new' || p.status === 'missing');
|
|
311
|
+
if (needApproval.length || project) heading('What to do next');
|
|
312
|
+
if (needApproval.length) {
|
|
313
|
+
say(' Look at each picture in the report. If the new one is what you meant, approve it:');
|
|
314
|
+
for (const p of needApproval.slice(0, MAX_COMMANDS)) {
|
|
315
|
+
say(` ${paint.cyan(`staysfixed approve ${p.name}`)}`);
|
|
316
|
+
}
|
|
317
|
+
const rest = needApproval.length - MAX_COMMANDS;
|
|
318
|
+
if (rest > 0) say(paint.grey(` ...and ${countText(rest)} more`));
|
|
319
|
+
if (needApproval.length > 1) say(` Or accept every one of them: ${paint.cyan('staysfixed approve --all')}`);
|
|
320
|
+
}
|
|
321
|
+
if (project) {
|
|
322
|
+
say(` The pictures, side by side: ${paint.cyan(shortPath(project.paths.reportFile))}`);
|
|
323
|
+
}
|
|
324
|
+
blank();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The closing block of `staysfixed walk`.
|
|
329
|
+
* @param {import('../types.js').WalkReport} report
|
|
330
|
+
* @returns {void}
|
|
331
|
+
*/
|
|
332
|
+
export function printWalkReport(report) {
|
|
333
|
+
const steps = report.steps ?? [];
|
|
334
|
+
const broken = steps.filter((s) => Boolean(s.error));
|
|
335
|
+
blank();
|
|
336
|
+
if (report.ok && broken.length === 0) {
|
|
337
|
+
ok(`Walked ${countText(steps.length)} ${plural(steps.length, 'screen', 'screens')} and every one of them opened.`);
|
|
338
|
+
} else {
|
|
339
|
+
fail(`${countText(broken.length)} of ${countText(steps.length)} ${plural(steps.length, 'screen', 'screens')} did not open properly.`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (broken.length) {
|
|
343
|
+
heading('Where it went wrong');
|
|
344
|
+
table(
|
|
345
|
+
broken.map((s) => [s.name, shorten(s.error ?? 'did not open', 70), paint.grey(s.file ? shortPath(s.file) : '')]),
|
|
346
|
+
{ indent: 2 },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const noisy = steps.filter((s) => (s.consoleErrors?.length ?? 0) > 0);
|
|
351
|
+
if (noisy.length) {
|
|
352
|
+
warn(`${countText(noisy.length)} ${plural(noisy.length, 'screen', 'screens')} printed errors of ${plural(noisy.length, 'its', 'their')} own while open.`);
|
|
353
|
+
for (const s of noisy) detail(` ${s.name}: ${shorten((s.consoleErrors ?? [])[0] ?? '', 160)}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const sheet = report.reportFile || report.dir;
|
|
357
|
+
if (sheet) say(` Every screen it photographed: ${paint.cyan(shortPath(sheet))}`);
|
|
358
|
+
blank();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @param {import('../types.js').TraceFinding} f
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function traceSentence(f) {
|
|
366
|
+
if (f.message) return f.message;
|
|
367
|
+
const good = f.lastGood ? `"${f.lastGood.label}"` : null;
|
|
368
|
+
const bad = f.firstBad ? `"${f.firstBad.label}"` : null;
|
|
369
|
+
if (f.verdict === 'unchanged') {
|
|
370
|
+
return good
|
|
371
|
+
? `This looks exactly as it did at ${good}, so nothing here has drifted.`
|
|
372
|
+
: 'This looks the same everywhere I have a record of it.';
|
|
373
|
+
}
|
|
374
|
+
if (f.verdict === 'changed') {
|
|
375
|
+
if (good && bad) return `It was still right at ${good} and already different by ${bad}. The change is in between.`;
|
|
376
|
+
if (good) return `It was still right at ${good} and is different now.`;
|
|
377
|
+
return 'It is different from every record I have.';
|
|
378
|
+
}
|
|
379
|
+
return 'There is no marker recording this one, so there is nothing to compare it against.';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The output of `staysfixed trace`.
|
|
384
|
+
* @param {import('../types.js').TraceReport} report
|
|
385
|
+
* @returns {void}
|
|
386
|
+
*/
|
|
387
|
+
export function printTrace(report) {
|
|
388
|
+
const findings = report.findings ?? [];
|
|
389
|
+
blank();
|
|
390
|
+
if (report.message) say(report.message);
|
|
391
|
+
if (findings.length === 0) {
|
|
392
|
+
say('There is nothing to trace yet. Pin a good version first with `staysfixed mark`.');
|
|
393
|
+
blank();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const f of findings) {
|
|
398
|
+
heading(f.name);
|
|
399
|
+
say(` ${traceSentence(f)}`);
|
|
400
|
+
const commits = f.commits ?? [];
|
|
401
|
+
if (commits.length) {
|
|
402
|
+
say(paint.grey(` ${countText(commits.length)} ${plural(commits.length, 'commit', 'commits')} landed in between:`));
|
|
403
|
+
table(
|
|
404
|
+
commits.map((c) => [paint.grey(c.shortSha), paint.grey(c.date), shorten(c.subject, 62), paint.grey(c.author)]),
|
|
405
|
+
{ indent: 4 },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const files = f.files ?? [];
|
|
409
|
+
if (files.length) {
|
|
410
|
+
say(paint.grey(' files those commits touched:'));
|
|
411
|
+
for (const file of files.slice(0, MAX_TRACE_FILES)) say(` ${file}`);
|
|
412
|
+
const rest = files.length - MAX_TRACE_FILES;
|
|
413
|
+
if (rest > 0) say(paint.grey(` ...and ${countText(rest)} more`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
blank();
|
|
418
|
+
say(paint.grey(`Looked through ${countText(report.markersSearched ?? 0)} ${plural(report.markersSearched ?? 0, 'marker', 'markers')}.`));
|
|
419
|
+
blank();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The flake register, for `staysfixed flake`.
|
|
424
|
+
* @param {import('../types.js').History} history
|
|
425
|
+
* @param {number} [flakeLimit]
|
|
426
|
+
* @returns {void}
|
|
427
|
+
*/
|
|
428
|
+
export function printFlakes(history, flakeLimit = 2) {
|
|
429
|
+
const entries = wobbly(history ?? {});
|
|
430
|
+
blank();
|
|
431
|
+
if (entries.length === 0) {
|
|
432
|
+
ok('No check here has ever changed its mind. That is exactly how it should be.');
|
|
433
|
+
blank();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
heading('Checks that have changed their mind');
|
|
438
|
+
/** @type {string[][]} */
|
|
439
|
+
const rows = [[paint.grey('check'), paint.grey('wobbled'), paint.grey('last time'), paint.grey('verdict')]];
|
|
440
|
+
for (const e of entries) {
|
|
441
|
+
const left = Math.max(1, flakeLimit - e.flakes);
|
|
442
|
+
rows.push([
|
|
443
|
+
e.name,
|
|
444
|
+
`${countText(e.flakes)} ${plural(e.flakes, 'time', 'times')}`,
|
|
445
|
+
e.lastFlakeAt ? ago(e.lastFlakeAt) : 'unknown',
|
|
446
|
+
e.condemned
|
|
447
|
+
? paint.red('fix it or delete it')
|
|
448
|
+
: paint.yellow(`${countText(left)} more and it gets condemned`),
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
451
|
+
table(rows, { indent: 2 });
|
|
452
|
+
|
|
453
|
+
if (entries.some((e) => e.condemned)) {
|
|
454
|
+
blank();
|
|
455
|
+
say(paint.red('A condemned check is not a warning to live with. Fix it, or delete it.'));
|
|
456
|
+
say(paint.grey('Once it is genuinely fixed, forgive it with: ') + paint.cyan('staysfixed flake --clear <name>'));
|
|
457
|
+
}
|
|
458
|
+
blank();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* What `staysfixed status` prints. The CLI gathers this; the shape is deliberately
|
|
463
|
+
* forgiving so it can report on a half-set-up project without special cases.
|
|
464
|
+
*
|
|
465
|
+
* @typedef {object} StatusInfo
|
|
466
|
+
* @property {number} [approved] Approved pictures on disk.
|
|
467
|
+
* @property {number} [screens] Screens named in the config.
|
|
468
|
+
* @property {number} [guards] Guards loaded from the guards folder.
|
|
469
|
+
* @property {number} [markers] Known-good markers saved.
|
|
470
|
+
* @property {{label: string, at?: string}|null} [lastMarker]
|
|
471
|
+
* @property {import('../types.js').RunSummary|null} [lastRun]
|
|
472
|
+
* @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
|
|
473
|
+
* @property {string} [configFile]
|
|
474
|
+
* @property {string} [root]
|
|
475
|
+
*/
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @param {StatusInfo} status
|
|
479
|
+
* @returns {void}
|
|
480
|
+
*/
|
|
481
|
+
export function printStatus(status) {
|
|
482
|
+
const s = /** @type {StatusInfo} */ (status || {});
|
|
483
|
+
heading('Stays Fixed');
|
|
484
|
+
if (s.root) say(paint.grey(` watching ${shortPath(s.root)}`));
|
|
485
|
+
if (s.configFile) say(paint.grey(` settings in ${shortPath(s.configFile)}`));
|
|
486
|
+
|
|
487
|
+
/** @type {string[][]} */
|
|
488
|
+
const rows = [];
|
|
489
|
+
rows.push([`${countText(s.approved ?? 0)}`, plural(s.approved ?? 0, 'approved picture', 'approved pictures')]);
|
|
490
|
+
if (s.screens !== undefined) rows.push([`${countText(s.screens)}`, `${plural(s.screens, 'screen', 'screens')} in the settings`]);
|
|
491
|
+
rows.push([`${countText(s.guards ?? 0)}`, plural(s.guards ?? 0, 'guard', 'guards')]);
|
|
492
|
+
rows.push([`${countText(s.markers ?? 0)}`, plural(s.markers ?? 0, 'known-good marker', 'known-good markers')]);
|
|
493
|
+
blank();
|
|
494
|
+
table(rows, { indent: 2 });
|
|
495
|
+
|
|
496
|
+
if (s.lastMarker) {
|
|
497
|
+
say(paint.grey(` newest marker: ${s.lastMarker.label}${s.lastMarker.at ? ` — pinned ${ago(s.lastMarker.at)}` : ''}`));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
blank();
|
|
501
|
+
const run = s.lastRun ?? null;
|
|
502
|
+
if (!run) {
|
|
503
|
+
say(' Nothing has been checked here yet.');
|
|
504
|
+
say(` Start with: ${paint.cyan('staysfixed check')}`);
|
|
505
|
+
} else {
|
|
506
|
+
const where = run.git?.shortSha ? ` at ${run.git.shortSha}${run.git.branch ? ` on ${run.git.branch}` : ''}` : '';
|
|
507
|
+
say(paint.grey(` last checked ${ago(run.startedAt)}${where}, took ${plainTime(run.durationMs ?? 0)}`));
|
|
508
|
+
if (allClear(run)) ok(verdictFor(run));
|
|
509
|
+
else fail(verdictFor(run));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const stubborn = s.condemned ?? run?.condemned ?? [];
|
|
513
|
+
if (stubborn.length) {
|
|
514
|
+
heading(paint.red('These checks keep changing their mind'));
|
|
515
|
+
for (const name of stubborn) say(paint.red(` ${mark.warn} ${name}`));
|
|
516
|
+
say(paint.grey(' Fix them or delete them. Never tolerate them.'));
|
|
517
|
+
}
|
|
518
|
+
blank();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Turn any thrown thing into something worth reading.
|
|
523
|
+
* @param {unknown} err
|
|
524
|
+
* @returns {void}
|
|
525
|
+
*/
|
|
526
|
+
export function errorReport(err) {
|
|
527
|
+
if (isExpected(err)) {
|
|
528
|
+
fail(err.message);
|
|
529
|
+
if (err.hint) warn(err.hint);
|
|
530
|
+
if (isVerbose() && err.cause instanceof Error) detail(err.cause.stack ?? err.cause.message);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
fail(messageOf(err));
|
|
534
|
+
warn('This is a bug in Stays Fixed, not something you did wrong.');
|
|
535
|
+
if (isVerbose() && err instanceof Error && err.stack) {
|
|
536
|
+
detail(err.stack);
|
|
537
|
+
} else {
|
|
538
|
+
warn('Run the same command again with --verbose for the technical details, then report it at https://github.com/asadev/staysfixed/issues');
|
|
539
|
+
}
|
|
540
|
+
}
|