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,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing the pictures on disk.
|
|
3
|
+
*
|
|
4
|
+
* `approved/` is the promise and belongs in git. `results/` is only evidence
|
|
5
|
+
* from the last run. Nothing in here ever promotes a result to approved by
|
|
6
|
+
* itself — `approveFromResult` is called when a human has said yes, and that
|
|
7
|
+
* separation is the point of the whole tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { approvedPicture, resultPicture, safeName } from '../core/paths.js';
|
|
13
|
+
import { sha256 } from '../core/hash.js';
|
|
14
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
15
|
+
import { platformTag } from '../drive/find.js';
|
|
16
|
+
import { pngSize } from './capture.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A small note written beside a result picture so `approve` knows things the PNG
|
|
20
|
+
* cannot tell it — the screen density it was taken at, and its description.
|
|
21
|
+
* @typedef {object} ResultNote
|
|
22
|
+
* @property {number} [deviceScaleFactor]
|
|
23
|
+
* @property {string} [describe]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
28
|
+
* @param {string} name
|
|
29
|
+
* @returns {Promise<{png: Buffer, meta: import('../types.js').PictureMeta|null}|null>}
|
|
30
|
+
*/
|
|
31
|
+
export async function readApproved(paths, name) {
|
|
32
|
+
const files = approvedPicture(paths, name);
|
|
33
|
+
let png;
|
|
34
|
+
try {
|
|
35
|
+
png = await fsp.readFile(files.png);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return { png, meta: await readJson(files.json) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @param {Buffer} png
|
|
46
|
+
* @param {ResultNote} [note]
|
|
47
|
+
* @returns {Promise<string>} the file written
|
|
48
|
+
*/
|
|
49
|
+
export async function writeResult(paths, name, png, note) {
|
|
50
|
+
const files = resultPicture(paths, name);
|
|
51
|
+
await fsp.mkdir(path.dirname(files.png), { recursive: true });
|
|
52
|
+
await fsp.writeFile(files.png, png);
|
|
53
|
+
if (note && (note.deviceScaleFactor !== undefined || note.describe !== undefined)) {
|
|
54
|
+
await fsp.writeFile(resultNotePath(paths, name), JSON.stringify(note, null, 2) + '\n');
|
|
55
|
+
}
|
|
56
|
+
return files.png;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
61
|
+
* @param {string} name
|
|
62
|
+
* @param {Buffer} png
|
|
63
|
+
* @returns {Promise<string>} the file written
|
|
64
|
+
*/
|
|
65
|
+
export async function writeDiff(paths, name, png) {
|
|
66
|
+
const files = resultPicture(paths, name);
|
|
67
|
+
await fsp.mkdir(path.dirname(files.diff), { recursive: true });
|
|
68
|
+
await fsp.writeFile(files.diff, png);
|
|
69
|
+
return files.diff;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Promote the latest result to being the approved picture. Only ever called
|
|
74
|
+
* after a human has looked at it and said yes.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
77
|
+
* @param {string} name
|
|
78
|
+
* @param {{
|
|
79
|
+
* git?: import('../types.js').GitInfo|null,
|
|
80
|
+
* tool?: string,
|
|
81
|
+
* describe?: string,
|
|
82
|
+
* deviceScaleFactor?: number,
|
|
83
|
+
* }} [opts]
|
|
84
|
+
* @returns {Promise<import('../types.js').PictureMeta>}
|
|
85
|
+
*/
|
|
86
|
+
export async function approveFromResult(paths, name, opts = {}) {
|
|
87
|
+
const result = resultPicture(paths, name);
|
|
88
|
+
let png;
|
|
89
|
+
try {
|
|
90
|
+
png = await fsp.readFile(result.png);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new StaysFixedError(`There is no new picture of "${name}" to approve.`, {
|
|
93
|
+
hint: 'Run `staysfixed check` first, then approve what you saw.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const files = approvedPicture(paths, name);
|
|
98
|
+
const previous = await readJson(files.json);
|
|
99
|
+
const note = /** @type {ResultNote|null} */ (await readJson(resultNotePath(paths, name)));
|
|
100
|
+
const size = pngSize(png);
|
|
101
|
+
|
|
102
|
+
/** @type {import('../types.js').PictureMeta} */
|
|
103
|
+
const meta = {
|
|
104
|
+
name,
|
|
105
|
+
width: size.width,
|
|
106
|
+
height: size.height,
|
|
107
|
+
deviceScaleFactor:
|
|
108
|
+
opts.deviceScaleFactor ?? note?.deviceScaleFactor ?? previous?.deviceScaleFactor ?? 1,
|
|
109
|
+
sha256: sha256(png),
|
|
110
|
+
approvedAt: new Date().toISOString(),
|
|
111
|
+
approvedBy: opts.git?.user ?? 'unknown',
|
|
112
|
+
tool: opts.tool ?? 'staysfixed',
|
|
113
|
+
platform: platformTag(),
|
|
114
|
+
};
|
|
115
|
+
const describe = opts.describe ?? note?.describe ?? previous?.describe;
|
|
116
|
+
if (describe !== undefined) meta.describe = describe;
|
|
117
|
+
if (opts.git?.sha) meta.gitSha = opts.git.sha;
|
|
118
|
+
|
|
119
|
+
await fsp.mkdir(paths.approved, { recursive: true });
|
|
120
|
+
await fsp.writeFile(files.png, png);
|
|
121
|
+
await fsp.writeFile(files.json, JSON.stringify(meta, null, 2) + '\n');
|
|
122
|
+
return meta;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
127
|
+
* @returns {Promise<string[]>}
|
|
128
|
+
*/
|
|
129
|
+
export async function listApproved(paths) {
|
|
130
|
+
return listPictureNames(paths.approved);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
135
|
+
* @returns {Promise<string[]>}
|
|
136
|
+
*/
|
|
137
|
+
export async function listResults(paths) {
|
|
138
|
+
return listPictureNames(paths.results);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
143
|
+
* @param {string} name
|
|
144
|
+
* @returns {Promise<void>}
|
|
145
|
+
*/
|
|
146
|
+
export async function removeApproved(paths, name) {
|
|
147
|
+
const files = approvedPicture(paths, name);
|
|
148
|
+
await fsp.rm(files.png, { force: true });
|
|
149
|
+
await fsp.rm(files.json, { force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fingerprint every approved picture, so a marker can tell later whether a
|
|
154
|
+
* screen still looks the way it did without keeping a second copy of it.
|
|
155
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
156
|
+
* @returns {Promise<Record<string,string>>}
|
|
157
|
+
*/
|
|
158
|
+
export async function approvedHashes(paths) {
|
|
159
|
+
/** @type {Record<string,string>} */
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const name of await listApproved(paths)) {
|
|
162
|
+
try {
|
|
163
|
+
out[name] = sha256(await fsp.readFile(approvedPicture(paths, name).png));
|
|
164
|
+
} catch {
|
|
165
|
+
// A picture that vanished between the listing and the read simply has no fingerprint.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} dir
|
|
173
|
+
* @returns {Promise<string[]>}
|
|
174
|
+
*/
|
|
175
|
+
async function listPictureNames(dir) {
|
|
176
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
177
|
+
let entries;
|
|
178
|
+
try {
|
|
179
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
return entries
|
|
184
|
+
.filter((e) => e.isFile() && e.name.endsWith('.png') && !e.name.endsWith('.diff.png'))
|
|
185
|
+
.map((e) => e.name.slice(0, -4))
|
|
186
|
+
.sort();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
191
|
+
* @param {string} name
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
function resultNotePath(paths, name) {
|
|
195
|
+
return path.join(paths.results, `${safeName(name)}.json`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} file
|
|
200
|
+
* @returns {Promise<any>} the parsed contents, or null when it is missing or unreadable
|
|
201
|
+
*/
|
|
202
|
+
async function readJson(file) {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|