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/cli/init.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed init` — thirty seconds from nothing to a first check.
|
|
3
|
+
*
|
|
4
|
+
* It looks at the project before it writes anything, because a settings file
|
|
5
|
+
* with your real dev server and your real app in it gets edited, and a generic
|
|
6
|
+
* one gets deleted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { GITIGNORE_LINES, DEFAULT_DIR, findConfigFile, rootForConfig } from '../core/paths.js';
|
|
14
|
+
import { guardTemplate } from '../guard/load.js';
|
|
15
|
+
import { mcpConfigSnippet } from '../mcp/server.js';
|
|
16
|
+
import { say, ok, warn, blank, heading, paint, setLogLevel, shortPath } from '../core/log.js';
|
|
17
|
+
import { EXIT } from '../core/errors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What we worked out about the project before writing anything.
|
|
21
|
+
* @typedef {object} Guess
|
|
22
|
+
* @property {'web'|'electron'} kind
|
|
23
|
+
* @property {string} name
|
|
24
|
+
* @property {string} [url]
|
|
25
|
+
* @property {string} [start]
|
|
26
|
+
* @property {string} [binary]
|
|
27
|
+
* @property {string} [why] How we worked it out, said in one line.
|
|
28
|
+
* @property {any} [pkg]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('./index.js').CliContext} ctx
|
|
33
|
+
* @returns {Promise<number>}
|
|
34
|
+
*/
|
|
35
|
+
export async function run(ctx) {
|
|
36
|
+
const asJson = ctx.bool('json');
|
|
37
|
+
if (asJson) setLogLevel({ quiet: true, verbose: false });
|
|
38
|
+
|
|
39
|
+
const root = ctx.cwd;
|
|
40
|
+
const guess = await lookAround(root);
|
|
41
|
+
|
|
42
|
+
/** @type {string[]} */
|
|
43
|
+
const created = [];
|
|
44
|
+
/** @type {string[]} */
|
|
45
|
+
const kept = [];
|
|
46
|
+
|
|
47
|
+
// 1. The settings file. A project that has not declared itself as ES modules
|
|
48
|
+
// gets a .mjs file, so `export default` works without touching its package.json.
|
|
49
|
+
const found = findConfigFile(root);
|
|
50
|
+
const alreadyHere = found && rootForConfig(found) === root ? found : null;
|
|
51
|
+
const suffix = guess.pkg?.type === 'module' ? 'js' : 'mjs';
|
|
52
|
+
let configFile = alreadyHere ?? path.join(root, `staysfixed.config.${suffix}`);
|
|
53
|
+
|
|
54
|
+
if (alreadyHere && !ctx.bool('force')) {
|
|
55
|
+
kept.push(alreadyHere);
|
|
56
|
+
} else {
|
|
57
|
+
if (alreadyHere) configFile = alreadyHere;
|
|
58
|
+
await fsp.writeFile(configFile, configTemplate(guess));
|
|
59
|
+
created.push(configFile);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. The folders.
|
|
63
|
+
const dir = path.join(root, DEFAULT_DIR);
|
|
64
|
+
for (const folder of [dir, path.join(dir, 'approved'), path.join(dir, 'guards'), path.join(dir, 'markers')]) {
|
|
65
|
+
if (!existsSync(folder)) {
|
|
66
|
+
await fsp.mkdir(folder, { recursive: true });
|
|
67
|
+
created.push(folder);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. One starter guard, plus the page that explains what a guard even is.
|
|
72
|
+
// The file name starts with an underscore so it is not run: it is a thing to
|
|
73
|
+
// copy, not a check that would fail on day one against selectors you do not have.
|
|
74
|
+
const example = path.join(dir, 'guards', '_example.js');
|
|
75
|
+
if (!existsSync(example)) {
|
|
76
|
+
await fsp.writeFile(example, guardTemplate());
|
|
77
|
+
created.push(example);
|
|
78
|
+
} else {
|
|
79
|
+
kept.push(example);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const readme = path.join(dir, 'guards', 'README.md');
|
|
83
|
+
if (!existsSync(readme)) {
|
|
84
|
+
await fsp.writeFile(readme, guardsReadme());
|
|
85
|
+
created.push(readme);
|
|
86
|
+
} else {
|
|
87
|
+
kept.push(readme);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Keep the throwaway files out of git.
|
|
91
|
+
const ignored = await addIgnoreLines(root);
|
|
92
|
+
if (ignored) created.push(path.join(root, '.gitignore'));
|
|
93
|
+
|
|
94
|
+
// 5. The snippet that lets a coding agent check its own work.
|
|
95
|
+
const snippet = snippetFor(root, guess.pkg);
|
|
96
|
+
|
|
97
|
+
if (asJson) {
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
JSON.stringify(
|
|
100
|
+
{
|
|
101
|
+
ok: true,
|
|
102
|
+
root,
|
|
103
|
+
configFile,
|
|
104
|
+
kind: guess.kind,
|
|
105
|
+
url: guess.url ?? null,
|
|
106
|
+
binary: guess.binary ?? null,
|
|
107
|
+
created,
|
|
108
|
+
alreadyThere: kept,
|
|
109
|
+
mcp: snippet,
|
|
110
|
+
next: ['staysfixed check', 'staysfixed approve --all'],
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2,
|
|
114
|
+
) + '\n',
|
|
115
|
+
);
|
|
116
|
+
return EXIT.ok;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
blank();
|
|
120
|
+
ok('Stays Fixed is set up here.');
|
|
121
|
+
if (guess.why) say(paint.grey(` ${guess.why}`));
|
|
122
|
+
blank();
|
|
123
|
+
|
|
124
|
+
for (const file of created) say(` ${paint.green('made')} ${shortPath(file)}`);
|
|
125
|
+
for (const file of kept) say(` ${paint.grey('left alone (already there)')} ${shortPath(file)}`);
|
|
126
|
+
if (alreadyHere && kept.includes(alreadyHere)) {
|
|
127
|
+
blank();
|
|
128
|
+
warn('You already had a settings file, so it was not touched. Pass --force to replace it.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
heading('Open the settings file and name the screens that matter');
|
|
132
|
+
say(` ${paint.cyan(shortPath(configFile))}`);
|
|
133
|
+
say(paint.grey(' It is heavily commented. Three or four screens is a good start — the ones you would'));
|
|
134
|
+
say(paint.grey(' be upset to find broken.'));
|
|
135
|
+
|
|
136
|
+
heading('Then these two commands');
|
|
137
|
+
say(` 1. ${paint.cyan('staysfixed check')} takes the first pictures`);
|
|
138
|
+
say(` 2. ${paint.cyan('staysfixed approve --all')} you look at them, and say they are right`);
|
|
139
|
+
say(paint.grey(' From then on, `staysfixed check` tells you the moment one of them changes.'));
|
|
140
|
+
|
|
141
|
+
heading('To let your coding agent check its own work');
|
|
142
|
+
say(paint.grey(' Add this to the MCP settings of Claude Code, Codex, Gemini or Cursor:'));
|
|
143
|
+
blank();
|
|
144
|
+
for (const line of String(typeof snippet === 'string' ? snippet : JSON.stringify(snippet, null, 2)).split('\n')) {
|
|
145
|
+
say(` ${line}`);
|
|
146
|
+
}
|
|
147
|
+
blank();
|
|
148
|
+
say(paint.grey(' It can take pictures and run guards. It cannot approve them — that stays yours.'));
|
|
149
|
+
blank();
|
|
150
|
+
|
|
151
|
+
return EXIT.ok;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Work out what this project is, from what is already in the folder.
|
|
156
|
+
* @param {string} root
|
|
157
|
+
* @returns {Promise<Guess>}
|
|
158
|
+
*/
|
|
159
|
+
async function lookAround(root) {
|
|
160
|
+
const pkg = await readJson(path.join(root, 'package.json'));
|
|
161
|
+
const name = typeof pkg?.name === 'string' ? pkg.name : path.basename(root);
|
|
162
|
+
const scripts = /** @type {Record<string,string>} */ (pkg?.scripts ?? {});
|
|
163
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
164
|
+
|
|
165
|
+
const bundle = findAppBundle(root, name, pkg);
|
|
166
|
+
if (deps.electron || bundle) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'electron',
|
|
169
|
+
name,
|
|
170
|
+
pkg,
|
|
171
|
+
binary: bundle ?? '/Applications/Your App.app',
|
|
172
|
+
why: bundle
|
|
173
|
+
? `Found your built app at ${bundle}, so the settings open that.`
|
|
174
|
+
: 'This looks like an Electron app, so the settings open a built app rather than a browser.',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [scriptName, scriptBody] = pickDevScript(scripts);
|
|
179
|
+
const port = guessPort(scriptBody, deps);
|
|
180
|
+
return {
|
|
181
|
+
kind: 'web',
|
|
182
|
+
name,
|
|
183
|
+
pkg,
|
|
184
|
+
url: `http://localhost:${port}`,
|
|
185
|
+
start: scriptName ? `npm run ${scriptName}` : undefined,
|
|
186
|
+
why: scriptName
|
|
187
|
+
? `Found "npm run ${scriptName}" in package.json, so the settings start that and open port ${port}.`
|
|
188
|
+
: `No dev script found, so the settings assume your app is already running on port ${port}.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {Record<string,string>} scripts
|
|
194
|
+
* @returns {[string|null, string]}
|
|
195
|
+
*/
|
|
196
|
+
function pickDevScript(scripts) {
|
|
197
|
+
for (const name of ['dev', 'start', 'serve', 'develop']) {
|
|
198
|
+
if (typeof scripts[name] === 'string') return [name, scripts[name]];
|
|
199
|
+
}
|
|
200
|
+
return [null, ''];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The port a dev server will most likely answer on. An explicit port in the
|
|
205
|
+
* script always wins over a framework's habit.
|
|
206
|
+
* @param {string} script
|
|
207
|
+
* @param {Record<string, unknown>} deps
|
|
208
|
+
* @returns {number}
|
|
209
|
+
*/
|
|
210
|
+
function guessPort(script, deps) {
|
|
211
|
+
const explicit =
|
|
212
|
+
/(?:--port[= ]|-p[= ]|PORT[= ])(\d{2,5})/.exec(script) ?? /localhost:(\d{2,5})/.exec(script);
|
|
213
|
+
if (explicit) return Number(explicit[1]);
|
|
214
|
+
|
|
215
|
+
if (deps.vite || deps.vitest || deps['@sveltejs/kit']) return 5173;
|
|
216
|
+
if (deps['@angular/core']) return 4200;
|
|
217
|
+
if (deps.gatsby) return 8000;
|
|
218
|
+
if (deps.astro) return 4321;
|
|
219
|
+
if (deps.nuxt || deps.next || deps.remix || deps['@remix-run/dev']) return 3000;
|
|
220
|
+
return 3000;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A built Mac app matching this project, if there is one sitting around.
|
|
225
|
+
* @param {string} root
|
|
226
|
+
* @param {string} name
|
|
227
|
+
* @param {any} pkg
|
|
228
|
+
* @returns {string|null}
|
|
229
|
+
*/
|
|
230
|
+
function findAppBundle(root, name, pkg) {
|
|
231
|
+
if (process.platform !== 'darwin') return null;
|
|
232
|
+
/** @type {string[]} */
|
|
233
|
+
const titles = [];
|
|
234
|
+
for (const candidate of [pkg?.build?.productName, pkg?.productName, name]) {
|
|
235
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') titles.push(candidate.trim());
|
|
236
|
+
}
|
|
237
|
+
titles.push(...titles.map(titleCase));
|
|
238
|
+
|
|
239
|
+
for (const title of [...new Set(titles)]) {
|
|
240
|
+
for (const where of ['/Applications', path.join(root, 'dist'), path.join(root, 'out'), path.join(root, 'release')]) {
|
|
241
|
+
const bundle = path.join(where, `${title}.app`);
|
|
242
|
+
if (existsSync(bundle)) return bundle;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {string} text
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
function titleCase(text) {
|
|
253
|
+
return text
|
|
254
|
+
.replace(/[-_]+/g, ' ')
|
|
255
|
+
.split(' ')
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
258
|
+
.join(' ');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} root
|
|
263
|
+
* @returns {Promise<boolean>} true when lines were added
|
|
264
|
+
*/
|
|
265
|
+
async function addIgnoreLines(root) {
|
|
266
|
+
const file = path.join(root, '.gitignore');
|
|
267
|
+
let current = '';
|
|
268
|
+
try {
|
|
269
|
+
current = await fsp.readFile(file, 'utf8');
|
|
270
|
+
} catch {
|
|
271
|
+
current = '';
|
|
272
|
+
}
|
|
273
|
+
const present = new Set(current.split('\n').map((line) => line.trim()));
|
|
274
|
+
const missing = GITIGNORE_LINES.filter((line) => line.startsWith('#') || !present.has(line.trim()));
|
|
275
|
+
const realMissing = GITIGNORE_LINES.filter((line) => !line.startsWith('#') && !present.has(line.trim()));
|
|
276
|
+
if (realMissing.length === 0) return false;
|
|
277
|
+
|
|
278
|
+
const prefix = current === '' || current.endsWith('\n') ? '' : '\n';
|
|
279
|
+
await fsp.writeFile(file, `${current}${prefix}\n${missing.join('\n')}\n`);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* How this machine should invoke the MCP server, written so it still works
|
|
285
|
+
* tomorrow: a project that depends on the package gets `npx`, a one-off run
|
|
286
|
+
* through npx gets the package name back, and a checkout gets its own path.
|
|
287
|
+
* @param {string} root
|
|
288
|
+
* @param {any} pkg
|
|
289
|
+
* @returns {unknown}
|
|
290
|
+
*/
|
|
291
|
+
function snippetFor(root, pkg) {
|
|
292
|
+
const bin = fileURLToPath(new URL('../../bin/staysfixed.js', import.meta.url));
|
|
293
|
+
const declared = Boolean(pkg?.dependencies?.staysfixed || pkg?.devDependencies?.staysfixed);
|
|
294
|
+
|
|
295
|
+
let command = process.execPath;
|
|
296
|
+
let args = [bin, 'mcp'];
|
|
297
|
+
if (declared || bin.startsWith(path.join(root, 'node_modules') + path.sep)) {
|
|
298
|
+
command = 'npx';
|
|
299
|
+
args = ['staysfixed', 'mcp'];
|
|
300
|
+
} else if (bin.includes(`${path.sep}_npx${path.sep}`)) {
|
|
301
|
+
command = 'npx';
|
|
302
|
+
args = ['github:asadev/staysfixed', 'mcp'];
|
|
303
|
+
}
|
|
304
|
+
return mcpConfigSnippet({ command, args, cwd: root });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {string} file
|
|
309
|
+
* @returns {Promise<any>}
|
|
310
|
+
*/
|
|
311
|
+
async function readJson(file) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
322
|
+
function guardsReadme() {
|
|
323
|
+
return `# Guards
|
|
324
|
+
|
|
325
|
+
A guard is one check for one bug you have already fixed.
|
|
326
|
+
|
|
327
|
+
Its only job is to fail the day that bug comes back. Nothing else. It is not a
|
|
328
|
+
unit test, it does not prove a feature works, and it should not try to.
|
|
329
|
+
|
|
330
|
+
The name is the important part. Write what should still be true, in the words
|
|
331
|
+
you would say out loud:
|
|
332
|
+
|
|
333
|
+
"the sidebar still collapses"
|
|
334
|
+
"signing out really does sign you out"
|
|
335
|
+
"the long file name no longer pushes the buttons off screen"
|
|
336
|
+
|
|
337
|
+
Six months from now that sentence is the only thing that will tell you what
|
|
338
|
+
broke, so it is worth ten seconds of thought.
|
|
339
|
+
|
|
340
|
+
## How to add one
|
|
341
|
+
|
|
342
|
+
Copy \`_example.js\` to a new file without the underscore — files starting with
|
|
343
|
+
\`_\` are ignored, which is why the example never runs. Then:
|
|
344
|
+
|
|
345
|
+
1. Put the app back in the state where the bug used to happen.
|
|
346
|
+
2. Say, in plain words, what must still be true.
|
|
347
|
+
|
|
348
|
+
await expect('the sidebar is hidden', async () => !(await page.visible('.sidebar')));
|
|
349
|
+
|
|
350
|
+
When that turns out false, the failure reads as the sentence you wrote.
|
|
351
|
+
|
|
352
|
+
## When to add one
|
|
353
|
+
|
|
354
|
+
The moment you fix something. Especially the second time you fix it — a bug
|
|
355
|
+
that came back once will come back again, and this is the cheapest way to hear
|
|
356
|
+
about it before your users do.
|
|
357
|
+
`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* A single-quoted JavaScript string, so the file we write reads like the rest of
|
|
362
|
+
* the file we write.
|
|
363
|
+
* @param {string} [text]
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function quoted(text) {
|
|
367
|
+
return `'${String(text ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The settings file. It is long because it is meant to be read: every option
|
|
372
|
+
* that matters is in here as a commented-out example, so nobody has to go
|
|
373
|
+
* looking for documentation to change the window size.
|
|
374
|
+
*
|
|
375
|
+
* @param {Guess} guess
|
|
376
|
+
* @returns {string}
|
|
377
|
+
*/
|
|
378
|
+
function configTemplate(guess) {
|
|
379
|
+
const appBlock =
|
|
380
|
+
guess.kind === 'electron'
|
|
381
|
+
? ` app: {
|
|
382
|
+
kind: 'electron',
|
|
383
|
+
|
|
384
|
+
// The app to open. On a Mac this can be the .app bundle itself.
|
|
385
|
+
binary: ${quoted(guess.binary ?? '/Applications/Your App.app')},
|
|
386
|
+
|
|
387
|
+
// args: ['--some-flag'],
|
|
388
|
+
// env: { NODE_ENV: 'test' },
|
|
389
|
+
// cwd: '.',
|
|
390
|
+
|
|
391
|
+
// Your app probably opens more than one window. Name a word from the title
|
|
392
|
+
// of the one that matters, so a check never photographs a splash screen.
|
|
393
|
+
// windowMatch: 'Main Window',
|
|
394
|
+
|
|
395
|
+
// Already running with remote debugging on? Attach instead of launching.
|
|
396
|
+
// Stays Fixed never closes anything it only attached to.
|
|
397
|
+
// attach: 'http://127.0.0.1:9333',
|
|
398
|
+
|
|
399
|
+
// startTimeoutMs: 60000,
|
|
400
|
+
},`
|
|
401
|
+
: ` app: {
|
|
402
|
+
kind: 'web',
|
|
403
|
+
|
|
404
|
+
// Where your app answers.
|
|
405
|
+
url: ${quoted(guess.url ?? 'http://localhost:3000')},
|
|
406
|
+
${
|
|
407
|
+
guess.start
|
|
408
|
+
? `
|
|
409
|
+
// Stays Fixed runs this and waits for the address above to answer. Remove it
|
|
410
|
+
// if you would rather start the app yourself.
|
|
411
|
+
start: ${quoted(guess.start)},`
|
|
412
|
+
: `
|
|
413
|
+
// Let Stays Fixed start the app itself and wait for the address above:
|
|
414
|
+
// start: 'npm run dev',`
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// env: { NODE_ENV: 'test' },
|
|
418
|
+
// cwd: '.',
|
|
419
|
+
|
|
420
|
+
// A specific browser, instead of the one found on this machine.
|
|
421
|
+
// browser: '/Applications/Google Chrome.app',
|
|
422
|
+
|
|
423
|
+
// headless: true,
|
|
424
|
+
|
|
425
|
+
// Attach to a browser that is already running instead of launching one.
|
|
426
|
+
// attach: 'http://127.0.0.1:9222',
|
|
427
|
+
|
|
428
|
+
// startTimeoutMs: 60000,
|
|
429
|
+
},`;
|
|
430
|
+
|
|
431
|
+
return `/**
|
|
432
|
+
* Stays Fixed — settings for ${guess.name}.
|
|
433
|
+
*
|
|
434
|
+
* This file says what to open and which screens matter. Everything else has a
|
|
435
|
+
* sensible default and is left commented out below, so you can see what exists
|
|
436
|
+
* without going to look it up.
|
|
437
|
+
*
|
|
438
|
+
* Two commands are all you need:
|
|
439
|
+
* staysfixed check take the pictures and compare them
|
|
440
|
+
* staysfixed approve say a new picture is the correct one
|
|
441
|
+
*/
|
|
442
|
+
|
|
443
|
+
export default {
|
|
444
|
+
${appBlock}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The screens worth photographing. Start with three or four: the ones you
|
|
448
|
+
* would be upset to find broken. Every screen needs a name — it becomes the
|
|
449
|
+
* file name of its picture — and a way to get there.
|
|
450
|
+
*/
|
|
451
|
+
screens: [
|
|
452
|
+
{
|
|
453
|
+
name: 'home',
|
|
454
|
+
describe: 'The first thing anybody sees',
|
|
455
|
+
url: '/',
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// A screen you have to click your way to:
|
|
459
|
+
// {
|
|
460
|
+
// name: 'settings-open',
|
|
461
|
+
// describe: 'The settings panel, open, with nothing typed in it',
|
|
462
|
+
// url: '/',
|
|
463
|
+
// steps: [
|
|
464
|
+
// { click: '[data-test="open-settings"]' },
|
|
465
|
+
// { waitFor: '.settings-panel' },
|
|
466
|
+
// { note: 'the panel animates in; settle waits for it to stop moving' },
|
|
467
|
+
// ],
|
|
468
|
+
// },
|
|
469
|
+
|
|
470
|
+
// Or the same thing in code, since this file is JavaScript:
|
|
471
|
+
// {
|
|
472
|
+
// name: 'signed-in',
|
|
473
|
+
// async do(page) {
|
|
474
|
+
// await page.goto('/login');
|
|
475
|
+
// await page.type('#email', 'someone@example.com');
|
|
476
|
+
// await page.type('#password', 'hunter2');
|
|
477
|
+
// await page.click('button[type=submit]');
|
|
478
|
+
// await page.waitFor('.dashboard');
|
|
479
|
+
// },
|
|
480
|
+
// // Only photograph one part of the screen:
|
|
481
|
+
// // clip: '.dashboard',
|
|
482
|
+
// // Or the whole scrollable page:
|
|
483
|
+
// // fullPage: true,
|
|
484
|
+
// // Hide something that is genuinely different every time:
|
|
485
|
+
// // masks: ['.last-updated', { x: 0, y: 0, width: 200, height: 40 }],
|
|
486
|
+
// // Be stricter or looser on this one screen only:
|
|
487
|
+
// // tolerance: { pixels: 0 },
|
|
488
|
+
// // A different window size for this one screen only:
|
|
489
|
+
// // viewport: { width: 390, height: 844, mobile: true },
|
|
490
|
+
// // Temporarily leave it out without deleting it:
|
|
491
|
+
// // skip: true,
|
|
492
|
+
// },
|
|
493
|
+
],
|
|
494
|
+
|
|
495
|
+
// The window size every picture is taken at.
|
|
496
|
+
// viewport: { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Freezing is what makes a picture repeatable. Without it the clock, the
|
|
500
|
+
* animations and the random numbers change every run and every check cries
|
|
501
|
+
* wolf. These defaults are on already — they are here so you can see them.
|
|
502
|
+
*/
|
|
503
|
+
// freeze: {
|
|
504
|
+
// clock: '2026-01-01T12:00:00.000Z', // the time your app always believes it is; false leaves it alone
|
|
505
|
+
// timezone: 'UTC',
|
|
506
|
+
// locale: 'en-US',
|
|
507
|
+
// motion: true, // no animations, no transitions, no blinking cursor
|
|
508
|
+
// random: 'seeded', // Math.random and crypto give the same answers every run
|
|
509
|
+
// seed: 20260101,
|
|
510
|
+
// fonts: true, // wait for web fonts before the shutter
|
|
511
|
+
// network: 'block-external', // 'block-external' | 'replay' | 'live'
|
|
512
|
+
// networkAllow: ['https://fonts.gstatic.com/*'],
|
|
513
|
+
// hideScrollbars: true,
|
|
514
|
+
// hideCaret: true,
|
|
515
|
+
// settle: {
|
|
516
|
+
// frames: 2, // this many identical frames in a row before the shutter
|
|
517
|
+
// intervalMs: 250,
|
|
518
|
+
// timeoutMs: 10000,
|
|
519
|
+
// maxDriftPixels: 0,
|
|
520
|
+
// },
|
|
521
|
+
// },
|
|
522
|
+
|
|
523
|
+
// How different two pictures may be before it counts as a change.
|
|
524
|
+
// The default is about one twentieth of one percent of the pixels.
|
|
525
|
+
// tolerance: { pixels: 0.0005, threshold: 0.12, antialiasing: true },
|
|
526
|
+
// tolerance: { maxPixels: 200 }, // or a hard cap, in pixels
|
|
527
|
+
|
|
528
|
+
// Painted over on every screen before comparing. Use it for the things that
|
|
529
|
+
// are honestly different every time: a clock, a version number, an avatar.
|
|
530
|
+
// masks: ['.timestamp', '[data-live]'],
|
|
531
|
+
|
|
532
|
+
// The click-through before a release: staysfixed walk.
|
|
533
|
+
// Leave it out and the walk visits every screen above, in order.
|
|
534
|
+
// walk: {
|
|
535
|
+
// describe: 'The path a new user takes',
|
|
536
|
+
// steps: [
|
|
537
|
+
// { name: 'landing', url: '/' },
|
|
538
|
+
// { name: 'pricing', url: '/pricing' },
|
|
539
|
+
// ],
|
|
540
|
+
// },
|
|
541
|
+
|
|
542
|
+
// What a coding agent is allowed to do through the MCP server.
|
|
543
|
+
// Approving stays off on purpose: an agent must never approve its own work.
|
|
544
|
+
// mcp: { allowApprove: false, allowMark: false },
|
|
545
|
+
|
|
546
|
+
// Where guards live.
|
|
547
|
+
// guards: '.staysfixed/guards',
|
|
548
|
+
|
|
549
|
+
// Where everything else lives.
|
|
550
|
+
// dir: '.staysfixed',
|
|
551
|
+
|
|
552
|
+
// A check that changes its mind this many times gets condemned: fix it or
|
|
553
|
+
// delete it. Never tolerate it.
|
|
554
|
+
// flakeLimit: 2,
|
|
555
|
+
|
|
556
|
+
// Re-takes of a failing screen before calling it a real change.
|
|
557
|
+
// retries: 1,
|
|
558
|
+
|
|
559
|
+
// Screens photographed at once. One, on purpose — a machine under load takes
|
|
560
|
+
// different pictures, and a check that cries wolf is worse than no check.
|
|
561
|
+
// concurrency: 1,
|
|
562
|
+
};
|
|
563
|
+
`;
|
|
564
|
+
}
|
package/src/cli/mark.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed mark` — pin a version that was good, so a regression has somewhere
|
|
3
|
+
* to be traced back to.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadProject } from '../core/config.js';
|
|
7
|
+
import { writeMarker, listMarkers, deleteMarker, describeMarker } from '../marker/mark.js';
|
|
8
|
+
import { say, ok, warn, blank, heading, paint } from '../core/log.js';
|
|
9
|
+
import { StaysFixedError, EXIT } from '../core/errors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('./index.js').CliContext} ctx
|
|
13
|
+
* @returns {Promise<number>}
|
|
14
|
+
*/
|
|
15
|
+
export async function run(ctx) {
|
|
16
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
17
|
+
|
|
18
|
+
if (ctx.bool('list')) return showAll(project);
|
|
19
|
+
|
|
20
|
+
const toDelete = ctx.str('delete');
|
|
21
|
+
if (toDelete) {
|
|
22
|
+
const removed = await deleteMarker(project, toDelete);
|
|
23
|
+
blank();
|
|
24
|
+
if (removed) ok(`The marker "${toDelete}" is gone.`);
|
|
25
|
+
else warn(`There is no marker called "${toDelete}". Run \`staysfixed mark --list\` to see the ones there are.`);
|
|
26
|
+
blank();
|
|
27
|
+
return removed ? EXIT.ok : EXIT.failed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const label = ctx.args.join(' ').trim();
|
|
31
|
+
if (!label) {
|
|
32
|
+
throw new StaysFixedError('A marker needs a name.', {
|
|
33
|
+
hint: 'Something you will recognise in three months: `staysfixed mark v0.15.0`.',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const marker = await writeMarker(project, label, { note: ctx.str('note'), force: ctx.bool('force'), tool: ctx.version });
|
|
38
|
+
|
|
39
|
+
blank();
|
|
40
|
+
ok(`Pinned "${marker.label}" as a version that was good.`);
|
|
41
|
+
say(` ${paint.grey(describeMarker(marker))}`);
|
|
42
|
+
if (marker.note) say(` ${paint.grey(marker.note)}`);
|
|
43
|
+
blank();
|
|
44
|
+
say(paint.grey('If a screen goes wrong later, `staysfixed trace` can now name the commits in between.'));
|
|
45
|
+
blank();
|
|
46
|
+
return EXIT.ok;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {import('../types.js').Project} project
|
|
51
|
+
* @returns {Promise<number>}
|
|
52
|
+
*/
|
|
53
|
+
async function showAll(project) {
|
|
54
|
+
const markers = await listMarkers(project);
|
|
55
|
+
if (markers.length === 0) {
|
|
56
|
+
blank();
|
|
57
|
+
say('No versions have been pinned here yet.');
|
|
58
|
+
say(`Pin one at your next release: ${paint.cyan('staysfixed mark v1.0.0')}`);
|
|
59
|
+
blank();
|
|
60
|
+
return EXIT.ok;
|
|
61
|
+
}
|
|
62
|
+
heading('Versions pinned as good, newest first');
|
|
63
|
+
for (const marker of markers) {
|
|
64
|
+
say(` ${describeMarker(marker)}`);
|
|
65
|
+
if (marker.note) say(` ${paint.grey(marker.note)}`);
|
|
66
|
+
}
|
|
67
|
+
blank();
|
|
68
|
+
return EXIT.ok;
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed status` — reads what is on disk and says it. Launches nothing.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadProject } from '../core/config.js';
|
|
6
|
+
import { projectStatus } from '../run.js';
|
|
7
|
+
import { printStatus } from '../report/console.js';
|
|
8
|
+
import { EXIT } from '../core/errors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('./index.js').CliContext} ctx
|
|
12
|
+
* @returns {Promise<number>}
|
|
13
|
+
*/
|
|
14
|
+
export async function run(ctx) {
|
|
15
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
16
|
+
const status = await projectStatus(project);
|
|
17
|
+
printStatus(/** @type {any} */ (status));
|
|
18
|
+
return EXIT.ok;
|
|
19
|
+
}
|