staysfixed 0.6.2 → 0.7.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 +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/docs/guards.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Guards
|
|
2
|
+
|
|
3
|
+
A guard is one check per bug that has already been fixed once. Its only job is to
|
|
4
|
+
fail on the day that bug comes back.
|
|
5
|
+
|
|
6
|
+
That is the whole idea. A guard is not a unit test and it is not a spec. Nobody
|
|
7
|
+
writes a guard for behaviour that has never broken. You write one the moment you
|
|
8
|
+
finish fixing something, while you still remember exactly what went wrong, and
|
|
9
|
+
then you never think about it again until the day it saves you.
|
|
10
|
+
|
|
11
|
+
Picture checks catch what you can see. Guards catch what you cannot: a keyboard
|
|
12
|
+
shortcut that stopped firing, a build that started emitting the wrong file, a
|
|
13
|
+
session that stopped being cleared on logout.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Where they live
|
|
18
|
+
|
|
19
|
+
A folder of plain JavaScript files, `.staysfixed/guards/` by default. Files
|
|
20
|
+
starting with `_` or `.` are skipped, which is a handy way to park one. The
|
|
21
|
+
default export is the guard; a file may also export an array of guards, or
|
|
22
|
+
several named exports.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
export default {
|
|
26
|
+
name: 'the sidebar still collapses',
|
|
27
|
+
because: 'A CSS rename broke the toggle handler and it shipped.',
|
|
28
|
+
async run(app) {
|
|
29
|
+
await app.open('/');
|
|
30
|
+
await app.click('[data-action="toggle-sidebar"]');
|
|
31
|
+
await app.expect('the sidebar is hidden', async () => !(await app.page.visible('.sidebar')));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
They run inside `staysfixed check`, in the same real app the pictures are taken
|
|
37
|
+
from. `staysfixed check --guards-only` runs just the guards, which is much faster
|
|
38
|
+
and is what you want when your edit could not possibly change how anything looks.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## The name is the point
|
|
43
|
+
|
|
44
|
+
The name is the whole handover. It is what prints when the guard fails, what goes
|
|
45
|
+
in the report, what an agent reads back before deciding whether it broke
|
|
46
|
+
something, and what a person has to judge in five seconds at one in the morning.
|
|
47
|
+
Six months from now the name is all that is left of the bug.
|
|
48
|
+
|
|
49
|
+
So the tool refuses names that only make sense to whoever typed them. This is
|
|
50
|
+
enforced, not suggested — a bad name is rejected when the guards load, with an
|
|
51
|
+
explanation and, where the tool can honestly build one, a rewrite.
|
|
52
|
+
|
|
53
|
+
**The rule:** write what the app is supposed to do, in the words you would say out
|
|
54
|
+
loud. At least three words. Present tense. No test ids.
|
|
55
|
+
|
|
56
|
+
### What gets refused
|
|
57
|
+
|
|
58
|
+
| Refused | Why |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `sidebar_collapse_test` | A code identifier, not a sentence. Guard names are printed to people, so use spaces and ordinary words. |
|
|
61
|
+
| `#4412` or `BUG-88` | An issue number. The number tells nobody what broke — put it in `link` and use the name to say what should still work. |
|
|
62
|
+
| `test sidebar collapse` | Starts with a test word. That describes a test, not the app. Drop it. |
|
|
63
|
+
| `should collapse` | Same, and only two words. |
|
|
64
|
+
| `sidebar` | One word. It says which area it touches, not what should still be true. |
|
|
65
|
+
| `SIDEBAR COLLAPSES` | ALL CAPS reads like shouting, not like a sentence. |
|
|
66
|
+
| `src/ui/sidebar.js` | A file name. Say what the app should still do, not where the code lives. |
|
|
67
|
+
| `Sidebar#collapse` | `#` and `::` read like code references. Put the reference in `link`. |
|
|
68
|
+
| A 140-character paragraph | Over 120 characters. The name is a short sentence; the story goes in `because`. |
|
|
69
|
+
|
|
70
|
+
### What passes
|
|
71
|
+
|
|
72
|
+
- `the sidebar still collapses`
|
|
73
|
+
- `prices still show two decimals`
|
|
74
|
+
- `logging out clears the session`
|
|
75
|
+
- `the export button still produces a csv`
|
|
76
|
+
- `keyboard shortcuts still work after the modal closes`
|
|
77
|
+
- `the settings window remembers its size`
|
|
78
|
+
|
|
79
|
+
Notice they are all sentences you could say to a colleague, and each one names
|
|
80
|
+
the behaviour rather than the code.
|
|
81
|
+
|
|
82
|
+
Two guards may not share a name. When one fails, the report shows the name — and
|
|
83
|
+
if two guards share it, nobody can tell which one broke.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## The fields
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
export default {
|
|
91
|
+
// Required. Plain language, three words or more.
|
|
92
|
+
name: 'the sidebar still collapses',
|
|
93
|
+
|
|
94
|
+
// When it was fixed. Free text — a date, a version, whatever you would say.
|
|
95
|
+
fixed: '2026-08-14',
|
|
96
|
+
|
|
97
|
+
// The story of the bug, in a sentence or two. Printed underneath the failure,
|
|
98
|
+
// so somebody who has never seen this bug knows what they are looking at.
|
|
99
|
+
because: 'A CSS refactor renamed .sidebar--open everywhere except the toggle handler.',
|
|
100
|
+
|
|
101
|
+
// An issue, a commit, a note. Anywhere to read more.
|
|
102
|
+
link: 'https://github.com/asadev/staysfixed/issues/12',
|
|
103
|
+
|
|
104
|
+
// Park it without deleting it. `check` reports it as left out on purpose.
|
|
105
|
+
skip: false,
|
|
106
|
+
|
|
107
|
+
// How long it gets before it is called failed. Default 30000.
|
|
108
|
+
timeoutMs: 20_000,
|
|
109
|
+
|
|
110
|
+
// The check itself.
|
|
111
|
+
async run(app) { /* ... */ },
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## What `app` gives you
|
|
118
|
+
|
|
119
|
+
| | |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `app.page` | The full page. `goto`, `click`, `type`, `press`, `hover`, `waitFor`, `waitForGone`, `scrollTo`, `wait`, `evaluate`, `visible`, `exists`, `textOf`, `count`, `boxOf`, `url`, `title`, `shoot`, `setViewport`, `consoleErrors`. |
|
|
122
|
+
| `app.open(path)` | Shorthand for `page.goto`. |
|
|
123
|
+
| `app.click(selector)` | Shorthand for `page.click`. |
|
|
124
|
+
| `app.expect(sentence, check)` | An assertion, in plain language. |
|
|
125
|
+
| `app.run(cmd, opts)` | Run a shell command in the project root. Returns `{ code, stdout, stderr }`. A non-zero exit is returned, never thrown — whether it means failure is the guard's decision. |
|
|
126
|
+
| `app.read(file)` | Read a project file as text. |
|
|
127
|
+
| `app.project` | The resolved config and paths. |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## The `expect` style
|
|
132
|
+
|
|
133
|
+
An assertion is a sentence plus a check, never a bare comparison:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
await app.expect('the sidebar is hidden', async () => !(await app.page.visible('.sidebar')));
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
When it fails, the terminal says:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
✗ the sidebar still collapses This should still be true, and it is not: "the sidebar is hidden". 0.4s
|
|
143
|
+
expected: the sidebar is hidden
|
|
144
|
+
why this guard exists: A CSS refactor renamed .sidebar--open everywhere except the toggle handler.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Anyone can act on that, including somebody who has never opened this repository.
|
|
148
|
+
Compare it with what a normal assertion library would have printed —
|
|
149
|
+
`AssertionError: expected false to be true` — which tells you nothing at all.
|
|
150
|
+
|
|
151
|
+
The check fails when it returns something falsy, or throws. Anything truthy
|
|
152
|
+
passes. Some shapes that read well:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
// A thing is on screen
|
|
156
|
+
await app.expect('the invoice total is on screen', () => app.page.visible('[data-total]'));
|
|
157
|
+
|
|
158
|
+
// A thing is gone
|
|
159
|
+
await app.expect('the error banner is gone', async () => !(await app.page.exists('.error-banner')));
|
|
160
|
+
|
|
161
|
+
// Text is right
|
|
162
|
+
await app.expect('the total still shows two decimals', async () => {
|
|
163
|
+
const text = await app.page.textOf('[data-total]');
|
|
164
|
+
return /^\$\d+\.\d{2}$/.test(text.trim());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// A count
|
|
168
|
+
await app.expect('all five plans are listed', async () => (await app.page.count('.plan-card')) === 5);
|
|
169
|
+
|
|
170
|
+
// Nothing to do with the screen at all
|
|
171
|
+
await app.expect('the production build still succeeds', async () => {
|
|
172
|
+
const { code } = await app.run('npm run build');
|
|
173
|
+
return code === 0;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await app.expect('the collapsed class is still defined', async () => {
|
|
177
|
+
const css = await app.read('src/styles/sidebar.css');
|
|
178
|
+
return css.includes('.sidebar--collapsed');
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Write the sentence first. If you cannot say in plain words what should be true,
|
|
183
|
+
the guard is not ready to be written.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Writing a good one
|
|
188
|
+
|
|
189
|
+
**Check the thing that actually broke, not only the thing you can see.** If the
|
|
190
|
+
bug was a class name that no longer matched, check the class name as well as the
|
|
191
|
+
visible result. A later refactor could hide the sidebar a different way, and the
|
|
192
|
+
guard should still hold.
|
|
193
|
+
|
|
194
|
+
**Check that the fix did not create a new trap.** A collapse you cannot undo is a
|
|
195
|
+
worse bug than the one you were fixing. Toggle it back.
|
|
196
|
+
|
|
197
|
+
**Keep it to one bug.** A guard that checks four unrelated things fails with one
|
|
198
|
+
name and four possible causes.
|
|
199
|
+
|
|
200
|
+
**Put the story in `because`, not the name.** The name is a sentence. The story
|
|
201
|
+
is a paragraph, and the tool prints it right underneath the failure.
|
|
202
|
+
|
|
203
|
+
**Do not add a guard for something that has never broken.** That is a test, and
|
|
204
|
+
it belongs in your test suite. Guards are a memory of real failures, and their
|
|
205
|
+
value comes from every single one of them mattering.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## The rule that keeps them honest
|
|
210
|
+
|
|
211
|
+
**A guard that flakes twice gets fixed or deleted. Never tolerated.**
|
|
212
|
+
|
|
213
|
+
Every run is remembered. When a guard changes its mind while the git sha and the
|
|
214
|
+
working tree stood still, that is a flake. Past `flakeLimit` — 2 by default — the
|
|
215
|
+
guard is condemned, and `check` says so in red until a person deals with it.
|
|
216
|
+
|
|
217
|
+
There is no option to tolerate a condemned guard, and there never will be. A
|
|
218
|
+
guard nobody believes is worse than no guard: it trains everybody to ignore red,
|
|
219
|
+
and then the real one goes unread. Usually the fix is easy — wait for the right
|
|
220
|
+
thing rather than a fixed delay, use `waitFor` instead of `wait`, or stop
|
|
221
|
+
depending on data that changes.
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
staysfixed flake # the register
|
|
225
|
+
staysfixed flake --clear "the sidebar still collapses" # forgive one that is genuinely fixed
|
|
226
|
+
```
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# How it stays stable
|
|
2
|
+
|
|
3
|
+
A picture check is only worth having if it is silent when nothing changed. The
|
|
4
|
+
moment it fails for a reason nobody caused, people start ignoring it — and once
|
|
5
|
+
they ignore it, the real regression walks through with everything else. So most
|
|
6
|
+
of the engineering in Stays Fixed is not in taking the picture or comparing it.
|
|
7
|
+
It is in removing every reason the picture could change on its own.
|
|
8
|
+
|
|
9
|
+
This page is the long version: each source of wobble, what actually goes wrong,
|
|
10
|
+
what the tool does about it, and — the part that usually goes unsaid — what it
|
|
11
|
+
cannot fix.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Time
|
|
16
|
+
|
|
17
|
+
**What goes wrong.** Almost every app puts time on the screen: a "3 minutes ago",
|
|
18
|
+
a copyright year, a date column, a greeting that says good morning. Any of them
|
|
19
|
+
makes a picture that never matches itself twice. Worse are the invisible ones: a
|
|
20
|
+
component that keys off `Date.now()`, a cache that expires, a token that looks
|
|
21
|
+
stale on the second run.
|
|
22
|
+
|
|
23
|
+
Time zone is the sneakier half. The same instant renders as `14:00` in London and
|
|
24
|
+
`09:00` in New York, so a picture approved on somebody's laptop fails in CI for a
|
|
25
|
+
reason that has nothing to do with the code.
|
|
26
|
+
|
|
27
|
+
**What the tool does.** Two layers, because neither is enough alone.
|
|
28
|
+
|
|
29
|
+
The Chrome DevTools Protocol is told which time zone and locale the renderer
|
|
30
|
+
believes it is in. That reaches `Date.prototype.toString`, `getTimezoneOffset`
|
|
31
|
+
and all the ICU date formatters — places page script cannot touch. The defaults
|
|
32
|
+
are UTC and `en-US`.
|
|
33
|
+
|
|
34
|
+
Then a script injected before any of the app's own code replaces `Date` with a
|
|
35
|
+
subclass whose `new Date()` and `Date.now()` return a fixed instant, by default
|
|
36
|
+
`2026-01-01T12:00:00.000Z`. A subclass rather than a proxy, so `instanceof`,
|
|
37
|
+
every `Date.prototype` method and all date arithmetic keep working untouched.
|
|
38
|
+
|
|
39
|
+
The instant is frozen but the app is not dead: real timers still fire, so a
|
|
40
|
+
spinner that waits 300ms still finishes. Only the *reading* of the clock is
|
|
41
|
+
pinned. `Emulation.setVirtualTimePolicy` — which would genuinely stop time —
|
|
42
|
+
is deliberately not used, because it also stops the app.
|
|
43
|
+
|
|
44
|
+
**What it cannot fix.** Time that comes from your server rather than the browser.
|
|
45
|
+
If the API returns `"created_at"` and the app renders it, the picture depends on
|
|
46
|
+
your database, not on the clock. Seed the data, use `network: 'replay'`, or mask
|
|
47
|
+
the column. And `Date()` called without `new`, which returns a string — app code
|
|
48
|
+
effectively never does this and minifiers never produce it, but it is the one
|
|
49
|
+
thing the subclass gives up.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 2. Movement
|
|
54
|
+
|
|
55
|
+
**What goes wrong.** Anything that moves is a picture that disagrees with itself.
|
|
56
|
+
A fade-in caught at 60% opacity. A skeleton loader shimmering. A carousel one
|
|
57
|
+
slide further along. A modal mid-scale. A `<video>` that has autoplayed to a
|
|
58
|
+
different frame. Timing is what decides which frame you got, and timing is never
|
|
59
|
+
the same twice.
|
|
60
|
+
|
|
61
|
+
**What the tool does.** Three layers, because each catches what the others miss.
|
|
62
|
+
|
|
63
|
+
CSS kills declared animations and transitions — including delayed ones, which
|
|
64
|
+
would otherwise fire later, and infinite ones, which hold a compositor layer
|
|
65
|
+
open. `will-change` is forced back to `auto`: a promoted element is rasterised on
|
|
66
|
+
different pixel boundaries, so taking the promotion away puts it back on the same
|
|
67
|
+
pixels every run. Smooth scrolling becomes instant scrolling.
|
|
68
|
+
|
|
69
|
+
Page script kills what CSS cannot reach: running Web Animations are cancelled
|
|
70
|
+
(cancelled, not finished — finishing an infinite spinner is meaningless and
|
|
71
|
+
finishing a fade-out would hide content you wanted to see), `<video>` elements
|
|
72
|
+
are paused and seeked to frame zero, and `Element.prototype.animate` is replaced
|
|
73
|
+
with a stub that reports itself already finished. The stub matters: libraries
|
|
74
|
+
await `animation.finished` before showing the next thing, so simply deleting
|
|
75
|
+
`animate()` would leave those apps hung half-rendered forever. A mutation
|
|
76
|
+
observer re-sweeps whenever the page changes, because a router-mounted spinner
|
|
77
|
+
or a lazily-faded image brings its own animations with it.
|
|
78
|
+
|
|
79
|
+
The protocol is told the machine prefers reduced motion, which is the only thing
|
|
80
|
+
that stops a well-behaved app starting an animation in the first place.
|
|
81
|
+
|
|
82
|
+
**What it cannot fix.** An animated GIF. A `<canvas>` draw loop that ignores
|
|
83
|
+
`requestAnimationFrame`. A WebGL scene. Anything driven by a `setInterval` that
|
|
84
|
+
paints directly. Mask those regions.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 3. Randomness
|
|
89
|
+
|
|
90
|
+
**What goes wrong.** Randomness reaches a picture in more places than people
|
|
91
|
+
expect: a shuffled list, a placeholder avatar colour, a chart's jitter, a React
|
|
92
|
+
key printed into a data attribute, a generated id that ends up in the
|
|
93
|
+
accessibility tree and then in a tooltip. Any one of them makes a screen that
|
|
94
|
+
never matches itself.
|
|
95
|
+
|
|
96
|
+
**What the tool does.** `Math.random`, `crypto.getRandomValues` and
|
|
97
|
+
`crypto.randomUUID` are all replaced with a seeded generator — mulberry32, 32
|
|
98
|
+
bits of state, well distributed enough that a shuffled list still looks
|
|
99
|
+
shuffled, and identical on every machine on every run. The seed defaults to
|
|
100
|
+
`20260101` and is configurable.
|
|
101
|
+
|
|
102
|
+
The real functions stay reachable on `window.__staysfixed_realRandom`, because a
|
|
103
|
+
handful of apps genuinely need unpredictable bytes — a crypto key, a WebRTC
|
|
104
|
+
session — and would break rather than merely look different.
|
|
105
|
+
|
|
106
|
+
**What it cannot fix.** Randomness that happened on your server. An API that
|
|
107
|
+
returns a random featured item produces a different picture no matter what the
|
|
108
|
+
browser does.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 4. The network
|
|
113
|
+
|
|
114
|
+
**What goes wrong.** This is the single biggest reason a picture stops matching
|
|
115
|
+
tomorrow. A page that fetches an avatar from a CDN, a font from a third party, an
|
|
116
|
+
analytics beacon or a live feed is a page whose picture depends on somebody
|
|
117
|
+
else's server, on today's weather in their data centre, and on the office wifi.
|
|
118
|
+
A stock photo service rotates its image and your check fails at 3am for a change
|
|
119
|
+
nobody made.
|
|
120
|
+
|
|
121
|
+
**What the tool does.** Every request is intercepted, in one of three modes.
|
|
122
|
+
|
|
123
|
+
`live` lets everything through and counts it, so `--verbose` can show you what
|
|
124
|
+
your app is actually reaching for.
|
|
125
|
+
|
|
126
|
+
`block-external` — the default — lets the app's own origin, localhost and an
|
|
127
|
+
allow list out, and refuses everybody else. A tiny glob syntax covers the allow
|
|
128
|
+
list: `*` stops at a path separator, `**` crosses them.
|
|
129
|
+
|
|
130
|
+
`replay` records every reply once into `.staysfixed/fixtures/` and then serves
|
|
131
|
+
those same bytes forever. This is usually the only way a desktop app or an
|
|
132
|
+
API-heavy page renders the same screen twice. Those recordings belong in git —
|
|
133
|
+
they are part of the promise, not part of the evidence.
|
|
134
|
+
|
|
135
|
+
Every paused request gets exactly one answer: continue, fail, or fulfil. A
|
|
136
|
+
request that is paused and never answered stalls the page silently, which looks
|
|
137
|
+
exactly like a hung app, so the code is careful about it. On replay,
|
|
138
|
+
`content-encoding`, `content-length` and `transfer-encoding` headers are dropped:
|
|
139
|
+
the recorded body was handed over already decoded and whole, so telling the
|
|
140
|
+
browser it is gzipped makes the browser throw the reply away and the "replayed"
|
|
141
|
+
run shows a blank page for no visible reason.
|
|
142
|
+
|
|
143
|
+
**What it cannot fix.** Your own backend returning different data. If the app
|
|
144
|
+
talks to a live database, the picture is a picture of that database. Seed it,
|
|
145
|
+
replay it, or accept that the screen is not checkable.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 5. Fonts and images arriving late
|
|
150
|
+
|
|
151
|
+
**What goes wrong.** The most common cause of a picture that "randomly" fails is
|
|
152
|
+
a font or an image that had not landed yet. Text reflows when the real face
|
|
153
|
+
replaces the fallback — every line moves. A missing image collapses a card and
|
|
154
|
+
everything below it slides up. Neither is a bug in the app, and neither is worth
|
|
155
|
+
waking a human for.
|
|
156
|
+
|
|
157
|
+
**What the tool does.** Waits for both before the shutter. `document.fonts.ready`
|
|
158
|
+
is treated as a starting gun rather than a finish line — it can settle while a
|
|
159
|
+
face requested a moment ago is still in flight — so the tool then polls
|
|
160
|
+
`document.fonts.status` until the browser itself agrees everything is loaded, up
|
|
161
|
+
to a bounded number of tries. Images are waited on the same way.
|
|
162
|
+
|
|
163
|
+
Every wait races a real `setTimeout` rather than a clock reading, because the
|
|
164
|
+
clock is frozen and `Date.now()` would never reach the deadline.
|
|
165
|
+
|
|
166
|
+
**What it cannot fix.** A font that genuinely is not available on the machine
|
|
167
|
+
taking the picture. If your CSS falls back to a system font, the picture is of
|
|
168
|
+
the fallback, and a different machine has a different fallback. Self-host your
|
|
169
|
+
fonts, or allow the font host through `networkAllow`.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 6. Layout that shifts after it looks finished
|
|
174
|
+
|
|
175
|
+
**What goes wrong.** A chart draws itself a beat late. A virtualised list
|
|
176
|
+
measures its rows and re-lays them out. A scrollbar decides it exists and takes
|
|
177
|
+
15 pixels of width away from everything. A late `ResizeObserver` fires. The page
|
|
178
|
+
looked ready and then moved.
|
|
179
|
+
|
|
180
|
+
**What the tool does.** This is what the settle loop is for, and it is the reason
|
|
181
|
+
picture checks can be trusted at all. Take the photo, take it again, and only
|
|
182
|
+
accept it once two photos in a row agree. Everything else in the freeze layer
|
|
183
|
+
removes a *reason* to change; settle is the net for the reasons nobody thought
|
|
184
|
+
of.
|
|
185
|
+
|
|
186
|
+
Before the first shot it waits for `load`, gives any surviving animations a
|
|
187
|
+
bounded grace period to end on their own, and waits two animation frames — the
|
|
188
|
+
first lets the browser run what was scheduled, the second only arrives once that
|
|
189
|
+
work has actually been painted. Then it shoots on an interval until `frames`
|
|
190
|
+
consecutive photos are identical, or `timeoutMs` runs out. Two identical frames
|
|
191
|
+
is the default; `maxDriftPixels` is 0, meaning identical means identical.
|
|
192
|
+
|
|
193
|
+
The comparison between two settle frames starts by comparing the compressed PNG
|
|
194
|
+
bytes, which costs nothing and is what happens almost every time. Only when they
|
|
195
|
+
differ does it pay to decode both.
|
|
196
|
+
|
|
197
|
+
If it times out it hands back the last photo anyway and records that it never
|
|
198
|
+
settled, rather than failing — a screen that will not hold still is worth seeing.
|
|
199
|
+
|
|
200
|
+
**What it cannot fix.** A page that never settles: a live-updating dashboard, a
|
|
201
|
+
running timer, a chat that polls. Mask the moving region, or do not check that
|
|
202
|
+
screen.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 7. Focus rings
|
|
207
|
+
|
|
208
|
+
**What goes wrong.** Whichever element happened to have focus when the last step
|
|
209
|
+
finished draws an outline. Which element that is depends on click timing, so it
|
|
210
|
+
changes between runs on identical code — and the approved picture has the ring on
|
|
211
|
+
a different element, or on none.
|
|
212
|
+
|
|
213
|
+
**What the tool does.** Blurs the active element immediately before the shutter,
|
|
214
|
+
unless it is `document.body`, then forces a layout flush and waits two frames so
|
|
215
|
+
the removal is actually painted rather than merely calculated. It also resets
|
|
216
|
+
scroll to the top unless the recipe scrolled somewhere on purpose, for the same
|
|
217
|
+
reason: a page left scrolled by a click-into-view photographs differently every
|
|
218
|
+
run.
|
|
219
|
+
|
|
220
|
+
**What it cannot fix.** A focus ring you actually want in the picture. If the
|
|
221
|
+
screen you are checking is "the search box is focused", the blur will undo it —
|
|
222
|
+
capture that state with a mask around the rest, or check it with a guard instead.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 8. Scrollbars and the text caret
|
|
227
|
+
|
|
228
|
+
**What goes wrong.** A scrollbar appears when content grows by one line, and
|
|
229
|
+
taking 15 pixels of width away reflows the entire page. Whether it is an overlay
|
|
230
|
+
scrollbar or a classic one is an operating-system setting, so the same code
|
|
231
|
+
photographs differently on two machines. Meanwhile a text cursor blinks: half the
|
|
232
|
+
runs catch it on, half catch it off.
|
|
233
|
+
|
|
234
|
+
**What the tool does.** Hides both. Scrollbars go via CSS
|
|
235
|
+
(`::-webkit-scrollbar`, `scrollbar-width: none`) and via Chrome's
|
|
236
|
+
`--hide-scrollbars` flag; the caret goes via `caret-color: transparent`. Both are
|
|
237
|
+
on by default and both can be turned off in `freeze`.
|
|
238
|
+
|
|
239
|
+
**What it cannot fix.** A custom scrollbar your app draws itself out of divs.
|
|
240
|
+
That is content, and it is checked like content.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 9. GPU rasterisation
|
|
245
|
+
|
|
246
|
+
**What goes wrong.** The same page, rasterised through two different graphics
|
|
247
|
+
drivers, is not the same pixels. Gradients band differently. A rotated element's
|
|
248
|
+
edges land a fraction differently. A composited layer is rounded to a different
|
|
249
|
+
boundary. None of it is visible to a person and all of it is visible to a pixel
|
|
250
|
+
comparison.
|
|
251
|
+
|
|
252
|
+
**What the tool does.** The browser is launched with GPU rasterisation off
|
|
253
|
+
(`--disable-gpu`), runtime Skia optimisations off, partial raster off, composited
|
|
254
|
+
antialiasing off, and the colour profile forced to sRGB. Software rendering is
|
|
255
|
+
slower and it is the same everywhere, which is the trade this tool exists to
|
|
256
|
+
make. The device scale factor is forced rather than inherited from the display,
|
|
257
|
+
so plugging in an external monitor does not change your pictures.
|
|
258
|
+
|
|
259
|
+
**What it cannot fix.** Software rasterisation still differs a little between
|
|
260
|
+
Chrome versions. That is why the CI workflow pins Chrome's major version, and why
|
|
261
|
+
bumping it is a deliberate act followed by re-approving what moved.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 10. Operating-system text smoothing
|
|
266
|
+
|
|
267
|
+
**What goes wrong.** macOS, Windows and Linux each draw text differently, and
|
|
268
|
+
within one OS the answer changes with the display, with whether the window is on
|
|
269
|
+
an external monitor, and with the graphics driver version. Subpixel antialiasing
|
|
270
|
+
puts colour fringes on glyph edges. Hinting snaps stems to the pixel grid.
|
|
271
|
+
Different fake-bold synthesis appears when a weight is missing.
|
|
272
|
+
|
|
273
|
+
**What the tool does.** Pins all of it: `-webkit-font-smoothing: antialiased`,
|
|
274
|
+
`text-rendering: geometricPrecision` (which stops glyph advances being rounded to
|
|
275
|
+
whole pixels — that rounding is what makes a line of text reflow by one pixel
|
|
276
|
+
between runs), and `font-synthesis: none` (so a fake bold is never invented,
|
|
277
|
+
because inventing one is a per-machine decision). At the browser level, font
|
|
278
|
+
render hinting is off, LCD text is off, and subpixel positioning is off.
|
|
279
|
+
|
|
280
|
+
This trades a little fidelity for pictures that do not change when the operating
|
|
281
|
+
system changes its mind. It is worth it.
|
|
282
|
+
|
|
283
|
+
**What it cannot fix — and this is the honest limit of the whole tool.** A
|
|
284
|
+
picture is tied to the operating system that took it. A macOS-approved picture
|
|
285
|
+
will not match on Linux, no matter how many flags are set: the font stack is
|
|
286
|
+
different, the fallback faces are different, and the text rasteriser is a
|
|
287
|
+
different piece of code. Approved pictures are stamped with the platform that
|
|
288
|
+
took them (`darwin-arm64`, `linux-x64`) and comparing across platforms warns you.
|
|
289
|
+
|
|
290
|
+
There are two honest ways to live with this:
|
|
291
|
+
|
|
292
|
+
1. **Take the pictures in one place.** Approve on CI, or approve on one machine
|
|
293
|
+
everyone shares. This is the simpler answer and it is the one most projects
|
|
294
|
+
should pick.
|
|
295
|
+
2. **Approve per platform.** Keep a separate approved folder per platform by
|
|
296
|
+
setting `dir` from an environment variable, and approve on each. More work,
|
|
297
|
+
but it lets everybody run `check` locally.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## The last line of defence: the flake register
|
|
302
|
+
|
|
303
|
+
Even with all of the above, something will eventually wobble. So the tool keeps
|
|
304
|
+
score. Every run appends a status per check. When a check changes its mind while
|
|
305
|
+
the git sha and the working tree stood still, that is recorded as a flake — that
|
|
306
|
+
is the only honest definition, because anything looser blames you for your own
|
|
307
|
+
edits. A check that only passes on the retry inside a single run counts too.
|
|
308
|
+
|
|
309
|
+
Past `flakeLimit` (2 by default) the check is **condemned**, and `check` says so
|
|
310
|
+
in red until a person deals with it. There is no option to tolerate it. Fix it or
|
|
311
|
+
delete it — a check nobody believes is worse than no check, and the whole tool is
|
|
312
|
+
built on being believed.
|
|
313
|
+
|
|
314
|
+
`staysfixed flake` shows the register. `staysfixed flake --clear <name>` forgives
|
|
315
|
+
a check once it has genuinely been fixed.
|