staysfixed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A guard, written the way guards are meant to be written.
3
+ *
4
+ * A guard is one check per bug that has already been fixed once. Its only job is
5
+ * to fail on the day that bug comes back. It is not a unit test and it is not a
6
+ * spec — nobody adds a guard for behaviour that has never broken.
7
+ *
8
+ * Drop files like this one into `.staysfixed/guards/`. They are plain JavaScript
9
+ * modules; the default export is the guard. A file may also export an array of
10
+ * guards, or several named ones.
11
+ */
12
+
13
+ /** @type {import('../../src/types.js').Guard} */
14
+ const guard = {
15
+ // The name is the whole handover. It is what prints when the guard fails, what
16
+ // goes in the report, and what an agent reads before deciding whether it broke
17
+ // something. Write what the app is supposed to do, in the words you would say
18
+ // out loud. `sidebar_collapse_test` would be refused, and rightly.
19
+ name: 'the sidebar still collapses',
20
+
21
+ // When it was fixed. Free text — a date, a version, whatever you would say.
22
+ fixed: '2026-08-14',
23
+
24
+ // The story of the bug, in one or two sentences. This is printed underneath
25
+ // the failure, so the person who has never seen this bug knows in five seconds
26
+ // what they are looking at and whether it matters.
27
+ because:
28
+ 'A CSS refactor renamed .sidebar--open to .sidebar-open everywhere except the toggle handler, ' +
29
+ 'so the collapse button did nothing and the sidebar was stuck open on every screen under 900px wide. ' +
30
+ 'It shipped and nobody noticed for four days.',
31
+
32
+ // Where to read more. An issue, a commit, a session note — anything.
33
+ link: 'https://github.com/asadev/staysfixed/issues/12',
34
+
35
+ // How long this guard gets before it is called failed. Default 30000.
36
+ timeoutMs: 20_000,
37
+
38
+ // Set `skip: true` to park a guard without deleting it. `check` reports it as
39
+ // left out on purpose, so nobody mistakes it for passing.
40
+ // skip: true,
41
+
42
+ /**
43
+ * Everything the guard needs is on `app`: the full page, shorthands for the
44
+ * two things guards do most, `expect` for assertions, `run` for a shell
45
+ * command, `read` for a project file.
46
+ *
47
+ * @param {import('../../src/types.js').GuardApi} app
48
+ */
49
+ async run(app) {
50
+ const { page, expect } = app;
51
+
52
+ await app.open('/');
53
+ await page.waitFor('.sidebar');
54
+
55
+ // Assertions are a sentence plus a check, never a bare comparison. When this
56
+ // fails the person reading the terminal sees
57
+ // expected: the sidebar starts open
58
+ // which anyone can act on, six months from now, at one in the morning.
59
+ await expect('the sidebar starts open', () => page.visible('.sidebar'));
60
+
61
+ await app.click('[data-action="toggle-sidebar"]');
62
+ await page.waitForGone('.sidebar--open');
63
+
64
+ await expect('clicking the toggle hides the sidebar', async () => !(await page.visible('.sidebar')));
65
+
66
+ // The bug was that the class name and the handler disagreed. Check the thing
67
+ // that actually broke, not only the thing you can see — a future refactor
68
+ // could hide the sidebar a different way and this guard should still hold.
69
+ await expect('the collapsed sidebar keeps its collapsed class', async () => {
70
+ const classes = await page.evaluate('document.querySelector(".sidebar").className');
71
+ return String(classes).includes('sidebar--collapsed');
72
+ });
73
+
74
+ // And that it comes back, because a collapse you cannot undo is a worse bug
75
+ // than the one we were fixing.
76
+ await app.click('[data-action="toggle-sidebar"]');
77
+ await page.waitFor('.sidebar--open');
78
+ await expect('clicking the toggle again brings the sidebar back', () => page.visible('.sidebar'));
79
+
80
+ // Guards do not have to be about the screen at all. This one is, but a guard
81
+ // can equally run a command or read a file:
82
+ //
83
+ // const { code } = await app.run('npm run build');
84
+ // await expect('the production build still succeeds', () => code === 0);
85
+ //
86
+ // const css = await app.read('src/styles/sidebar.css');
87
+ // await expect('the collapsed class is still defined', () => css.includes('.sidebar--collapsed'));
88
+ },
89
+ };
90
+
91
+ export default guard;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * An Electron desktop app, checked end to end.
3
+ *
4
+ * The difference from a web app is small on purpose: instead of an address, you
5
+ * give it the executable to launch. Everything after that — screens, freezing,
6
+ * masks, guards — works exactly the same way, because an Electron window is a
7
+ * Chromium window with a different frame around it.
8
+ */
9
+
10
+ import path from 'node:path';
11
+
12
+ /**
13
+ * Point this at the app you actually ship, not at `electron .`. A packaged build
14
+ * loads the real bundle, the real stylesheets and the real assets; running from
15
+ * source loads whatever the dev server felt like serving that second.
16
+ *
17
+ * On macOS the executable is inside the bundle:
18
+ * /Applications/Your App.app/Contents/MacOS/Your App
19
+ * On Linux it is the AppImage or the binary in the unpacked folder.
20
+ * On Windows it is the .exe. (Windows is untested — see the README.)
21
+ */
22
+ const APP_BINARY =
23
+ process.platform === 'darwin'
24
+ ? path.resolve('dist/mac-arm64/Your App.app/Contents/MacOS/Your App')
25
+ : path.resolve('dist/linux-unpacked/your-app');
26
+
27
+ /** @type {import('../src/types.js').StaysFixedConfig} */
28
+ const config = {
29
+ app: {
30
+ kind: 'electron',
31
+ binary: APP_BINARY,
32
+
33
+ // Extra argv the app is launched with. A flag that puts the app into a
34
+ // known state is worth more than any amount of tolerance tuning.
35
+ args: ['--staysfixed', '--skip-onboarding'],
36
+
37
+ // Working directory for the launched process.
38
+ cwd: '.',
39
+
40
+ // A separate data directory is not optional. Without it the run opens YOUR
41
+ // installed copy's settings, your real sessions and your real window size —
42
+ // and then writes to them. Point the app at a throwaway folder and every run
43
+ // starts from the same place.
44
+ env: {
45
+ YOUR_APP_DATA_DIR: '.staysfixed/tmp/app-data',
46
+ YOUR_APP_TELEMETRY: 'off',
47
+ },
48
+
49
+ // An Electron app usually opens more than one window: a splash, a hidden
50
+ // background window, sometimes a devtools window. This picks the one whose
51
+ // title or url contains this text, so the tool never photographs the splash.
52
+ windowMatch: 'Your App',
53
+
54
+ // How long to wait for the window to appear.
55
+ startTimeoutMs: 60_000,
56
+
57
+ // Leave `debugPort` out and a free port is chosen. Set it if your app opens
58
+ // its remote debugging port itself and you have already fixed the number.
59
+ // debugPort: 9333,
60
+
61
+ // Or attach to an app that is already running with a debugging port open,
62
+ // instead of launching one. Nothing you attach to is ever closed by the tool.
63
+ // attach: 'http://127.0.0.1:9333',
64
+ },
65
+
66
+ // A desktop window is usually smaller than a browser viewport. Pick a size
67
+ // once — changing it later means re-approving every picture.
68
+ viewport: {
69
+ width: 1280,
70
+ height: 800,
71
+ deviceScaleFactor: 2,
72
+ },
73
+
74
+ freeze: {
75
+ clock: '2026-01-01T12:00:00.000Z',
76
+ timezone: 'UTC',
77
+ locale: 'en-US',
78
+ motion: true,
79
+ random: 'seeded',
80
+ fonts: true,
81
+
82
+ // Desktop apps talk to their own backend constantly — updates, licence
83
+ // checks, sync. 'replay' records each reply once and then serves the same
84
+ // bytes forever, which is usually the only way a desktop app renders the
85
+ // same screen twice. The recordings live in .staysfixed/fixtures and belong
86
+ // in git.
87
+ network: 'replay',
88
+ networkAllow: ['file://**', 'app://**'],
89
+ },
90
+
91
+ tolerance: {
92
+ pixels: 0.0005,
93
+ threshold: 0.12,
94
+ antialiasing: true,
95
+ },
96
+
97
+ // Window chrome, an update banner and a clock in the status bar are all
98
+ // allowed to change. The layout underneath is not.
99
+ masks: ['.titlebar-buttons', '#update-banner', '[data-clock]'],
100
+
101
+ screens: [
102
+ {
103
+ name: 'welcome',
104
+ describe: 'The first window a new user sees',
105
+ steps: [{ waitFor: '[data-view="welcome"]' }],
106
+ },
107
+ {
108
+ name: 'workspace-empty',
109
+ describe: 'An open workspace with nothing in it yet',
110
+ steps: [
111
+ { click: '[data-action="new-workspace"]' },
112
+ { type: 'input[name="workspace-name"]', text: 'Demo' },
113
+ { press: 'Enter' },
114
+ { waitFor: '[data-view="workspace"]' },
115
+ { waitForGone: '.spinner' },
116
+ ],
117
+ },
118
+ {
119
+ name: 'settings-appearance',
120
+ describe: 'Settings, on the Appearance tab',
121
+ async do(page) {
122
+ await page.press('Escape');
123
+ await page.click('[data-action="open-settings"]');
124
+ await page.click('[data-tab="appearance"]');
125
+ await page.waitFor('[data-settings-pane="appearance"]');
126
+ // Scroll the pane so the theme picker is on screen, then let the list
127
+ // finish settling before the shutter.
128
+ await page.scrollTo('[data-setting="theme"]');
129
+ },
130
+ masks: ['[data-setting="version"]'],
131
+ },
132
+ {
133
+ name: 'sidebar-collapsed',
134
+ describe: 'The workspace with the sidebar collapsed',
135
+ steps: [
136
+ { click: '[data-action="toggle-sidebar"]' },
137
+ { waitForGone: '.sidebar--open' },
138
+ ],
139
+ },
140
+ ],
141
+
142
+ guards: '.staysfixed/guards',
143
+
144
+ walk: {
145
+ describe: 'What a reviewer clicks through before a release goes out',
146
+ steps: [
147
+ { name: 'walk-welcome', describe: 'Welcome window', steps: [{ waitFor: '[data-view="welcome"]' }] },
148
+ {
149
+ name: 'walk-workspace',
150
+ describe: 'A workspace, opened',
151
+ steps: [{ click: '[data-action="new-workspace"]' }, { press: 'Enter' }, { waitFor: '[data-view="workspace"]' }],
152
+ },
153
+ {
154
+ name: 'walk-settings',
155
+ describe: 'Settings',
156
+ steps: [{ click: '[data-action="open-settings"]' }, { waitFor: '[data-view="settings"]' }],
157
+ },
158
+ ],
159
+ },
160
+
161
+ mcp: {
162
+ allowApprove: false,
163
+ allowMark: false,
164
+ },
165
+
166
+ dir: '.staysfixed',
167
+ flakeLimit: 2,
168
+ retries: 1,
169
+ concurrency: 1,
170
+ };
171
+
172
+ export default config;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * A web app, checked end to end.
3
+ *
4
+ * Copy this to the root of your project as `staysfixed.config.js`, point it at
5
+ * your dev server, and delete the parts you do not need. Every option here is
6
+ * optional except `app` and `screens` — the defaults are chosen so that a config
7
+ * of five lines already works.
8
+ */
9
+
10
+ /** @type {import('../src/types.js').StaysFixedConfig} */
11
+ const config = {
12
+ // ---------------------------------------------------------------------------
13
+ // What to open
14
+ // ---------------------------------------------------------------------------
15
+ app: {
16
+ kind: 'web',
17
+
18
+ // The address the app answers on. Relative screen urls hang off this, so
19
+ // `url: '/settings'` on a screen below means http://localhost:3000/settings.
20
+ url: 'http://localhost:3000',
21
+
22
+ // Optional: the command that starts the app. Leave it out if you start the
23
+ // server yourself — Stays Fixed simply waits for `url` to answer either way.
24
+ // Use a production-like build, not a hot-reloading dev server: a dev overlay
25
+ // that pops up for half a second is a picture that disagrees with itself.
26
+ start: 'npm run preview',
27
+ cwd: '.',
28
+
29
+ // Extra environment for the command above. Handy for pointing the app at a
30
+ // seeded database, which is the single biggest thing you can do to make
31
+ // pictures repeatable.
32
+ env: {
33
+ NODE_ENV: 'production',
34
+ DATABASE_URL: 'postgres://localhost:5432/myapp_pictures',
35
+ },
36
+
37
+ // How long to wait for the app to answer before giving up.
38
+ startTimeoutMs: 60_000,
39
+
40
+ // Leave `browser` out and the tool finds Chrome, Chromium, Edge or Brave on
41
+ // this machine. Set it to pin one exact binary, which is what you want when
42
+ // more than one person approves pictures.
43
+ // browser: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
44
+
45
+ // Headless is the default. Turn it off while you are writing a screen recipe
46
+ // and want to watch the browser do it.
47
+ headless: true,
48
+ },
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // How big the window is
52
+ //
53
+ // Change this and every approved picture stops matching, so pick a size once.
54
+ // deviceScaleFactor 2 gives retina-sharp pictures; it is still deterministic.
55
+ // ---------------------------------------------------------------------------
56
+ viewport: {
57
+ width: 1440,
58
+ height: 900,
59
+ deviceScaleFactor: 2,
60
+ mobile: false,
61
+ },
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Holding the app still
65
+ //
66
+ // These are the defaults spelled out. You can delete this whole block.
67
+ // docs/how-it-stays-stable.md explains what each one is protecting you from.
68
+ // ---------------------------------------------------------------------------
69
+ freeze: {
70
+ // The instant the app always believes it is. Timers still fire; only the
71
+ // reading of the clock is pinned. Set to false to leave time alone.
72
+ clock: '2026-01-01T12:00:00.000Z',
73
+ timezone: 'UTC',
74
+ locale: 'en-US',
75
+
76
+ // Stop animations, transitions, video and the blinking text cursor.
77
+ motion: true,
78
+
79
+ // Seed Math.random, crypto.getRandomValues and crypto.randomUUID, so a
80
+ // shuffled list is shuffled the same way every time.
81
+ random: 'seeded',
82
+ seed: 20260101,
83
+
84
+ // Wait for web fonts and images to land, and pin how text is rasterised.
85
+ fonts: true,
86
+
87
+ // 'block-external' lets the app's own origin and localhost through and
88
+ // blocks everybody else's servers — the usual choice.
89
+ // 'replay' records every reply once into .staysfixed/fixtures and then
90
+ // serves those same bytes forever, which is what you want when the app
91
+ // cannot render at all without its API.
92
+ // 'live' lets everything through, and your pictures then depend on the
93
+ // internet.
94
+ network: 'block-external',
95
+
96
+ // Globs that get out even in 'block-external'. The `*` stops at a path
97
+ // separator; `**` crosses them.
98
+ networkAllow: ['https://fonts.gstatic.com/**'],
99
+
100
+ hideScrollbars: true,
101
+ hideCaret: true,
102
+
103
+ // Take the photo, take it again, and only accept it once two in a row
104
+ // agree. This is the safety net for anything the rest of the list missed.
105
+ settle: {
106
+ frames: 2,
107
+ intervalMs: 250,
108
+ timeoutMs: 10_000,
109
+ maxDriftPixels: 0,
110
+ },
111
+ },
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // How much difference is allowed
115
+ //
116
+ // 0.0005 is 0.05% of the pixels — on a 1440x900 at 2x picture that is about
117
+ // 1,300 pixels. Enough to absorb font hinting noise, nowhere near enough to
118
+ // hide a missing stylesheet or a shifted column.
119
+ // ---------------------------------------------------------------------------
120
+ tolerance: {
121
+ pixels: 0.0005,
122
+ threshold: 0.12,
123
+ antialiasing: true,
124
+ // maxPixels: 500, // a hard cap, overrides `pixels` when set
125
+ },
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Things that are allowed to change
129
+ //
130
+ // A CSS selector paints over every element that matches; a rectangle paints
131
+ // over an exact area in CSS pixels. Mask a live clock, a session id, a
132
+ // "3 minutes ago" — never mask something just because it keeps failing.
133
+ // ---------------------------------------------------------------------------
134
+ masks: ['[data-live-clock]', '.session-id', { x: 0, y: 0, width: 240, height: 32 }],
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // The screens
138
+ // ---------------------------------------------------------------------------
139
+ screens: [
140
+ // The simplest possible screen: a name and an address.
141
+ {
142
+ name: 'home',
143
+ describe: 'The landing page, signed out',
144
+ url: '/',
145
+ },
146
+
147
+ // Declarative steps. This form also works in staysfixed.config.json, so a
148
+ // project in any language can use it without writing JavaScript.
149
+ {
150
+ name: 'signed-in-dashboard',
151
+ describe: 'The dashboard after a normal sign-in',
152
+ steps: [
153
+ { goto: '/login' },
154
+ { type: 'input[name="email"]', text: 'demo@example.com' },
155
+ { type: 'input[name="password"]', text: 'demo-password' },
156
+ { click: 'button[type="submit"]' },
157
+ { waitFor: '[data-testid="dashboard"]' },
158
+ { note: 'The greeting shows the account name, which is seeded data.' },
159
+ ],
160
+ masks: ['[data-testid="last-seen"]'],
161
+ },
162
+
163
+ // Or write it as code, when the steps need a decision.
164
+ {
165
+ name: 'settings-notifications',
166
+ describe: 'Notification settings with the daily digest switched on',
167
+ async do(page) {
168
+ await page.goto('/settings');
169
+ await page.click('[data-tab="notifications"]');
170
+ await page.waitFor('#daily-digest');
171
+ const alreadyOn = await page.evaluate(
172
+ 'document.querySelector("#daily-digest").checked === true',
173
+ );
174
+ if (!alreadyOn) await page.click('#daily-digest');
175
+ await page.waitFor('[data-saved="true"]');
176
+ },
177
+ },
178
+
179
+ // Capture one element instead of the whole window.
180
+ {
181
+ name: 'pricing-card-pro',
182
+ describe: 'The Pro pricing card on its own',
183
+ url: '/pricing',
184
+ clip: '[data-plan="pro"]',
185
+ },
186
+
187
+ // A long page, photographed all the way down. Full-page shots are more
188
+ // fragile than viewport shots: anything lazy-loading below the fold has to
189
+ // finish first, so give it a longer settle if it wobbles.
190
+ {
191
+ name: 'changelog-full',
192
+ describe: 'The whole changelog page, top to bottom',
193
+ url: '/changelog',
194
+ fullPage: true,
195
+ freeze: {
196
+ settle: { timeoutMs: 20_000 },
197
+ },
198
+ },
199
+
200
+ // A screen with its own size and its own tolerance.
201
+ {
202
+ name: 'home-narrow',
203
+ describe: 'The landing page at a narrow width, where the nav collapses',
204
+ url: '/',
205
+ viewport: { width: 720, height: 900 },
206
+ tolerance: { pixels: 0.001 },
207
+ },
208
+
209
+ // Leave a screen out for now without deleting it. `check` says it was left
210
+ // out on purpose, so nobody thinks it is passing.
211
+ {
212
+ name: 'billing-empty',
213
+ describe: 'Billing with no invoices yet',
214
+ url: '/billing',
215
+ skip: true,
216
+ },
217
+ ],
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Guards — one check per bug that was already fixed once.
221
+ //
222
+ // A folder of plain JavaScript files. See examples/guards/ and docs/guards.md.
223
+ // ---------------------------------------------------------------------------
224
+ guards: '.staysfixed/guards',
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // The pre-release walk. Leave `steps` out and it walks every screen above.
228
+ // ---------------------------------------------------------------------------
229
+ walk: {
230
+ describe: 'The path a new customer takes, opened for real before a release',
231
+ steps: [
232
+ { name: 'walk-home', describe: 'Landing page', url: '/' },
233
+ { name: 'walk-pricing', describe: 'Pricing', url: '/pricing' },
234
+ {
235
+ name: 'walk-signup',
236
+ describe: 'Sign-up form, filled in but not submitted',
237
+ steps: [
238
+ { goto: '/signup' },
239
+ { type: 'input[name="email"]', text: 'demo@example.com' },
240
+ ],
241
+ },
242
+ ],
243
+ },
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // What an AI agent is allowed to do through the MCP server.
247
+ //
248
+ // Both default to false and both should stay false. An agent that can approve
249
+ // its own pictures has no safety net at all: it edits the code, notices the
250
+ // picture moved, blesses the new picture, and reports success.
251
+ // ---------------------------------------------------------------------------
252
+ mcp: {
253
+ allowApprove: false,
254
+ allowMark: false,
255
+ },
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Housekeeping
259
+ // ---------------------------------------------------------------------------
260
+
261
+ // Where approved pictures, guards, markers and results live.
262
+ dir: '.staysfixed',
263
+
264
+ // How many times a check may change its mind before it is condemned and the
265
+ // tool starts saying so in red. Fix it or delete it — never tolerate it.
266
+ flakeLimit: 2,
267
+
268
+ // Re-photograph a failing screen this many times before calling it a real
269
+ // change. A screen that only passes on the retry is recorded as a flake.
270
+ retries: 1,
271
+
272
+ // Screens photographed at once. One, on purpose: two browsers competing for
273
+ // the same machine is exactly how pictures start disagreeing with themselves.
274
+ concurrency: 1,
275
+ };
276
+
277
+ export default config;
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "staysfixed",
3
+ "version": "0.1.0",
4
+ "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Asad Iqbal",
8
+ "homepage": "https://github.com/asadev/staysfixed",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/asadev/staysfixed.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/asadev/staysfixed/issues"
15
+ },
16
+ "keywords": [
17
+ "regression",
18
+ "visual-regression",
19
+ "screenshot-testing",
20
+ "snapshot",
21
+ "mcp",
22
+ "mcp-server",
23
+ "ai-agents",
24
+ "electron",
25
+ "testing"
26
+ ],
27
+ "bin": {
28
+ "staysfixed": "bin/staysfixed.js"
29
+ },
30
+ "exports": {
31
+ ".": "./src/index.js",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "examples",
38
+ "README.md",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc -p tsconfig.json",
47
+ "test": "node --test --test-concurrency=1 \"test/*.test.js\"",
48
+ "test:determinism": "node --test test/determinism.test.js",
49
+ "check": "npm run typecheck && npm test",
50
+ "staysfixed": "node bin/staysfixed.js"
51
+ },
52
+ "dependencies": {
53
+ "pixelmatch": "^7.2.0",
54
+ "pngjs": "^7.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "@types/pngjs": "^6.0.5",
59
+ "typescript": "^5.9.0"
60
+ }
61
+ }