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,187 @@
1
+ /**
2
+ * Covering up the parts that are allowed to change.
3
+ *
4
+ * Some things on a screen are genuinely different every run and always will be: a live
5
+ * clock, a session id, a "3 minutes ago", a randomly-picked hero image. Masking them is
6
+ * how a picture check stays honest — the alternative is a tolerance so loose it would
7
+ * also hide a broken layout.
8
+ *
9
+ * Two ways to do it, and the difference matters:
10
+ * paintMasks - paints over the finished photo. The layout is real, only the pixels go.
11
+ * maskCss - hides the content in the page before the photo. Use it only when the
12
+ * changing content also changes the LAYOUT, because hiding content can
13
+ * move everything around it, and then the mask is not the only difference.
14
+ */
15
+
16
+ /**
17
+ * Turn selectors and rectangles into rectangles in device pixels.
18
+ *
19
+ * A selector that matches nothing is silently skipped: masking "the toast" on a screen
20
+ * with no toast is normal, not a mistake worth failing a run over.
21
+ *
22
+ * @param {import('../types.js').PageHandle} page
23
+ * @param {import('../types.js').Mask[]} masks
24
+ * @param {{deviceScaleFactor?: number, fullPage?: boolean}} [opts]
25
+ * @returns {Promise<import('../types.js').MaskRect[]>}
26
+ */
27
+ export async function resolveMasks(page, masks, opts = {}) {
28
+ const dpr = opts.deviceScaleFactor && opts.deviceScaleFactor > 0 ? opts.deviceScaleFactor : 1;
29
+ const fullPage = opts.fullPage === true;
30
+
31
+ /** @type {string[]} */
32
+ const selectors = [];
33
+ /** @type {import('../types.js').MaskRect[]} */
34
+ const out = [];
35
+
36
+ for (const mask of masks ?? []) {
37
+ if (typeof mask === 'string') {
38
+ if (mask.trim()) selectors.push(mask);
39
+ } else if (mask && typeof mask === 'object') {
40
+ out.push(toDevicePixels(mask, dpr));
41
+ }
42
+ }
43
+
44
+ if (selectors.length === 0) return out;
45
+
46
+ // getBoundingClientRect is relative to the viewport, which is exactly what a viewport
47
+ // screenshot uses. A full-page shot is relative to the document, so the scroll offset
48
+ // has to go back in.
49
+ const source = `(() => {
50
+ const selectors = ${JSON.stringify(selectors)};
51
+ const addScroll = ${fullPage ? 'true' : 'false'};
52
+ const out = [];
53
+ for (const selector of selectors) {
54
+ let nodes = [];
55
+ try { nodes = Array.prototype.slice.call(document.querySelectorAll(selector)); }
56
+ catch (e) { continue; }
57
+ for (const el of nodes) {
58
+ let r = null;
59
+ try { r = el.getBoundingClientRect(); } catch (e) { continue; }
60
+ if (!r || (r.width <= 0 && r.height <= 0)) continue;
61
+ out.push({
62
+ x: r.left + (addScroll ? (window.scrollX || 0) : 0),
63
+ y: r.top + (addScroll ? (window.scrollY || 0) : 0),
64
+ width: r.width,
65
+ height: r.height
66
+ });
67
+ }
68
+ }
69
+ return out;
70
+ })()`;
71
+
72
+ /** @type {any} */
73
+ let found = [];
74
+ try {
75
+ found = await page.evaluate(source);
76
+ } catch {
77
+ // A page that navigated mid-resolve. No masks is better than a failed run; the
78
+ // comparison will show the moving content, which is at least honest.
79
+ return out;
80
+ }
81
+
82
+ if (Array.isArray(found)) {
83
+ for (const r of found) {
84
+ if (!r || typeof r.x !== 'number') continue;
85
+ out.push(toDevicePixels(r, dpr));
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Scale a CSS-pixel rectangle into device pixels, rounding outward so a mask never leaves
93
+ * a one-pixel sliver of the thing it was meant to cover.
94
+ *
95
+ * @param {import('../types.js').MaskRect} rect
96
+ * @param {number} dpr
97
+ * @returns {import('../types.js').MaskRect}
98
+ */
99
+ function toDevicePixels(rect, dpr) {
100
+ const left = Math.max(0, Math.floor(Number(rect.x) * dpr));
101
+ const top = Math.max(0, Math.floor(Number(rect.y) * dpr));
102
+ const right = Math.max(left, Math.ceil((Number(rect.x) + Number(rect.width)) * dpr));
103
+ const bottom = Math.max(top, Math.ceil((Number(rect.y) + Number(rect.height)) * dpr));
104
+ return { x: left, y: top, width: right - left, height: bottom - top };
105
+ }
106
+
107
+ /**
108
+ * Paint rectangles into a decoded picture, in place.
109
+ *
110
+ * Magenta on purpose. If a mask ever drifts off the thing it was covering, a human sees a
111
+ * screaming pink block sitting in the wrong place instantly — which is the whole point of
112
+ * a tool whose job is to make changes impossible to miss.
113
+ *
114
+ * @param {import('pngjs').PNG} png
115
+ * @param {import('../types.js').MaskRect[]} rects
116
+ * @param {{color?: {r: number, g: number, b: number}}} [opts]
117
+ * @returns {import('pngjs').PNG} the same picture, painted
118
+ */
119
+ export function paintMasks(png, rects, opts = {}) {
120
+ const color = opts.color ?? { r: 255, g: 0, b: 255 };
121
+ const width = png.width;
122
+ const height = png.height;
123
+ const data = png.data;
124
+
125
+ for (const rect of rects ?? []) {
126
+ const x0 = clamp(Math.round(rect.x), 0, width);
127
+ const y0 = clamp(Math.round(rect.y), 0, height);
128
+ const x1 = clamp(Math.round(rect.x + rect.width), 0, width);
129
+ const y1 = clamp(Math.round(rect.y + rect.height), 0, height);
130
+ if (x1 <= x0 || y1 <= y0) continue;
131
+
132
+ for (let y = y0; y < y1; y += 1) {
133
+ let i = (width * y + x0) * 4;
134
+ for (let x = x0; x < x1; x += 1) {
135
+ data[i] = color.r;
136
+ data[i + 1] = color.g;
137
+ data[i + 2] = color.b;
138
+ data[i + 3] = 255;
139
+ i += 4;
140
+ }
141
+ }
142
+ }
143
+ return png;
144
+ }
145
+
146
+ /**
147
+ * @param {number} n
148
+ * @param {number} low
149
+ * @param {number} high
150
+ * @returns {number}
151
+ */
152
+ function clamp(n, low, high) {
153
+ if (!Number.isFinite(n)) return low;
154
+ return Math.min(high, Math.max(low, Math.round(n)));
155
+ }
156
+
157
+ /**
158
+ * CSS that flattens the named elements before the photo is taken.
159
+ *
160
+ * Painting afterwards is the better tool almost always, because the layout in the picture
161
+ * stays exactly what the app really did. Reach for this only when the changing content
162
+ * changes the size of things around it — a name that is sometimes short and sometimes
163
+ * long, a count that grows a column. Keeping the box and blanking what is inside it means
164
+ * the rest of the screen stops moving.
165
+ *
166
+ * @param {string[]} selectors
167
+ * @returns {Promise<string>} CSS
168
+ */
169
+ export async function maskCss(selectors) {
170
+ const list = (selectors ?? []).filter((s) => typeof s === 'string' && s.trim());
171
+ if (list.length === 0) return '';
172
+ const joined = list.join(', ');
173
+ const children = list.map((s) => `${s} *`).join(', ');
174
+ return `${joined} {
175
+ background-color: #ff00ff !important;
176
+ background-image: none !important;
177
+ color: transparent !important;
178
+ text-shadow: none !important;
179
+ border-color: #ff00ff !important;
180
+ box-shadow: none !important;
181
+ }
182
+
183
+ ${children} {
184
+ visibility: hidden !important;
185
+ }
186
+ `;
187
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Killing movement.
3
+ *
4
+ * Anything that moves is a picture that disagrees with itself. Three layers, because
5
+ * each one catches what the others miss:
6
+ *
7
+ * - CSS kills declared animations and transitions, including ones that have not started.
8
+ * - Page script kills what CSS cannot: running Web Animations, playing video, and
9
+ * element.animate() calls the app makes at runtime.
10
+ * - The protocol tells the page it is on a machine set to "reduce motion", which is the
11
+ * only thing that stops well-behaved apps starting an animation in the first place.
12
+ *
13
+ * What none of this stops is an animated GIF or an autoplaying canvas draw loop that
14
+ * ignores requestAnimationFrame. Mask those.
15
+ */
16
+
17
+ /**
18
+ * @param {{hideScrollbars?: boolean, hideCaret?: boolean}} [opts]
19
+ * @returns {string} CSS
20
+ */
21
+ export function motionCss(opts = {}) {
22
+ const hideScrollbars = opts.hideScrollbars !== false;
23
+ const hideCaret = opts.hideCaret !== false;
24
+
25
+ const parts = [
26
+ // animation-duration 0 alone is not enough: a delayed animation still fires later,
27
+ // and an infinite one still holds a compositor layer. Say all of it.
28
+ `*, *::before, *::after {
29
+ animation: none !important;
30
+ animation-duration: 0s !important;
31
+ animation-delay: 0s !important;
32
+ animation-iteration-count: 1 !important;
33
+ animation-play-state: paused !important;
34
+ transition: none !important;
35
+ transition-duration: 0s !important;
36
+ transition-delay: 0s !important;
37
+ }`,
38
+ // will-change promotes an element to its own compositor layer, and a promoted layer
39
+ // is rasterised on slightly different pixel boundaries. Take the promotion away and
40
+ // the same element lands on the same pixels every run.
41
+ `*, *::before, *::after {
42
+ will-change: auto !important;
43
+ }`,
44
+ `html {
45
+ scroll-behavior: auto !important;
46
+ }`,
47
+ `video, marquee {
48
+ animation-play-state: paused !important;
49
+ }`,
50
+ ];
51
+
52
+ if (hideCaret) {
53
+ // A text cursor blinks. Half the runs catch it on, half catch it off.
54
+ parts.push(`*, *::before, *::after {
55
+ caret-color: transparent !important;
56
+ }`);
57
+ }
58
+
59
+ if (hideScrollbars) {
60
+ // Scrollbars appear and disappear with content height and with the OS setting for
61
+ // overlay scrollbars. Hiding them costs a few pixels of width and buys a picture
62
+ // that does not depend on the machine that took it.
63
+ parts.push(`::-webkit-scrollbar {
64
+ display: none !important;
65
+ width: 0 !important;
66
+ height: 0 !important;
67
+ }`);
68
+ parts.push(`html, body {
69
+ scrollbar-width: none !important;
70
+ -ms-overflow-style: none !important;
71
+ }`);
72
+ }
73
+
74
+ return parts.join('\n\n') + '\n';
75
+ }
76
+
77
+ /**
78
+ * Page-side source that stops movement CSS cannot reach.
79
+ * @returns {string} JavaScript to evaluate in the page
80
+ */
81
+ export function motionScript() {
82
+ return `(function () {
83
+ if (window.__staysfixed_motion) return;
84
+ window.__staysfixed_motion = true;
85
+
86
+ // A finished-looking stub. Libraries await animation.finished before showing the next
87
+ // thing; if we simply removed animate() those apps would hang half-rendered forever.
88
+ function stubAnimation() {
89
+ var done = Promise.resolve();
90
+ return {
91
+ id: '',
92
+ effect: null,
93
+ playState: 'finished',
94
+ playbackRate: 1,
95
+ currentTime: 0,
96
+ startTime: 0,
97
+ finished: done,
98
+ ready: done,
99
+ onfinish: null,
100
+ oncancel: null,
101
+ play: function () {},
102
+ pause: function () {},
103
+ cancel: function () {},
104
+ finish: function () {},
105
+ reverse: function () {},
106
+ persist: function () {},
107
+ commitStyles: function () {},
108
+ updatePlaybackRate: function () {},
109
+ addEventListener: function () {},
110
+ removeEventListener: function () {},
111
+ dispatchEvent: function () { return true; }
112
+ };
113
+ }
114
+
115
+ try {
116
+ if (window.Element && Element.prototype && Element.prototype.animate) {
117
+ Element.prototype.animate = function () { return stubAnimation(); };
118
+ }
119
+ } catch (e) {}
120
+
121
+ function quiet(root) {
122
+ try {
123
+ var vids = root && root.querySelectorAll ? root.querySelectorAll('video') : [];
124
+ for (var i = 0; i < vids.length; i++) {
125
+ try {
126
+ vids[i].autoplay = false;
127
+ vids[i].pause();
128
+ // Seek to the first frame so the poster-or-frame-0 question has one answer.
129
+ if (vids[i].currentTime !== 0) vids[i].currentTime = 0;
130
+ } catch (e) {}
131
+ }
132
+ } catch (e) {}
133
+ try {
134
+ if (typeof document.getAnimations === 'function') {
135
+ var anims = document.getAnimations();
136
+ for (var j = 0; j < anims.length; j++) {
137
+ var a = anims[j];
138
+ var forever = false;
139
+ try {
140
+ var t = a.effect && a.effect.getTiming ? a.effect.getTiming() : null;
141
+ forever = Boolean(t && t.iterations === Infinity);
142
+ } catch (e) {}
143
+ if (forever) {
144
+ // A spinner has no end state, so hold it at its first frame. Every run then
145
+ // photographs the same frame instead of whichever one the shutter caught.
146
+ try { a.pause(); a.currentTime = 0; } catch (e) { try { a.cancel(); } catch (e2) {} }
147
+ } else {
148
+ // finish(), NOT cancel(). This one cost a wrong picture of a real app.
149
+ //
150
+ // cancel() throws the animation away and puts the element back where it
151
+ // STARTED. So a sidebar that collapses with a 200ms slide was photographed
152
+ // still open — the click worked, the content moved, and the panel snapped
153
+ // back to its opening position the instant we cancelled. finish() jumps to
154
+ // the end state, which is what a person would see a moment later, and that
155
+ // is the picture worth keeping.
156
+ try { a.finish(); } catch (e) { try { a.cancel(); } catch (e2) {} }
157
+ }
158
+ }
159
+ }
160
+ } catch (e) {}
161
+ }
162
+
163
+ function sweep() { quiet(document); }
164
+ sweep();
165
+ try { document.addEventListener('DOMContentLoaded', sweep); } catch (e) {}
166
+ try { window.addEventListener('load', sweep); } catch (e) {}
167
+
168
+ // Anything rendered after load brings its own animations with it — a spinner mounted by
169
+ // a router, a toast, a lazy image fading in. Re-sweep whenever the page changes, but on
170
+ // the next microtask so we are not mutating inside the mutation we are being told about.
171
+ try {
172
+ var queued = false;
173
+ var observer = new MutationObserver(function () {
174
+ if (queued) return;
175
+ queued = true;
176
+ Promise.resolve().then(function () { queued = false; sweep(); });
177
+ });
178
+ observer.observe(document, {
179
+ childList: true,
180
+ subtree: true,
181
+ attributes: true,
182
+ attributeFilter: ['class', 'style']
183
+ });
184
+ window.__staysfixed_motionObserver = observer;
185
+ } catch (e) {}
186
+ })();`;
187
+ }
188
+
189
+ /**
190
+ * Tell the page it is running on a machine set to reduce motion, and optionally pin the
191
+ * colour scheme so a picture does not flip to dark because the CI box prefers dark.
192
+ *
193
+ * @param {import('../types.js').PageHandle} page
194
+ * @param {{colorScheme?: 'light'|'dark'}} [opts]
195
+ * @returns {Promise<void>}
196
+ */
197
+ export async function reduceMotionCdp(page, opts = {}) {
198
+ /** @type {{name: string, value: string}[]} */
199
+ const features = [{ name: 'prefers-reduced-motion', value: 'reduce' }];
200
+ if (opts.colorScheme) features.push({ name: 'prefers-color-scheme', value: opts.colorScheme });
201
+ try {
202
+ await page.send('Emulation.setEmulatedMedia', { features });
203
+ } catch {
204
+ // Not every target carries Emulation.setEmulatedMedia. The CSS layer still applies.
205
+ }
206
+ }