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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The naming rule for guards, enforced instead of hoped for.
|
|
3
|
+
*
|
|
4
|
+
* A guard outlives the memory of the bug it was written for. The name is the
|
|
5
|
+
* whole handover: it is what fails in CI, what an agent reads back, and what a
|
|
6
|
+
* person has to judge in five seconds at midnight. So the tool refuses names
|
|
7
|
+
* that only make sense to whoever typed them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} NameVerdict
|
|
12
|
+
* @property {boolean} ok
|
|
13
|
+
* @property {string} [why] Plain-language reason it was refused.
|
|
14
|
+
* @property {string} [suggestion] A rewrite, only when we can honestly offer one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Why the rule exists. Printed by the CLI whenever a name is refused. */
|
|
18
|
+
export const NAME_RULE_EXPLAINER =
|
|
19
|
+
'A guard name is the only thing that still makes sense six months from now. ' +
|
|
20
|
+
'It is what gets printed when the guard fails, what goes in the report, and what an agent reads ' +
|
|
21
|
+
'before deciding whether it broke something. So write what the app is supposed to do, in the words ' +
|
|
22
|
+
'you would say out loud: "the sidebar still collapses", "prices still show two decimals", ' +
|
|
23
|
+
'"logging out clears the session". A name like sidebar_collapse_test tells the next person nothing ' +
|
|
24
|
+
'about what broke or whether it matters. Three plain words minimum, present tense, no test ids.';
|
|
25
|
+
|
|
26
|
+
const MAX_LENGTH = 120;
|
|
27
|
+
|
|
28
|
+
/** Words that start a test name rather than describe the app. */
|
|
29
|
+
const TEST_SPEAK = new Set([
|
|
30
|
+
'test',
|
|
31
|
+
'tests',
|
|
32
|
+
'it',
|
|
33
|
+
'should',
|
|
34
|
+
'shall',
|
|
35
|
+
'verify',
|
|
36
|
+
'verifies',
|
|
37
|
+
'check',
|
|
38
|
+
'checks',
|
|
39
|
+
'assert',
|
|
40
|
+
'asserts',
|
|
41
|
+
'ensure',
|
|
42
|
+
'ensures',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Words that add nothing once the name is a sentence. */
|
|
46
|
+
const NOISE = new Set([
|
|
47
|
+
'test',
|
|
48
|
+
'tests',
|
|
49
|
+
'testing',
|
|
50
|
+
'spec',
|
|
51
|
+
'specs',
|
|
52
|
+
'should',
|
|
53
|
+
'shall',
|
|
54
|
+
'it',
|
|
55
|
+
'verify',
|
|
56
|
+
'verifies',
|
|
57
|
+
'verified',
|
|
58
|
+
'check',
|
|
59
|
+
'checks',
|
|
60
|
+
'checked',
|
|
61
|
+
'assert',
|
|
62
|
+
'asserts',
|
|
63
|
+
'ensure',
|
|
64
|
+
'ensures',
|
|
65
|
+
'case',
|
|
66
|
+
'cases',
|
|
67
|
+
'regression',
|
|
68
|
+
'regressions',
|
|
69
|
+
'bug',
|
|
70
|
+
'bugs',
|
|
71
|
+
'guard',
|
|
72
|
+
'guards',
|
|
73
|
+
'e2e',
|
|
74
|
+
'unit',
|
|
75
|
+
'snapshot',
|
|
76
|
+
'fix',
|
|
77
|
+
'fixed',
|
|
78
|
+
'fixes',
|
|
79
|
+
'still',
|
|
80
|
+
'again',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const ARTICLES = new Set(['the', 'a', 'an']);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verbs a user interface actually does. The rewriter only offers a sentence when
|
|
87
|
+
* it can find one of these, because guessing a verb out of a noun produces
|
|
88
|
+
* confident nonsense ("the login form still validations") and people accept
|
|
89
|
+
* suggestions without reading them.
|
|
90
|
+
*/
|
|
91
|
+
const VERBS = new Set([
|
|
92
|
+
'align', 'appear', 'apply', 'build', 'cancel', 'clear', 'close', 'collapse', 'connect', 'copy',
|
|
93
|
+
'delete', 'disappear', 'disconnect', 'download', 'drag', 'drop', 'exit', 'expand', 'export',
|
|
94
|
+
'fit', 'filter', 'focus', 'format', 'highlight', 'hide', 'hold', 'import', 'install', 'keep',
|
|
95
|
+
'launch', 'load', 'log', 'match', 'mount', 'navigate', 'open', 'paginate', 'parse', 'paste',
|
|
96
|
+
'persist', 'print', 'reconnect', 'redirect', 'redo', 'refresh', 'remain', 'render', 'reset',
|
|
97
|
+
'resize', 'restore', 'resume', 'retry', 'return', 'run', 'save', 'scroll', 'search', 'select',
|
|
98
|
+
'show', 'sign', 'sort', 'start', 'stay', 'stop', 'submit', 'sync', 'toggle', 'undo', 'update',
|
|
99
|
+
'upload', 'validate', 'work', 'wrap',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'} RefusalKind
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is this name acceptable, and if not, why — and can we offer a rewrite?
|
|
108
|
+
*
|
|
109
|
+
* @param {unknown} name
|
|
110
|
+
* @returns {NameVerdict}
|
|
111
|
+
*/
|
|
112
|
+
export function checkGuardName(name) {
|
|
113
|
+
const refusal = refuse(name);
|
|
114
|
+
if (!refusal) return { ok: true };
|
|
115
|
+
const suggestion = suggestFor(typeof name === 'string' ? name : '', refusal.kind);
|
|
116
|
+
return suggestion ? { ok: false, why: refusal.why, suggestion } : { ok: false, why: refusal.why };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The rule itself, with no rewriting. Kept separate so a suggested rewrite can be
|
|
121
|
+
* run back through it without ever recursing into the suggester.
|
|
122
|
+
*
|
|
123
|
+
* @param {unknown} name
|
|
124
|
+
* @returns {{kind: RefusalKind, why: string}|null}
|
|
125
|
+
*/
|
|
126
|
+
function refuse(name) {
|
|
127
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
128
|
+
return {
|
|
129
|
+
kind: 'empty',
|
|
130
|
+
why: 'A guard needs a name. Give it one plain sentence saying what should still be true, like "the sidebar still collapses".',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = name.trim();
|
|
135
|
+
|
|
136
|
+
if (text.length > MAX_LENGTH) {
|
|
137
|
+
return {
|
|
138
|
+
kind: 'long',
|
|
139
|
+
why: `That name is ${text.length} characters long. Keep it under ${MAX_LENGTH} — a guard name is a short sentence, not a paragraph. Put the story of the bug in "because" instead.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (/[\\/]/.test(text) || /\.(js|mjs|cjs|ts|tsx|jsx|json|py|rb|go|rs)$/i.test(text)) {
|
|
144
|
+
return {
|
|
145
|
+
kind: 'path',
|
|
146
|
+
why: 'That looks like a file name, not a description. Say what the app should still do, not where the code that does it lives.',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (/^#?[A-Za-z]{0,8}[-_ #]?\d+$/.test(text)) {
|
|
151
|
+
return {
|
|
152
|
+
kind: 'id',
|
|
153
|
+
why: 'That is an issue number, not a description. The number will not tell anyone what broke — put it in "link" and use the name to say what should still work.',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (text.includes('#') || text.includes('::')) {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'symbols',
|
|
160
|
+
why: 'Names containing "#" or "::" read like code references. Write the behaviour in ordinary words, and put any issue or commit reference in "link".',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (/[A-Za-z]/.test(text) && text === text.toUpperCase()) {
|
|
165
|
+
return {
|
|
166
|
+
kind: 'caps',
|
|
167
|
+
why: 'ALL CAPS reads like shouting, not like a sentence. Write it the way you would say it out loud.',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
172
|
+
|
|
173
|
+
// One token that carries word boundaries inside it — snake_case, kebab-case,
|
|
174
|
+
// SCREAMING_CASE or camelCase. These are identifiers, not sentences.
|
|
175
|
+
if (words.length === 1 && (/[_.-]/.test(text) || /[a-z][A-Z]/.test(text))) {
|
|
176
|
+
return {
|
|
177
|
+
kind: 'identifier',
|
|
178
|
+
why: 'That reads like a code identifier, not a sentence. Guard names are printed to people, so use spaces and ordinary words.',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const first = words[0].toLowerCase().replace(/[^a-z]/g, '');
|
|
183
|
+
if (words.length > 1 && TEST_SPEAK.has(first)) {
|
|
184
|
+
return {
|
|
185
|
+
kind: 'testspeak',
|
|
186
|
+
why: `Starting with "${words[0]}" describes a test, not the app. Drop the test word and say what should still be true.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (words.length < 3) {
|
|
191
|
+
return {
|
|
192
|
+
kind: 'short',
|
|
193
|
+
why: `That is only ${words.length} word${words.length === 1 ? '' : 's'}. Use at least three, so the name says what should still be true and not just which area it touches.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Offer a rewrite, but only one we would stand behind. Anything we cannot turn
|
|
202
|
+
* into a real sentence gets no suggestion at all — a wrong suggestion is worse
|
|
203
|
+
* than none, because people accept them.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} name
|
|
206
|
+
* @param {RefusalKind} kind
|
|
207
|
+
* @returns {string|undefined}
|
|
208
|
+
*/
|
|
209
|
+
function suggestFor(name, kind) {
|
|
210
|
+
// An issue number carries no behaviour at all, so there is nothing to rewrite from.
|
|
211
|
+
if (kind === 'empty' || kind === 'long' || kind === 'id') return undefined;
|
|
212
|
+
|
|
213
|
+
// Lower-casing keeps the author's own grammar, which beats anything we build.
|
|
214
|
+
if (kind === 'caps') {
|
|
215
|
+
const lowered = name.trim().toLowerCase();
|
|
216
|
+
if (!refuse(lowered)) return lowered;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const built = sentenceFrom(name);
|
|
220
|
+
if (built) return built;
|
|
221
|
+
|
|
222
|
+
if (kind === 'testspeak') {
|
|
223
|
+
const rest = name.trim().split(/\s+/).slice(1).join(' ');
|
|
224
|
+
if (rest && !refuse(rest)) return rest;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (kind === 'symbols') {
|
|
228
|
+
const stripped = name
|
|
229
|
+
.replace(/::/g, ' ')
|
|
230
|
+
.replace(/#\s*\d*/g, ' ')
|
|
231
|
+
.replace(/\s+/g, ' ')
|
|
232
|
+
.trim();
|
|
233
|
+
if (stripped && !refuse(stripped)) return stripped;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Turn `sidebar_collapse_test` into "the sidebar still collapses".
|
|
241
|
+
*
|
|
242
|
+
* Only attempted when a real verb can be found, and only when something is left
|
|
243
|
+
* in front of it to be the subject.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} raw
|
|
246
|
+
* @returns {string|undefined}
|
|
247
|
+
*/
|
|
248
|
+
function sentenceFrom(raw) {
|
|
249
|
+
let base = String(raw).trim();
|
|
250
|
+
base = base.split(/[\\/]/).pop() ?? base;
|
|
251
|
+
base = base.replace(/\.(js|mjs|cjs|ts|tsx|jsx|json|py|rb|go|rs)$/i, '');
|
|
252
|
+
|
|
253
|
+
let words = splitWords(base).filter((w) => !NOISE.has(w));
|
|
254
|
+
while (words.length > 0 && ARTICLES.has(words[0])) words = words.slice(1);
|
|
255
|
+
if (words.length < 2) return undefined;
|
|
256
|
+
|
|
257
|
+
let at = -1;
|
|
258
|
+
let stem = '';
|
|
259
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
260
|
+
const found = verbStem(words[i]);
|
|
261
|
+
if (found) {
|
|
262
|
+
at = i;
|
|
263
|
+
stem = found;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (at < 1) return undefined;
|
|
268
|
+
|
|
269
|
+
const subject = words.slice(0, at);
|
|
270
|
+
const tail = words.slice(at + 1);
|
|
271
|
+
// "the prices still show" — a plural subject takes the bare verb.
|
|
272
|
+
const last = subject[subject.length - 1];
|
|
273
|
+
const plural = /s$/.test(last) && !/(ss|us|is)$/.test(last);
|
|
274
|
+
const verb = plural ? stem : thirdPerson(stem);
|
|
275
|
+
|
|
276
|
+
const candidate = ['the', subject.join(' '), 'still', verb, tail.join(' ')]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(' ');
|
|
279
|
+
return refuse(candidate) ? undefined : candidate;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The plain form of a word if it is one of the verbs we know, else nothing.
|
|
284
|
+
* @param {string} word
|
|
285
|
+
* @returns {string|undefined}
|
|
286
|
+
*/
|
|
287
|
+
function verbStem(word) {
|
|
288
|
+
const tries = [
|
|
289
|
+
word,
|
|
290
|
+
word.replace(/s$/, ''),
|
|
291
|
+
word.replace(/es$/, ''),
|
|
292
|
+
word.replace(/ies$/, 'y'),
|
|
293
|
+
word.replace(/ing$/, ''),
|
|
294
|
+
word.replace(/ing$/, 'e'),
|
|
295
|
+
word.replace(/ed$/, ''),
|
|
296
|
+
];
|
|
297
|
+
for (const t of tries) if (t && VERBS.has(t)) return t;
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Split an identifier or phrase into lower-case words.
|
|
303
|
+
* @param {string} s
|
|
304
|
+
* @returns {string[]}
|
|
305
|
+
*/
|
|
306
|
+
function splitWords(s) {
|
|
307
|
+
return String(s)
|
|
308
|
+
.replace(/[_.\-#]+/g, ' ')
|
|
309
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
310
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
311
|
+
.toLowerCase()
|
|
312
|
+
.split(/\s+/)
|
|
313
|
+
.filter(Boolean);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* "collapse" -> "collapses", "apply" -> "applies", "match" -> "matches".
|
|
318
|
+
* Already-conjugated verbs are left alone.
|
|
319
|
+
* @param {string} word
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
322
|
+
function thirdPerson(word) {
|
|
323
|
+
if (/s$/.test(word) && !/(ss|us|is)$/.test(word)) return word;
|
|
324
|
+
if (/(s|x|z|ch|sh|o)$/.test(word)) return `${word}es`;
|
|
325
|
+
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
|
326
|
+
return `${word}s`;
|
|
327
|
+
}
|
package/src/guard/run.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running guards.
|
|
3
|
+
*
|
|
4
|
+
* A guard is a promise that a fixed bug stays fixed, so the run is arranged
|
|
5
|
+
* around one idea: the result must be trustworthy on its own. Every guard starts
|
|
6
|
+
* from the same clean state, a guard that needs a second go is recorded as
|
|
7
|
+
* wobbly rather than green, and the failure message carries the story of the
|
|
8
|
+
* original bug so nobody has to go looking for it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { makeGuardApi, ExpectationFailed } from './api.js';
|
|
12
|
+
import { resetWindow } from '../drive/launch.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('../types.js').GuardResult & {retriedToPass?: boolean}} GuardRunResult
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} AttemptOutcome
|
|
22
|
+
* @property {boolean} ok
|
|
23
|
+
* @property {string} [message]
|
|
24
|
+
* @property {string} [failedAt]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run every guard against an app that is already open.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('../types.js').Project} project
|
|
31
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
32
|
+
* @param {import('../types.js').Guard[]} guards
|
|
33
|
+
* @param {{onResult?: (result: GuardRunResult) => void, retries?: number, signal?: AbortSignal}} [opts]
|
|
34
|
+
* @returns {Promise<import('../types.js').GuardResult[]>}
|
|
35
|
+
*/
|
|
36
|
+
export async function runGuards(project, app, guards, opts = {}) {
|
|
37
|
+
const retries = Math.max(0, Math.trunc(opts.retries ?? 0));
|
|
38
|
+
|
|
39
|
+
// Electron apps have no address to go back to; for the web the configured url
|
|
40
|
+
// is the guard's starting line.
|
|
41
|
+
const baseUrl = project.config.app.kind === 'web' ? project.config.app.url : undefined;
|
|
42
|
+
|
|
43
|
+
/** @type {GuardRunResult[]} */
|
|
44
|
+
const results = [];
|
|
45
|
+
|
|
46
|
+
for (const guard of guards) {
|
|
47
|
+
// Between guards only — stopping one halfway would leave the app in a state
|
|
48
|
+
// the next run cannot reason about.
|
|
49
|
+
if (opts.signal?.aborted) break;
|
|
50
|
+
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
|
|
53
|
+
if (guard.skip === true) {
|
|
54
|
+
/** @type {GuardRunResult} */
|
|
55
|
+
const skipped = {
|
|
56
|
+
name: guard.name,
|
|
57
|
+
status: 'skipped',
|
|
58
|
+
message: 'Left out on purpose (this guard is marked skip).',
|
|
59
|
+
file: guard.file,
|
|
60
|
+
because: guard.because,
|
|
61
|
+
durationMs: Date.now() - startedAt,
|
|
62
|
+
attempts: 0,
|
|
63
|
+
};
|
|
64
|
+
results.push(skipped);
|
|
65
|
+
opts.onResult?.(skipped);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const timeoutMs = guard.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
70
|
+
/** @type {AttemptOutcome} */
|
|
71
|
+
let outcome = { ok: false, message: 'This guard did not run.' };
|
|
72
|
+
let attempts = 0;
|
|
73
|
+
|
|
74
|
+
while (attempts < retries + 1) {
|
|
75
|
+
attempts += 1;
|
|
76
|
+
outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs);
|
|
77
|
+
if (outcome.ok) break;
|
|
78
|
+
if (opts.signal?.aborted) break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @type {GuardRunResult} */
|
|
82
|
+
const result = {
|
|
83
|
+
name: guard.name,
|
|
84
|
+
status: outcome.ok ? 'passed' : 'failed',
|
|
85
|
+
file: guard.file,
|
|
86
|
+
because: guard.because,
|
|
87
|
+
durationMs: Date.now() - startedAt,
|
|
88
|
+
attempts,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (outcome.ok) {
|
|
92
|
+
// Passing only on the second go is not passing. The flake register picks
|
|
93
|
+
// this up and condemns the guard, because a guard nobody trusts is worse
|
|
94
|
+
// than no guard: people learn to re-run it until it goes green.
|
|
95
|
+
if (attempts > 1) result.retriedToPass = true;
|
|
96
|
+
} else {
|
|
97
|
+
if (outcome.failedAt) result.failedAt = outcome.failedAt;
|
|
98
|
+
result.message = withStory(outcome.message ?? 'This guard failed.', guard.because);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
results.push(result);
|
|
102
|
+
opts.onResult?.(result);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One attempt at one guard, from a clean start.
|
|
110
|
+
*
|
|
111
|
+
* @param {import('../types.js').Project} project
|
|
112
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
113
|
+
* @param {import('../types.js').Guard} guard
|
|
114
|
+
* @param {string|undefined} baseUrl
|
|
115
|
+
* @param {number} timeoutMs
|
|
116
|
+
* @returns {Promise<AttemptOutcome>}
|
|
117
|
+
*/
|
|
118
|
+
async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
|
|
119
|
+
/** @type {ReturnType<typeof setTimeout>|undefined} */
|
|
120
|
+
let timer;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await Promise.race([
|
|
124
|
+
(async () => {
|
|
125
|
+
// Guards must be independent. A guard that passes only because the guard
|
|
126
|
+
// before it left a dialog open will lie the day somebody runs it alone
|
|
127
|
+
// with --only, and that is exactly the day they are trusting it.
|
|
128
|
+
//
|
|
129
|
+
// A web app has a front door to walk back through. A desktop app does not —
|
|
130
|
+
// and leaving that as "nothing happens" cost real time: a guard about the
|
|
131
|
+
// sidebar failed only when it ran after another guard, and passed on its
|
|
132
|
+
// own, which is the single most confusing shape a failure can take. So an
|
|
133
|
+
// Electron window is reloaded instead. Its main process keeps whatever it
|
|
134
|
+
// was holding; only the screen goes back to how it opened.
|
|
135
|
+
if (baseUrl) await app.page.goto(baseUrl);
|
|
136
|
+
else await resetWindow(app);
|
|
137
|
+
clearConsole(app);
|
|
138
|
+
await guard.run(makeGuardApi(app.page, project));
|
|
139
|
+
})(),
|
|
140
|
+
new Promise((_resolve, reject) => {
|
|
141
|
+
timer = setTimeout(() => {
|
|
142
|
+
reject(new TookTooLong(`'${guard.name}' did not finish within ${humanSeconds(timeoutMs)}.`));
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof ExpectationFailed) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
failedAt: error.claim,
|
|
151
|
+
message: `This should still be true, and it is not: "${error.claim}".${consoleNote(app)}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
155
|
+
return { ok: false, message: `${raw}${consoleNote(app)}` };
|
|
156
|
+
} finally {
|
|
157
|
+
// The losing side of the race keeps running otherwise, and a stray timer
|
|
158
|
+
// holds the process open long after the run is reported.
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A timeout, kept apart from a real error so the wording stays ours. */
|
|
166
|
+
class TookTooLong extends Error {
|
|
167
|
+
/** @param {string} message */
|
|
168
|
+
constructor(message) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.name = 'TookTooLong';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The page keeps console errors for whoever asks; clearing them here means the
|
|
176
|
+
* ones we report belong to this guard and not to the one before it.
|
|
177
|
+
*
|
|
178
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
179
|
+
*/
|
|
180
|
+
function clearConsole(app) {
|
|
181
|
+
const handle = /** @type {{clearConsole?: () => void}} */ (/** @type {unknown} */ (app.page));
|
|
182
|
+
handle.clearConsole?.();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
function consoleNote(app) {
|
|
190
|
+
/** @type {string[]} */
|
|
191
|
+
let errors = [];
|
|
192
|
+
try {
|
|
193
|
+
errors = app.page.consoleErrors() ?? [];
|
|
194
|
+
} catch {
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
if (errors.length === 0) return '';
|
|
198
|
+
const first = String(errors[0]).split('\n')[0].slice(0, 200);
|
|
199
|
+
const rest = errors.length === 1 ? '' : ` (and ${errors.length - 1} more)`;
|
|
200
|
+
return `\nThe page also logged an error while this guard ran: ${first}${rest}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The story of the original bug is the single most useful thing to print when a
|
|
205
|
+
* guard fails — it says whether the failure matters.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} message
|
|
208
|
+
* @param {string|undefined} because
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
211
|
+
function withStory(message, because) {
|
|
212
|
+
if (typeof because !== 'string' || because.trim() === '') return message;
|
|
213
|
+
return `${message}\n\nWhy this guard exists: ${because.trim()}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {number} ms
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function humanSeconds(ms) {
|
|
221
|
+
if (ms < 1000) return `${Math.round(ms)} milliseconds`;
|
|
222
|
+
const seconds = Math.round(ms / 1000);
|
|
223
|
+
return seconds === 1 ? '1 second' : `${seconds} seconds`;
|
|
224
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stays Fixed, as a library.
|
|
3
|
+
*
|
|
4
|
+
* `import { loadProject, runCheck } from 'staysfixed'` gives you exactly what
|
|
5
|
+
* the `staysfixed` command and the MCP server use — there is no private path
|
|
6
|
+
* that behaves differently. Everything here is plain ESM with no build step, so
|
|
7
|
+
* you can also point Node straight at `src/index.js` from a checkout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find and read a project's config.
|
|
12
|
+
* Everything else takes the `Project` this hands back.
|
|
13
|
+
*/
|
|
14
|
+
export { loadProject } from './core/config.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The four nets.
|
|
18
|
+
*
|
|
19
|
+
* `runCheck` photographs the screens and runs the guards.
|
|
20
|
+
* `captureOne` photographs a single screen — what an agent calls right after it
|
|
21
|
+
* has changed something.
|
|
22
|
+
* `runWalk` opens the real app, walks it, and leaves a page of photos behind.
|
|
23
|
+
* `projectStatus` answers "what is set up here?" without opening anything.
|
|
24
|
+
*/
|
|
25
|
+
export { runCheck, captureOne, runWalk, projectStatus } from './run.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Approving a new look.
|
|
29
|
+
*
|
|
30
|
+
* This is a person's job, always. It is exported so a review tool can offer the
|
|
31
|
+
* button — never so an agent can press it on its own behalf.
|
|
32
|
+
*/
|
|
33
|
+
export { approveScreens } from './run.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Guards: one check per bug that has already been fixed once.
|
|
37
|
+
*/
|
|
38
|
+
export { loadGuards } from './guard/load.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Markers and tracing: pin a release that was known good, then find the commit
|
|
42
|
+
* where a screen stopped looking like it.
|
|
43
|
+
*/
|
|
44
|
+
export { writeMarker, listMarkers } from './marker/mark.js';
|
|
45
|
+
export { traceScreens } from './marker/trace.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The MCP server, so a coding agent can check its own work the moment it
|
|
49
|
+
* finishes editing — under the same rules, including never approving anything.
|
|
50
|
+
*/
|
|
51
|
+
export { serveMcp } from './mcp/server.js';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Errors and exit codes.
|
|
55
|
+
* A `StaysFixedError` is a problem worth explaining to a person; anything else
|
|
56
|
+
* is a bug in this tool.
|
|
57
|
+
*/
|
|
58
|
+
export { StaysFixedError, EXIT } from './core/errors.js';
|
|
59
|
+
|
|
60
|
+
/** The version of Stays Fixed you are running, read off its package.json. */
|
|
61
|
+
export { VERSION } from './run.js';
|