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
package/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version
5
+ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ Nothing yet.
10
+
11
+ ## [0.1.0] — 2026-08-29
12
+
13
+ The first version. It is a first version: it works, it has been used, and it has
14
+ not been used by many people yet.
15
+
16
+ ### Added
17
+
18
+ - **Picture checks.** Opens the real app, photographs the screens listed in the
19
+ config, and compares each one against the picture a human approved. Anything
20
+ that moved fails the run until a person looks at it and says yes.
21
+ - **Guards.** One check per bug that was already fixed once, named in plain
22
+ language ("the sidebar still collapses"). The tool refuses names that read like
23
+ code identifiers or issue numbers, because the name is what somebody has to
24
+ understand six months later.
25
+ - **Walk.** Opens the built app before a release, walks the screens in order,
26
+ photographs each step, and leaves behind a single page you can scroll through.
27
+ - **Markers.** Pins a known-good moment — a release, or just before something
28
+ risky — and `trace` then reports the last marker where a screen still looked
29
+ right, the first one where it did not, and the commits in between.
30
+ - **A flake register.** Every run is remembered. A check that changes its mind
31
+ while the code stood still is recorded as a flake, and past the limit it is
32
+ condemned and says so in red until a person fixes it or deletes it.
33
+ - **The freeze layer.** Frozen clock and time zone, animations and transitions
34
+ stopped, seeded randomness, external network requests blocked or replayed from
35
+ recorded fixtures, fonts and images waited for, text rasterisation pinned,
36
+ scrollbars and text carets hidden, and a settle loop that only accepts a photo
37
+ once two in a row agree.
38
+ - **An MCP server**, so Claude Code, Codex, Gemini CLI, Cursor and anything else
39
+ that speaks the Model Context Protocol can check their own work the moment they
40
+ finish editing. The approve tool is not offered to an agent unless the project
41
+ explicitly opts in, and it is off by default.
42
+ - **Commands:** `init`, `check`, `approve`, `walk`, `mark`, `trace`, `status`,
43
+ `flake`, `doctor`, `mcp`.
44
+ - **Web apps and Electron apps.** Config in JavaScript, or in JSON so a project
45
+ in any language can use the tool without anybody writing JavaScript.
46
+ - Two runtime dependencies, `pngjs` and `pixelmatch`. No build step: the source
47
+ in the repository is the code that runs.
48
+
49
+ ### Known limits
50
+
51
+ - Not published to npm yet. Run it from GitHub: `npx github:asadev/staysfixed`.
52
+ - Approved pictures are tied to the operating system that took them. A picture
53
+ approved on macOS will not match on Linux.
54
+ - Untested on Windows.
55
+ - Chromium-based rendering only — Chrome, Chromium, Edge, Brave, or the Chromium
56
+ inside your Electron app. No Firefox, no WebKit.
57
+ - No phone or tablet simulators.
58
+ - No hosted service, no dashboard, no accounts.
59
+
60
+ [Unreleased]: https://github.com/asadev/staysfixed/compare/v0.1.0...HEAD
61
+ [0.1.0]: https://github.com/asadev/staysfixed/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asad Iqbal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,529 @@
1
+ # Stays Fixed
2
+
3
+ A test runner that proves what already worked still works after an AI agent
4
+ changed the code.
5
+
6
+ AI agents change a lot of code very quickly, and the thing that breaks is almost
7
+ never the thing they were working on — it is something in the corner that had
8
+ already been built, already been fixed, and that nobody thought to look at again.
9
+ Ordinary tests read the code, and code that reads fine can still render a page
10
+ with no stylesheet, a collapsed sidebar or a button pushed off the screen. A
11
+ picture can see that. So Stays Fixed opens your real app, photographs the screens
12
+ that matter, and compares them against pictures a human approved.
13
+
14
+ Four nets, one engine:
15
+
16
+ | | |
17
+ | --- | --- |
18
+ | **Picture checks** | Photograph the screens that matter and fail on any visible difference — until a person approves the new picture. |
19
+ | **Guards** | One check per bug that was already fixed once, named in plain English, whose only job is to fail the day that bug comes back. |
20
+ | **Walk** | Before a release, open the real built app, walk the main routes, and photograph every step onto one page you can scroll. |
21
+ | **Markers** | Pin each known-good release, so when something does regress you trace it to the exact commit in minutes. |
22
+
23
+ It runs as a command you type, and as an MCP server so Claude Code, Codex, Gemini
24
+ CLI or Cursor can check their own work the moment they finish editing. **An agent
25
+ can check. Only a human can approve.**
26
+
27
+ ---
28
+
29
+ ## Install and first run
30
+
31
+ **It is not on npm yet.** Run it from GitHub — that is the way to use it today:
32
+
33
+ ```
34
+ npx github:asadev/staysfixed init
35
+ npx github:asadev/staysfixed check
36
+ ```
37
+
38
+ `init` asks what your app is, writes a `staysfixed.config.js` you can read, makes
39
+ the `.staysfixed/` folder and adds the two lines to your `.gitignore` that keep
40
+ the throwaway results out of git.
41
+
42
+ `check` opens the app, takes the pictures, and — the first time — tells you that
43
+ nobody has approved any of them yet. Look at them, and approve the ones that are
44
+ right:
45
+
46
+ ```
47
+ npx github:asadev/staysfixed approve --all
48
+ ```
49
+
50
+ From then on, `check` is silent unless something actually moved.
51
+
52
+ Requirements: **Node 22 or newer**, and a Chromium-based browser on the machine
53
+ (Chrome, Chromium, Edge or Brave — or, for a desktop app, the Chromium already
54
+ inside your Electron build). `npx github:asadev/staysfixed doctor` tells you what
55
+ it found and what it is missing.
56
+
57
+ Two runtime dependencies, `pngjs` and `pixelmatch`. No build step: the JavaScript
58
+ in the repository is the JavaScript that runs.
59
+
60
+ ---
61
+
62
+ ## The four nets
63
+
64
+ ### 1. Picture checks
65
+
66
+ Photograph the screens that matter, compare against the approved picture, fail on
67
+ any visible difference.
68
+
69
+ ```
70
+ $ staysfixed check
71
+
72
+ ✓ home still the same 1.4s
73
+ ✓ signed-in-dashboard still the same 2.1s
74
+ ✓ settings-notifications still the same 1.9s
75
+ ✗ billing-empty looks different — 4,118 pixels changed 2.3s
76
+ ! pricing-card-pro nobody has approved this picture yet 900ms
77
+ look at it, then run: staysfixed approve pricing-card-pro
78
+ ✓ the sidebar still collapses still holds 400ms
79
+ ✓ logging out clears the session still holds 700ms
80
+
81
+ ✗ 1 thing changed. Look at it before you ship. 1 new screen is waiting for a person to approve it.
82
+ 5 screens, 2 guards, about 12 seconds.
83
+
84
+ What is not right
85
+ name what happened where to look
86
+ billing-empty looks different — 4,118 pixels changed .staysfixed/results/diffs/billing-empty.diff.png
87
+ pricing-card-pro nobody has approved this picture yet .staysfixed/results/pricing-card-pro.png
88
+
89
+ What to do next
90
+ Look at each picture in the report. If the new one is what you meant, approve it:
91
+ staysfixed approve billing-empty
92
+ staysfixed approve pricing-card-pro
93
+ Or accept every one of them: staysfixed approve --all
94
+ The pictures, side by side: .staysfixed/report.html
95
+ ```
96
+
97
+ Open `.staysfixed/report.html` — one self-contained page with the old picture,
98
+ the new one and the difference side by side. If the new look is what you meant,
99
+ approve it and it becomes the picture everything is measured against from now on.
100
+
101
+ Approved pictures live in `.staysfixed/approved/` and belong in git. They are the
102
+ promise. The `results/` folder is only evidence from the last run and is ignored.
103
+
104
+ ### 2. Guards
105
+
106
+ One check per bug that has already been fixed once. Its only job is to fail on
107
+ the day that bug comes back.
108
+
109
+ ```js
110
+ // .staysfixed/guards/the-sidebar-still-collapses.js
111
+ export default {
112
+ name: 'the sidebar still collapses',
113
+ because: 'A CSS rename broke the toggle handler and it shipped unnoticed for four days.',
114
+ async run(app) {
115
+ await app.open('/');
116
+ await app.click('[data-action="toggle-sidebar"]');
117
+ await app.expect('the sidebar is hidden', async () => !(await app.page.visible('.sidebar')));
118
+ },
119
+ };
120
+ ```
121
+
122
+ ```
123
+ $ staysfixed check --guards-only
124
+
125
+ ✗ prices still show two decimals This should still be true, and it is not: "the total shows two decimals". 300ms
126
+ expected: the total shows two decimals
127
+ why this guard exists: A rounding change made the cart show $12.5 instead of $12.50 for two days.
128
+ ```
129
+
130
+ The name is not decoration. It is what prints when the guard fails, what goes in
131
+ the report, and what an agent reads before deciding whether it broke something.
132
+ So names are enforced: `sidebar_collapse_test` and `#4412` are refused, with an
133
+ explanation and — where one can honestly be built — a rewrite. Three plain words
134
+ minimum, present tense, no test ids. See [docs/guards.md](docs/guards.md).
135
+
136
+ **It does not steal your screen.** A desktop app has to really open to be
137
+ photographed, but it opens *behind* whatever you are using and stays there. The
138
+ rendering flags keep it painting while it sits in the background, so the pictures
139
+ are identical either way — bring it to the front yourself whenever you want to
140
+ watch it work. Set `app.foreground: true` if you would rather it came forward.
141
+
142
+ ### 3. Walk
143
+
144
+ Before a release, open the real built app, walk the main routes, photograph each
145
+ step, and leave behind one page you can scroll through in thirty seconds.
146
+
147
+ ```
148
+ $ staysfixed walk
149
+
150
+ ✓ Walked 6 screens and every one of them opened.
151
+ Every screen it photographed: .staysfixed/results/walk-20260829-013245/index.html
152
+ ```
153
+
154
+ Nothing is compared and nothing can fail on a pixel here. This net is for the
155
+ question a picture check cannot answer: *does the thing I am about to ship
156
+ actually open?*
157
+
158
+ ### 4. Markers
159
+
160
+ Pin each known-good moment — a release, or just before you start something risky.
161
+ Everything is checked first, and the marker is refused if anything is not
162
+ passing.
163
+
164
+ ```
165
+ $ staysfixed mark v0.1.0 --note "first public build"
166
+ ```
167
+
168
+ Then, when something has regressed and you have no idea when:
169
+
170
+ ```
171
+ $ staysfixed trace billing-empty
172
+
173
+ billing-empty
174
+ It was still right at "v0.1.0" and already different by "v0.2.0". The change is in between.
175
+ 2 commits landed in between:
176
+ 3f9c1ab 2026-08-24 Move the empty state into its own component Asad Iqbal
177
+ 77d0e42 2026-08-25 Tidy the card styles Asad Iqbal
178
+ files those commits touched:
179
+ src/billing/EmptyState.jsx
180
+ src/styles/cards.css
181
+
182
+ Looked through 3 markers.
183
+ ```
184
+
185
+ ### And the rest
186
+
187
+ ```
188
+ staysfixed status what is set up here, and how the last check went
189
+ staysfixed flake checks that have changed their mind
190
+ staysfixed doctor what is missing before any of this can run
191
+ staysfixed mcp serve to an AI agent (see below)
192
+ ```
193
+
194
+ ```
195
+ $ staysfixed status
196
+
197
+ Stays Fixed
198
+ watching ~/Projects/shop
199
+ settings in ~/Projects/shop/staysfixed.config.js
200
+
201
+ 6 approved pictures
202
+ 6 screens in the settings
203
+ 3 guards
204
+ 2 known-good markers
205
+ newest marker: v0.1.0 — pinned 2 days ago
206
+
207
+ last checked 11 minutes ago at a1b2c3d on main, took about 12 seconds
208
+ ✓ Everything that worked still works.
209
+ ```
210
+
211
+ ---
212
+
213
+ ## How it keeps pictures stable
214
+
215
+ A picture check is only worth having if it is silent when nothing changed. The
216
+ moment it fails for a reason nobody caused, people learn to ignore it — and once
217
+ they ignore it, the real regression walks through with everything else. So most
218
+ of the work in this tool is removing every reason a picture could change on its
219
+ own:
220
+
221
+ 1. **Frozen clock.** The app always believes it is the same instant, in the same
222
+ time zone and locale. Timers still fire, so nothing hangs; only the reading of
223
+ the clock is pinned.
224
+ 2. **No motion.** Animations, transitions, smooth scrolling and video are stopped
225
+ three different ways, and the page is told the machine prefers reduced motion.
226
+ 3. **Seeded randomness.** `Math.random`, `crypto.getRandomValues` and
227
+ `randomUUID` are replaced with a seeded generator, so a shuffled list is
228
+ shuffled the same way every time.
229
+ 4. **Frozen data.** External network requests are blocked, or replayed byte for
230
+ byte from recordings kept in the repository, so your picture never depends on
231
+ somebody else's server.
232
+ 5. **Pinned rendering.** Text smoothing, glyph positioning and font synthesis are
233
+ fixed in CSS; hinting, LCD text, subpixel positioning and GPU rasterisation
234
+ are switched off in the browser; the colour profile is forced to sRGB.
235
+ 6. **Wait for stillness.** Fonts and images are waited for, focus rings are
236
+ cleared, and then the tool photographs the screen repeatedly and only accepts a
237
+ picture once two in a row are identical.
238
+ 7. **Blackout boxes.** Anything genuinely allowed to change — a live clock, a
239
+ session id, a "3 minutes ago" — is painted over on **both** pictures, so
240
+ adding a mask never forces a re-approval.
241
+ 8. **Sensible tolerance.** 0.05% of pixels by default: enough to absorb hinting
242
+ noise, nowhere near enough to hide a missing stylesheet or a shifted column.
243
+ 9. **A flake register.** Every run is remembered. A check that changes its mind
244
+ while the code stood still is recorded, and past the limit it is condemned and
245
+ says so in red until a person fixes it or deletes it. There is no option to
246
+ tolerate one.
247
+
248
+ **The honest caveat.** A picture is tied to the operating system that took it. A
249
+ picture approved on macOS will not match on Linux — the font stack is different,
250
+ the fallback faces are different, and the text rasteriser is a different piece of
251
+ code. No flag fixes this. Approved pictures are stamped with the platform that
252
+ took them and comparing across platforms warns you.
253
+
254
+ Two ways to live with it. **Take the pictures in one place** — approve on CI, or
255
+ on one machine everyone shares. That is simpler and it is what most projects
256
+ should do. Or **approve per platform**, by setting `dir` from an environment
257
+ variable so each platform keeps its own approved folder.
258
+
259
+ The long version, with what each trick cannot fix, is in
260
+ [docs/how-it-stays-stable.md](docs/how-it-stays-stable.md).
261
+
262
+ ---
263
+
264
+ ## For AI agents (MCP)
265
+
266
+ An agent that has just changed twenty files has no way of knowing whether it
267
+ broke the settings page, because it never opened the settings page. With this
268
+ wired in, it can check before it tells you it is done.
269
+
270
+ Claude Code:
271
+
272
+ ```
273
+ claude mcp add staysfixed -- npx -y github:asadev/staysfixed mcp
274
+ ```
275
+
276
+ Cursor, Gemini CLI, and most other clients take the same block —
277
+ `.cursor/mcp.json`, `~/.gemini/settings.json`, or your project's `.mcp.json`:
278
+
279
+ ```json
280
+ {
281
+ "mcpServers": {
282
+ "staysfixed": {
283
+ "command": "npx",
284
+ "args": ["-y", "github:asadev/staysfixed", "mcp"],
285
+ "cwd": "/absolute/path/to/your/project"
286
+ }
287
+ }
288
+ }
289
+ ```
290
+
291
+ Codex keeps the same fields in TOML, in `~/.codex/config.toml`:
292
+
293
+ ```toml
294
+ [mcp_servers.staysfixed]
295
+ command = "npx"
296
+ args = ["-y", "github:asadev/staysfixed", "mcp"]
297
+ cwd = "/absolute/path/to/your/project"
298
+ ```
299
+
300
+ The tools an agent gets:
301
+
302
+ | Tool | What it does |
303
+ | --- | --- |
304
+ | `staysfixed_screens` | Lists the screens and guards this project watches. Cheap — does not open the app. Call it first. |
305
+ | `staysfixed_check` | Opens the app, photographs everything, runs the guards. Returns the verdict, what is not passing, and the diff image of each changed screen. |
306
+ | `staysfixed_capture` | Photographs one screen and hands back the picture. Compares nothing, changes nothing. |
307
+ | `staysfixed_status` | Approved pictures, guards, markers, last run, anything condemned for flaking. |
308
+ | `staysfixed_trace` | Which change broke this screen — last good marker, first bad one, the commits between. |
309
+
310
+ **An agent can check; only a human can approve.** `staysfixed_approve` is not
311
+ merely refused — it is not in the tool list at all unless the project explicitly
312
+ opts in, so the agent never sees a door to push on. That is the entire point of
313
+ the tool: an agent that can bless its own screenshots would edit the code, notice
314
+ the picture moved, approve the new picture, and report success, and your safety
315
+ net would have become a rubber stamp.
316
+
317
+ Full wiring instructions for every client: [docs/mcp.md](docs/mcp.md).
318
+
319
+ ---
320
+
321
+ ## Config reference
322
+
323
+ Everything is optional except `app` and `screens`. A five-line config works.
324
+
325
+ ```js
326
+ /** @type {import('staysfixed/src/types.js').StaysFixedConfig} */
327
+ export default {
328
+ // --- What to open -------------------------------------------------------
329
+ app: {
330
+ kind: 'web', // 'web' or 'electron'
331
+
332
+ // web:
333
+ url: 'http://localhost:3000', // the address; relative screen urls hang off it
334
+ start: 'npm run preview', // optional command that starts the app
335
+ browser: '/path/to/chrome', // optional; found on the system by default
336
+ headless: true, // default true
337
+
338
+ // electron:
339
+ // binary: '/Applications/Your App.app/Contents/MacOS/Your App',
340
+ // args: ['--skip-onboarding'],
341
+ // windowMatch: 'Your App', // only drive the window whose title/url contains this
342
+
343
+ cwd: '.', // working directory for start / binary
344
+ env: { NODE_ENV: 'production' }, // extra environment for the launched process
345
+ startTimeoutMs: 60000, // how long to wait for the app to answer
346
+ debugPort: 9333, // default: a free one is picked
347
+ // attach: 'http://127.0.0.1:9333' // drive something already running instead of launching
348
+ },
349
+
350
+ // --- How big the window is ----------------------------------------------
351
+ // Change this and every approved picture stops matching. Pick a size once.
352
+ viewport: {
353
+ width: 1440,
354
+ height: 900,
355
+ deviceScaleFactor: 2, // 2 = retina-sharp, still deterministic
356
+ mobile: false, // emulate a touch device
357
+ },
358
+
359
+ // --- Holding the app still ----------------------------------------------
360
+ freeze: {
361
+ clock: '2026-01-01T12:00:00.000Z', // the instant the app believes it is; false = leave time alone
362
+ timezone: 'UTC',
363
+ locale: 'en-US',
364
+ motion: true, // kill animations, transitions, video, smooth scroll
365
+ random: 'seeded', // 'seeded' or 'off'
366
+ seed: 20260101,
367
+ fonts: true, // wait for fonts and images, pin text rendering
368
+ network: 'block-external', // 'block-external' | 'replay' | 'live'
369
+ networkAllow: ['https://fonts.gstatic.com/**'], // globs let out even when blocking
370
+ hideScrollbars: true,
371
+ hideCaret: true, // the text cursor blinks; hide it
372
+ settle: {
373
+ frames: 2, // identical photos in a row before we accept one
374
+ intervalMs: 250,
375
+ timeoutMs: 10000,
376
+ maxDriftPixels: 0, // pixels allowed to differ and still count as identical
377
+ },
378
+ },
379
+
380
+ // --- How much difference is allowed --------------------------------------
381
+ tolerance: {
382
+ pixels: 0.0005, // share of pixels allowed to differ, 0..1
383
+ threshold: 0.12, // per-pixel colour sensitivity, lower = stricter
384
+ antialiasing: true, // ignore anti-aliasing noise
385
+ maxPixels: 500, // a hard cap; overrides `pixels` when set
386
+ },
387
+
388
+ // --- Things allowed to change, painted over before comparing -------------
389
+ // A CSS selector covers every element it matches; a rectangle covers an exact
390
+ // area in CSS pixels. Applied to every screen.
391
+ masks: ['[data-live-clock]', { x: 0, y: 0, width: 240, height: 32 }],
392
+
393
+ // --- The screens ---------------------------------------------------------
394
+ screens: [
395
+ {
396
+ name: 'billing-empty', // file-safe id; becomes the picture's file name
397
+ describe: 'Billing with no invoices yet', // shown to humans
398
+ url: '/billing', // shorthand for a single goto step
399
+
400
+ // Or a list of steps, which also works in staysfixed.config.json:
401
+ steps: [
402
+ { goto: '/billing' }, // navigate; relative resolves against app.url
403
+ { waitFor: '.invoice-list' }, // wait for a selector
404
+ { waitForGone: '.spinner' }, // wait for one to disappear
405
+ { scrollTo: '#totals' }, // scroll an element into view
406
+ { hover: '.plan-card' },
407
+ { click: 'button.new' },
408
+ { type: 'input[name="q"]', text: 'hello' }, // type into a field
409
+ { press: 'Enter' },
410
+ { evaluate: 'window.scrollTo(0, 0)' }, // run JavaScript in the page
411
+ { wait: 200 }, // last resort; settle usually beats this
412
+ { note: 'A human note, shown in reports.' },
413
+ ],
414
+
415
+ // Or code, when the steps need a decision (JS config only):
416
+ // async do(page) { await page.goto('/billing'); await page.click('#tab'); },
417
+
418
+ masks: ['.invoice-date'], // extra masks for this screen only
419
+ tolerance: { pixels: 0.001 }, // override tolerance for this screen only
420
+ viewport: { width: 720 }, // override the size for this screen only
421
+ freeze: { settle: { timeoutMs: 20000 } }, // per-screen freeze overrides
422
+ clip: '[data-plan="pro"]', // photograph only this element
423
+ fullPage: false, // photograph the whole scrollable page
424
+ skip: false, // leave it out for now, without deleting it
425
+ },
426
+ ],
427
+
428
+ // --- Guards: one check per bug already fixed once ------------------------
429
+ guards: '.staysfixed/guards', // folder of plain JavaScript files
430
+
431
+ // --- The pre-release walk -----------------------------------------------
432
+ walk: {
433
+ describe: 'What a reviewer clicks through before a release',
434
+ steps: [ /* same shape as screens; defaults to `screens` */ ],
435
+ },
436
+
437
+ // --- What an AI agent may do through the MCP server ----------------------
438
+ mcp: {
439
+ allowApprove: false, // let an agent approve pictures. FALSE on purpose.
440
+ allowMark: false, // let an agent write known-good markers
441
+ },
442
+
443
+ // --- Housekeeping --------------------------------------------------------
444
+ dir: '.staysfixed', // where approved pictures, guards and markers live
445
+ flakeLimit: 2, // flakes before a check is condemned
446
+ retries: 1, // re-captures before calling a difference real
447
+ concurrency: 1, // screens at once. One, on purpose: determinism first.
448
+ };
449
+ ```
450
+
451
+ A `staysfixed.config.json` file works too, with the declarative `steps` form and
452
+ no `do(page)` functions — so a Rust, Python or Go project can use the tool
453
+ without anybody writing JavaScript.
454
+
455
+ Two fuller examples, heavily commented, are in
456
+ [`examples/`](examples/): [a web app](examples/staysfixed.config.web.js),
457
+ [an Electron app](examples/staysfixed.config.electron.js), and
458
+ [a guard](examples/guards/the-sidebar-still-collapses.js).
459
+
460
+ ---
461
+
462
+ ## Does it actually work?
463
+
464
+ Two pieces of evidence ship with the repository.
465
+
466
+ **The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
467
+ photograph: a clock ticking ten times a second, a relative timestamp, an endless
468
+ CSS spinner, a Web Animations tween, a shuffled list, a random number, a random
469
+ uuid, a chart of random bars, a blinking caret, an autofocused input, a web font,
470
+ an image that arrives late, and a feed the server answers differently every single
471
+ time it is asked. `npm test` photographs it **twenty times and requires every
472
+ picture to be byte-for-byte identical**. If that ever fails, the tool is broken and
473
+ nothing else in the suite matters.
474
+
475
+ **A real desktop app.** It was pointed at a real Electron application — 11 screens
476
+ and 2 guards, about 25 seconds a run, five consecutive runs with not one pixel of
477
+ difference. Then one line was removed from the built app: the `<link>` to its
478
+ stylesheet. Every one of the 11 pictures failed, and so did the guard written for
479
+ exactly that bug:
480
+
481
+ ```
482
+ ✗ start looks different — 171,709 pixels changed
483
+ ✗ overview looks different — 188,813 pixels changed
484
+ ✗ files looks different — 187,242 pixels changed
485
+ ...
486
+ ✗ the app still has its styling
487
+ expected: the window is not plain white
488
+ why this guard exists: one release shipped with the whole app unstyled and
489
+ every one of its ~3,600 tests passed, because none of them could see it.
490
+ ```
491
+
492
+ Putting the line back made it green again on the next run.
493
+
494
+ Three checks were **deleted** during that run rather than tolerated — they wobbled,
495
+ and the rule in this tool is that a check which wobbles twice gets fixed or deleted.
496
+ That rule applies to the tool's own checks too.
497
+
498
+ ---
499
+
500
+ ## What version 0.1 does not do
501
+
502
+ Honestly, so you know before you invest an afternoon:
503
+
504
+ - **Not on npm yet.** Run it from GitHub: `npx github:asadev/staysfixed`.
505
+ - **No hosted service, no dashboard, no accounts, no teams, nothing paid.** It is
506
+ a command and a folder of files in your repository. There is no server
507
+ anywhere, and nothing is uploaded.
508
+ - **No history or analytics.** The flake register remembers whether a check has
509
+ wobbled; it does not chart anything over time and there is no trend view.
510
+ - **No phone or tablet simulators.** You can emulate a narrow viewport and touch,
511
+ which catches layout, but a real iOS or Android simulator is not supported.
512
+ - **Windows is untested.** The code has no deliberate Unix assumptions and CI runs
513
+ on Linux and macOS, but nobody has run it on Windows, so treat it as unknown.
514
+ - **Chromium-based rendering only.** Chrome, Chromium, Edge, Brave, or the
515
+ Chromium inside your Electron app. No Firefox and no WebKit, so this tool will
516
+ not tell you that something broke in Safari.
517
+ - **Pictures do not travel between operating systems.** See the caveat above.
518
+ - **Not battle-tested.** This is a first version. It works, it is used, and it has
519
+ not yet met the thousand strange apps that a widely-used tool meets. If it
520
+ reports something that is not true, that is the most serious kind of bug it can
521
+ have — please [open an issue](https://github.com/asadev/staysfixed/issues).
522
+
523
+ ---
524
+
525
+ ## Licence
526
+
527
+ MIT. See [LICENSE](LICENSE).
528
+
529
+ Built by Asad Iqbal.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Colour is settled the moment the logging module first loads, and a closed pipe
4
+ // is not a crash — both have to be dealt with before anything else is imported.
5
+ if (process.argv.includes('--no-color')) process.env.NO_COLOR = '1';
6
+ process.stdout.on('error', () => {});
7
+
8
+ const { EXIT } = await import('../src/core/errors.js');
9
+
10
+ try {
11
+ const { main } = await import('../src/cli/index.js');
12
+ process.exitCode = await main(process.argv.slice(2));
13
+ } catch (err) {
14
+ const { errorReport } = await import('../src/report/console.js');
15
+ errorReport(err);
16
+ const code = Number(/** @type {{exitCode?: unknown}} */ (Object(err)).exitCode);
17
+ process.exitCode = Number.isInteger(code) ? code : EXIT.error;
18
+ }