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