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