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/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
|
+
}
|