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.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * The click-through you do before you ship.
3
+ *
4
+ * A walk is not a test. It opens the real app, visits every screen in order and
5
+ * photographs each one into a fresh dated folder, so a person can scroll one page
6
+ * and see the whole app the way a user would. Nothing here compares against an
7
+ * approved picture and nothing here can ever write into `approved/` — a walk is
8
+ * evidence for a human, not a promise the tool is keeping.
9
+ */
10
+
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { captureScreen } from '../picture/capture.js';
14
+ import { settingsForScreen } from '../core/config.js';
15
+ import { gitInfo } from '../core/git.js';
16
+ import { StaysFixedError, messageOf } from '../core/errors.js';
17
+ import { safeName } from '../core/paths.js';
18
+
19
+ /**
20
+ * Progress handed to `opts.onStep`, once when a step starts and once when it is done.
21
+ * @typedef {object} WalkProgress
22
+ * @property {'start'|'done'} phase
23
+ * @property {number} index 1-based, matching the number on the photo file.
24
+ * @property {number} total
25
+ * @property {string} name
26
+ * @property {string} [describe]
27
+ * @property {import('../types.js').WalkStep} [step] The finished step, on 'done'.
28
+ */
29
+
30
+ /**
31
+ * Walk the app and photograph every screen in order.
32
+ *
33
+ * @param {import('../types.js').Project} project
34
+ * @param {import('../types.js').LaunchedApp} app
35
+ * @param {{
36
+ * onStep?: (update: WalkProgress) => void,
37
+ * only?: string|string[],
38
+ * signal?: AbortSignal,
39
+ * record?: boolean,
40
+ * }} [opts]
41
+ * @returns {Promise<import('../types.js').WalkReport>}
42
+ */
43
+ export async function walkApp(project, app, opts = {}) {
44
+ const { config, paths } = project;
45
+ const chosen = chooseSteps(config, opts.only);
46
+
47
+ const id = walkId(new Date());
48
+ const dir = await makeWalkDir(paths.results, id);
49
+
50
+ // A walk drives the same page object the whole way through, on purpose: the
51
+ // point is to see the app as a person moving through it would, not to open a
52
+ // clean tab per screen.
53
+ const page = /** @type {import('../types.js').PageHandle} */ (app.page);
54
+
55
+ /** @type {import('../types.js').WalkStep[]} */
56
+ const steps = [];
57
+ let stopped = false;
58
+
59
+ for (let i = 0; i < chosen.length; i++) {
60
+ if (opts.signal?.aborted) {
61
+ stopped = true;
62
+ break;
63
+ }
64
+ const screen = chosen[i];
65
+ const index = i + 1;
66
+ opts.onStep?.({
67
+ phase: 'start',
68
+ index,
69
+ total: chosen.length,
70
+ name: screen.name,
71
+ ...(screen.describe !== undefined ? { describe: screen.describe } : {}),
72
+ });
73
+
74
+ const step = await walkOneStep(page, screen, {
75
+ index,
76
+ dir,
77
+ settings: settingsForScreen(config, screen),
78
+ fixturesDir: paths.fixtures,
79
+ record: opts.record ?? false,
80
+ });
81
+ steps.push(step);
82
+
83
+ opts.onStep?.({
84
+ phase: 'done',
85
+ index,
86
+ total: chosen.length,
87
+ name: screen.name,
88
+ ...(screen.describe !== undefined ? { describe: screen.describe } : {}),
89
+ step,
90
+ });
91
+ }
92
+
93
+ const ok = !stopped && steps.every((s) => !s.error && (s.consoleErrors ?? []).length === 0);
94
+
95
+ return {
96
+ id,
97
+ dir,
98
+ steps,
99
+ ok,
100
+ git: await gitInfo(paths.root),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Photograph one screen. This never throws: a step that cannot be reached is
106
+ * recorded and the walk carries on. One broken screen must not hide the other
107
+ * eleven — hiding them is exactly how a release goes out with three things wrong
108
+ * instead of one.
109
+ *
110
+ * @param {import('../types.js').PageHandle} page
111
+ * @param {import('../types.js').ScreenConfig} screen
112
+ * @param {{
113
+ * index: number,
114
+ * dir: string,
115
+ * settings: ReturnType<typeof settingsForScreen>,
116
+ * fixturesDir: string,
117
+ * record: boolean,
118
+ * }} ctx
119
+ * @returns {Promise<import('../types.js').WalkStep>}
120
+ */
121
+ async function walkOneStep(page, screen, ctx) {
122
+ const startedAt = Date.now();
123
+ const target = path.join(
124
+ ctx.dir,
125
+ `${String(ctx.index).padStart(2, '0')}-${safeName(screen.name)}.png`,
126
+ );
127
+
128
+ /** @type {string} */
129
+ let file = '';
130
+ /** @type {string|undefined} */
131
+ let error;
132
+ /** @type {string[]} */
133
+ let consoleErrors = [];
134
+
135
+ try {
136
+ const shot = await captureScreen(page, screen, ctx.settings, {
137
+ fixturesDir: ctx.fixturesDir,
138
+ record: ctx.record,
139
+ });
140
+ await fsp.writeFile(target, shot.png);
141
+ file = target;
142
+ consoleErrors = shot.consoleErrors;
143
+ } catch (cause) {
144
+ error = messageOf(cause);
145
+ consoleErrors = readConsole(page);
146
+ // Even a failed step is worth a photo — seeing where the app actually got to
147
+ // is usually the whole diagnosis. If the page is too far gone to photograph,
148
+ // the step simply has no picture and says so.
149
+ try {
150
+ await fsp.writeFile(target, await page.shoot());
151
+ file = target;
152
+ } catch {
153
+ file = '';
154
+ }
155
+ }
156
+
157
+ /** @type {import('../types.js').WalkStep} */
158
+ const step = {
159
+ index: ctx.index,
160
+ name: screen.name,
161
+ file,
162
+ durationMs: Date.now() - startedAt,
163
+ };
164
+ if (screen.describe !== undefined) step.describe = screen.describe;
165
+ if (error !== undefined) step.error = error;
166
+ if (consoleErrors.length > 0) step.consoleErrors = consoleErrors;
167
+
168
+ const url = await quietly(() => page.url());
169
+ if (url) step.url = url;
170
+ const title = await quietly(() => page.title());
171
+ if (title) step.title = title;
172
+
173
+ return step;
174
+ }
175
+
176
+ /**
177
+ * Which screens the walk visits: `walk.steps` when the project spelled one out,
178
+ * otherwise every screen it already checks, in the order they are written.
179
+ *
180
+ * @param {import('../types.js').ResolvedConfig} config
181
+ * @param {string|string[]} [only]
182
+ * @returns {import('../types.js').ScreenConfig[]}
183
+ */
184
+ function chooseSteps(config, only) {
185
+ const spelledOut = config.walk?.steps;
186
+ const source = spelledOut && spelledOut.length > 0 ? spelledOut : config.screens ?? [];
187
+ if (source.length === 0) {
188
+ throw new StaysFixedError('There is nothing to walk through — no screens are set up yet.', {
189
+ hint: 'Add screens to your config, or a `walk: { steps: [ ... ] }` list of the ones to walk before a release.',
190
+ });
191
+ }
192
+
193
+ const wanted = only === undefined ? null : new Set((Array.isArray(only) ? only : [only]).map(safeName));
194
+
195
+ const chosen = source
196
+ .map((s, i) => normaliseStep(s, i))
197
+ .filter((s) => !s.skip)
198
+ .filter((s) => wanted === null || wanted.has(safeName(s.name)));
199
+
200
+ if (chosen.length === 0) {
201
+ throw new StaysFixedError(
202
+ wanted === null
203
+ ? 'Every screen is switched off, so the walkthrough has nothing to show.'
204
+ : 'None of the screens you asked for are in this project.',
205
+ { hint: 'Run `staysfixed status` to see the screen names this project knows about.' },
206
+ );
207
+ }
208
+ return chosen;
209
+ }
210
+
211
+ /**
212
+ * `walk.steps` comes straight off the raw config, so unlike `screens` its `url`
213
+ * shorthand has not been turned into a first step yet. Expand it here, but only
214
+ * when it is not already there, or a screen borrowed from `screens` would be
215
+ * told to navigate twice.
216
+ *
217
+ * @param {import('../types.js').ScreenConfig} screen
218
+ * @param {number} i
219
+ * @returns {import('../types.js').ScreenConfig}
220
+ */
221
+ function normaliseStep(screen, i) {
222
+ if (!screen || typeof screen !== 'object') {
223
+ throw new StaysFixedError(`walk.steps[${i}] is not a screen.`, {
224
+ hint: "Each one looks like { name: 'sessions', url: '/sessions' }.",
225
+ });
226
+ }
227
+ const named = screen.name ? screen : { ...screen, name: `step-${i + 1}` };
228
+ if (!named.url) return named;
229
+ const steps = named.steps ?? [];
230
+ if (steps.length > 0 && steps[0]?.goto === named.url) return named;
231
+ return { ...named, steps: [{ goto: named.url }, ...steps] };
232
+ }
233
+
234
+ /**
235
+ * @param {import('../types.js').PageHandle} page
236
+ * @returns {string[]}
237
+ */
238
+ function readConsole(page) {
239
+ try {
240
+ return page.consoleErrors();
241
+ } catch {
242
+ return [];
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @template T
248
+ * @param {() => Promise<T>} fn
249
+ * @returns {Promise<T|null>}
250
+ */
251
+ async function quietly(fn) {
252
+ try {
253
+ return await fn();
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Sortable, readable, and the same shape a run id uses: 20260829-014530.
261
+ * @param {Date} at
262
+ * @returns {string}
263
+ */
264
+ function walkId(at) {
265
+ const p = (/** @type {number} */ n) => String(n).padStart(2, '0');
266
+ return (
267
+ `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}` +
268
+ `-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Two walks in the same second must not photograph into each other's folder.
274
+ * @param {string} resultsDir
275
+ * @param {string} id
276
+ * @returns {Promise<string>}
277
+ */
278
+ async function makeWalkDir(resultsDir, id) {
279
+ await fsp.mkdir(resultsDir, { recursive: true });
280
+ for (let n = 0; n < 50; n++) {
281
+ const dir = path.join(resultsDir, n === 0 ? `walk-${id}` : `walk-${id}-${n + 1}`);
282
+ try {
283
+ // Deliberately not recursive: a clash must be caught, not silently shared.
284
+ await fsp.mkdir(dir, { recursive: false });
285
+ return dir;
286
+ } catch (cause) {
287
+ if (/** @type {NodeJS.ErrnoException} */ (cause).code !== 'EEXIST') throw cause;
288
+ }
289
+ }
290
+ throw new StaysFixedError('Could not make a folder for this walkthrough.', {
291
+ hint: `Too many walks already sit in ${resultsDir}.`,
292
+ });
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // The contact sheet
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Write one self-contained HTML page showing every photo in order.
301
+ *
302
+ * Everything is inlined — the pictures as data URIs, the styling in a <style>
303
+ * block — so the file can be dragged into a chat, attached to a release, or
304
+ * opened months later on a machine that never had this project on it.
305
+ *
306
+ * @param {import('../types.js').WalkReport} report
307
+ * @param {string} file Absolute path to write.
308
+ * @returns {Promise<string>} the file written
309
+ */
310
+ export async function writeWalkContactSheet(report, file) {
311
+ /** @type {string[]} */
312
+ const cards = [];
313
+ for (const step of report.steps) {
314
+ cards.push(await cardHtml(step));
315
+ }
316
+
317
+ const problems = report.steps.filter(
318
+ (s) => s.error || (s.consoleErrors ?? []).length > 0,
319
+ ).length;
320
+ const where = [report.git.branch, report.git.shortSha].filter(Boolean).join(' · ');
321
+
322
+ const html = `<!doctype html>
323
+ <html lang="en">
324
+ <meta charset="utf-8">
325
+ <meta name="viewport" content="width=device-width, initial-scale=1">
326
+ <title>Walkthrough ${escapeHtml(report.id)}</title>
327
+ <style>
328
+ :root {
329
+ color-scheme: light dark;
330
+ --ground: #f6f6f4;
331
+ --card: #ffffff;
332
+ --ink: #1a1a19;
333
+ --soft: #6b6b66;
334
+ --line: #e2e2dd;
335
+ --bad: #b02a1e;
336
+ --good: #2f6f43;
337
+ }
338
+ @media (prefers-color-scheme: dark) {
339
+ :root {
340
+ --ground: #14140f;
341
+ --card: #1e1e1a;
342
+ --ink: #ecece6;
343
+ --soft: #9a9a92;
344
+ --line: #33332c;
345
+ --bad: #ff8a7a;
346
+ --good: #7fd39b;
347
+ }
348
+ }
349
+ * { box-sizing: border-box; }
350
+ body {
351
+ margin: 0;
352
+ background: var(--ground);
353
+ color: var(--ink);
354
+ font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
355
+ padding: 32px 24px 64px;
356
+ }
357
+ header { max-width: 1100px; margin: 0 auto 28px; }
358
+ h1 { font-size: 22px; margin: 0 0 6px; letter-spacing: -0.01em; }
359
+ .meta { color: var(--soft); font-size: 13px; }
360
+ .meta b { color: var(--ink); font-weight: 600; }
361
+ .meta b.ok { color: var(--good); }
362
+ .grid {
363
+ max-width: 1100px;
364
+ margin: 0 auto;
365
+ display: grid;
366
+ gap: 22px;
367
+ grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
368
+ }
369
+ @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
370
+ .card {
371
+ background: var(--card);
372
+ border: 1px solid var(--line);
373
+ border-radius: 16px;
374
+ padding: 14px;
375
+ overflow: hidden;
376
+ }
377
+ .head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
378
+ .num {
379
+ font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
380
+ color: var(--soft);
381
+ background: var(--ground);
382
+ border: 1px solid var(--line);
383
+ border-radius: 999px;
384
+ padding: 5px 8px;
385
+ }
386
+ .name { font-weight: 600; }
387
+ .desc { color: var(--soft); font-size: 13px; margin: 2px 0 10px; }
388
+ .where {
389
+ color: var(--soft);
390
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
391
+ margin: 10px 0 0;
392
+ word-break: break-all;
393
+ }
394
+ .zoomer { position: absolute; opacity: 0; pointer-events: none; }
395
+ .frame { display: block; cursor: zoom-in; }
396
+ .frame img {
397
+ display: block;
398
+ width: 100%;
399
+ height: auto;
400
+ border-radius: 10px;
401
+ border: 1px solid var(--line);
402
+ background: var(--ground);
403
+ }
404
+ .zoomer:checked + .frame {
405
+ position: fixed;
406
+ inset: 0;
407
+ z-index: 50;
408
+ display: flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ padding: 24px;
412
+ background: rgba(0, 0, 0, 0.88);
413
+ cursor: zoom-out;
414
+ overflow: auto;
415
+ }
416
+ .zoomer:checked + .frame img {
417
+ width: auto;
418
+ max-width: 100%;
419
+ max-height: 100%;
420
+ border-radius: 4px;
421
+ }
422
+ .nopic {
423
+ border: 1px dashed var(--line);
424
+ border-radius: 10px;
425
+ padding: 34px 14px;
426
+ text-align: center;
427
+ color: var(--soft);
428
+ font-size: 13px;
429
+ }
430
+ .problem {
431
+ margin-top: 12px;
432
+ border-left: 3px solid var(--bad);
433
+ padding: 6px 0 6px 10px;
434
+ color: var(--bad);
435
+ font-size: 13px;
436
+ }
437
+ .problem h3 { font-size: 13px; margin: 0 0 4px; }
438
+ .problem ul { margin: 0; padding-left: 16px; }
439
+ .problem li { word-break: break-word; }
440
+ footer { max-width: 1100px; margin: 34px auto 0; color: var(--soft); font-size: 12px; }
441
+ </style>
442
+ <header>
443
+ <h1>Walkthrough — ${escapeHtml(readableId(report.id))}</h1>
444
+ <p class="meta">
445
+ <b>${report.steps.length}</b> screen${report.steps.length === 1 ? '' : 's'} ·
446
+ ${problems === 0 ? '<b class="ok">nothing went wrong</b>' : `<b>${problems}</b> with something to look at`}
447
+ ${where ? ` · ${escapeHtml(where)}` : ''}${report.git.dirty ? ' · uncommitted changes' : ''}
448
+ </p>
449
+ <p class="meta">Click any picture to see it full size. Click it again to come back.</p>
450
+ </header>
451
+ <main class="grid">
452
+ ${cards.join('\n')}
453
+ </main>
454
+ <footer>Taken by Stays Fixed. The pictures are inside this file, so it works anywhere.</footer>
455
+ <script>
456
+ // Escape closes whichever picture is open. That is the whole script.
457
+ document.addEventListener('keydown', function (e) {
458
+ if (e.key !== 'Escape') return;
459
+ document.querySelectorAll('.zoomer:checked').forEach(function (box) { box.checked = false; });
460
+ });
461
+ </script>
462
+ </html>
463
+ `;
464
+
465
+ await fsp.mkdir(path.dirname(file), { recursive: true });
466
+ await fsp.writeFile(file, html);
467
+ report.reportFile = file;
468
+ return file;
469
+ }
470
+
471
+ /**
472
+ * @param {import('../types.js').WalkStep} step
473
+ * @returns {Promise<string>}
474
+ */
475
+ async function cardHtml(step) {
476
+ const id = `zoom-${step.index}`;
477
+ const src = await dataUri(step.file);
478
+ const picture = src
479
+ ? `<input class="zoomer" type="checkbox" id="${id}">` +
480
+ `<label class="frame" for="${id}"><img alt="${escapeHtml(step.name)}" src="${src}"></label>`
481
+ : `<div class="nopic">No picture — the app never got this far.</div>`;
482
+
483
+ /** @type {string[]} */
484
+ const problems = [];
485
+ if (step.error) problems.push(`<h3>This screen could not be reached</h3><p>${escapeHtml(step.error)}</p>`);
486
+ if ((step.consoleErrors ?? []).length > 0) {
487
+ const list = (step.consoleErrors ?? [])
488
+ .slice(0, 20)
489
+ .map((e) => `<li>${escapeHtml(e)}</li>`)
490
+ .join('');
491
+ problems.push(`<h3>The app complained while this screen was open</h3><ul>${list}</ul>`);
492
+ }
493
+
494
+ return ` <section class="card">
495
+ <div class="head"><span class="num">${String(step.index).padStart(2, '0')}</span><span class="name">${escapeHtml(step.name)}</span></div>
496
+ ${step.describe ? `<p class="desc">${escapeHtml(step.describe)}</p>` : ''}
497
+ ${picture}
498
+ ${step.url || step.title ? `<p class="where">${escapeHtml([step.title, step.url].filter(Boolean).join(' — '))}</p>` : ''}
499
+ ${problems.length ? `<div class="problem">${problems.join('')}</div>` : ''}
500
+ </section>`;
501
+ }
502
+
503
+ /**
504
+ * @param {string} file
505
+ * @returns {Promise<string|null>}
506
+ */
507
+ async function dataUri(file) {
508
+ if (!file) return null;
509
+ try {
510
+ const png = await fsp.readFile(file);
511
+ return `data:image/png;base64,${png.toString('base64')}`;
512
+ } catch {
513
+ return null;
514
+ }
515
+ }
516
+
517
+ /**
518
+ * '20260829-014530' -> '29 Aug 2026 at 01:45'.
519
+ * @param {string} id
520
+ * @returns {string}
521
+ */
522
+ function readableId(id) {
523
+ const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/.exec(id);
524
+ if (!m) return id;
525
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
526
+ const month = months[Number(m[2]) - 1] ?? m[2];
527
+ return `${Number(m[3])} ${month} ${m[1]} at ${m[4]}:${m[5]}`;
528
+ }
529
+
530
+ /**
531
+ * @param {string} s
532
+ * @returns {string}
533
+ */
534
+ function escapeHtml(s) {
535
+ return String(s)
536
+ .replace(/&/g, '&amp;')
537
+ .replace(/</g, '&lt;')
538
+ .replace(/>/g, '&gt;')
539
+ .replace(/"/g, '&quot;')
540
+ .replace(/'/g, '&#39;');
541
+ }