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