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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flake register.
|
|
3
|
+
*
|
|
4
|
+
* Asad's rule, and it is the right one: a check that flakes twice gets fixed or
|
|
5
|
+
* deleted, never tolerated. So the tool has to remember. Every run appends a
|
|
6
|
+
* status per check; when a check changes its mind while the code stood still,
|
|
7
|
+
* that is a flake. Past the limit the check is condemned and `check` says so in
|
|
8
|
+
* red until a human deals with it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fsp from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const KEEP_RECENT = 12;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} file
|
|
18
|
+
* @returns {Promise<import('../types.js').History>}
|
|
19
|
+
*/
|
|
20
|
+
export async function loadHistory(file) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fsp.readFile(file, 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} file
|
|
32
|
+
* @param {import('../types.js').History} history
|
|
33
|
+
*/
|
|
34
|
+
export async function saveHistory(file, history) {
|
|
35
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
36
|
+
await fsp.writeFile(file, JSON.stringify(history, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fold one run into the register.
|
|
41
|
+
*
|
|
42
|
+
* A flake is: this check passed and then failed (or the reverse) while the git
|
|
43
|
+
* sha and the working-tree state did not change. That is the only honest
|
|
44
|
+
* definition — anything else blames the developer for their own edits.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('../types.js').History} history
|
|
47
|
+
* @param {{name: string, kind: 'picture'|'guard', status: import('../types.js').CheckStatus, retriedToPass?: boolean}[]} results
|
|
48
|
+
* @param {import('../types.js').GitInfo} git
|
|
49
|
+
* @param {string} at ISO timestamp
|
|
50
|
+
* @param {number} flakeLimit
|
|
51
|
+
* @returns {{history: import('../types.js').History, newlyCondemned: string[], flakedNow: string[]}}
|
|
52
|
+
*/
|
|
53
|
+
export function foldRun(history, results, git, at, flakeLimit) {
|
|
54
|
+
const next = /** @type {import('../types.js').History} */ (structuredClone(history));
|
|
55
|
+
const newlyCondemned = [];
|
|
56
|
+
const flakedNow = [];
|
|
57
|
+
// What "the code did not change" means.
|
|
58
|
+
//
|
|
59
|
+
// Only a clean tree at a known commit proves it. Without git there is no way to tell a
|
|
60
|
+
// wobble from an edit, and guessing costs more than it gives: treating consecutive runs
|
|
61
|
+
// in a repo-less folder as "the same state" made a deliberately broken stylesheet, and
|
|
62
|
+
// then its repair, register as two flakes on every screen in the project — the register
|
|
63
|
+
// shouting about eleven perfectly good checks. A false accusation of flakiness is
|
|
64
|
+
// exactly as corrosive as a false failure.
|
|
65
|
+
//
|
|
66
|
+
// So a run with no git evidence still records its status, and still catches the
|
|
67
|
+
// unambiguous signal (a check that needed a retry to pass INSIDE one run). It just does
|
|
68
|
+
// not compare across runs. `staysfixed flake` says so out loud rather than looking
|
|
69
|
+
// clean — see `blindWithoutGit`.
|
|
70
|
+
const stamp = git.dirty ? null : git.sha;
|
|
71
|
+
|
|
72
|
+
for (const r of results) {
|
|
73
|
+
const key = `${r.kind}:${r.name}`;
|
|
74
|
+
const entry = next[key] ?? {
|
|
75
|
+
name: r.name,
|
|
76
|
+
kind: r.kind,
|
|
77
|
+
runs: 0,
|
|
78
|
+
flakes: 0,
|
|
79
|
+
recent: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const previous = entry.recent.length ? entry.recent[entry.recent.length - 1] : null;
|
|
83
|
+
const previousSha = /** @type {any} */ (entry).lastSha ?? null;
|
|
84
|
+
|
|
85
|
+
// Two ways to catch a wobble:
|
|
86
|
+
// 1. it needed a retry to pass inside this very run — unambiguous;
|
|
87
|
+
// 2. it flipped between runs at the same commit, clean tree both times.
|
|
88
|
+
const flippedAtSameCommit =
|
|
89
|
+
previous !== null &&
|
|
90
|
+
previous !== r.status &&
|
|
91
|
+
stamp !== null &&
|
|
92
|
+
previousSha === stamp &&
|
|
93
|
+
isDecided(previous) &&
|
|
94
|
+
isDecided(r.status);
|
|
95
|
+
|
|
96
|
+
if (r.retriedToPass || flippedAtSameCommit) {
|
|
97
|
+
entry.flakes += 1;
|
|
98
|
+
entry.lastFlakeAt = at;
|
|
99
|
+
entry.lastFlakeGitSha = git.sha ?? undefined;
|
|
100
|
+
flakedNow.push(r.name);
|
|
101
|
+
if (entry.flakes >= flakeLimit && !entry.condemned) {
|
|
102
|
+
entry.condemned = true;
|
|
103
|
+
newlyCondemned.push(r.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
entry.runs += 1;
|
|
108
|
+
entry.recent = [...entry.recent, r.status].slice(-KEEP_RECENT);
|
|
109
|
+
/** @type {any} */ (entry).lastSha = stamp;
|
|
110
|
+
next[key] = entry;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { history: next, newlyCondemned, flakedNow };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A status that means the check actually reached a verdict.
|
|
118
|
+
* @param {import('../types.js').CheckStatus} s
|
|
119
|
+
*/
|
|
120
|
+
function isDecided(s) {
|
|
121
|
+
return s === 'passed' || s === 'changed' || s === 'failed';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {import('../types.js').History} history
|
|
126
|
+
* @returns {import('../types.js').HistoryEntry[]}
|
|
127
|
+
*/
|
|
128
|
+
export function condemned(history) {
|
|
129
|
+
return Object.values(history).filter((e) => e.condemned);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {import('../types.js').History} history
|
|
134
|
+
* @returns {import('../types.js').HistoryEntry[]}
|
|
135
|
+
*/
|
|
136
|
+
export function wobbly(history) {
|
|
137
|
+
return Object.values(history)
|
|
138
|
+
.filter((e) => e.flakes > 0)
|
|
139
|
+
.sort((a, b) => b.flakes - a.flakes);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Forgive a check — used by `staysfixed flake --clear <name>` once it has been fixed.
|
|
144
|
+
* @param {import('../types.js').History} history
|
|
145
|
+
* @param {string} name
|
|
146
|
+
* @returns {import('../types.js').History}
|
|
147
|
+
*/
|
|
148
|
+
export function clearFlakes(history, name) {
|
|
149
|
+
const next = /** @type {import('../types.js').History} */ (structuredClone(history));
|
|
150
|
+
for (const [key, entry] of Object.entries(next)) {
|
|
151
|
+
if (entry.name === name || key === name) {
|
|
152
|
+
entry.flakes = 0;
|
|
153
|
+
entry.condemned = false;
|
|
154
|
+
delete entry.lastFlakeAt;
|
|
155
|
+
delete entry.lastFlakeGitSha;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether the register can only see wobbles inside a single run.
|
|
163
|
+
*
|
|
164
|
+
* True when there is no commit to pin a status to — no git, or a dirty tree. The `flake`
|
|
165
|
+
* command prints this, because a register that looks empty for the wrong reason is worse
|
|
166
|
+
* than one that admits what it cannot see.
|
|
167
|
+
*
|
|
168
|
+
* @param {import('../types.js').GitInfo} git
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
export function blindWithoutGit(git) {
|
|
172
|
+
return git.sha === null || git.dirty;
|
|
173
|
+
}
|
package/src/core/log.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output. No dependencies, no spinners that break in CI, no jargon.
|
|
3
|
+
*
|
|
4
|
+
* Everything a human reads comes through here, so the voice stays the same
|
|
5
|
+
* whether it is printed by `check`, by `walk`, or quoted back by the MCP server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const noColor =
|
|
9
|
+
process.env.NO_COLOR !== undefined ||
|
|
10
|
+
process.env.STAYSFIXED_NO_COLOR !== undefined ||
|
|
11
|
+
process.env.TERM === 'dumb';
|
|
12
|
+
|
|
13
|
+
const tty = Boolean(process.stdout.isTTY) && !noColor;
|
|
14
|
+
|
|
15
|
+
/** @type {(code: string) => (s: string) => string} */
|
|
16
|
+
const wrap = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
17
|
+
|
|
18
|
+
export const paint = {
|
|
19
|
+
bold: wrap('1'),
|
|
20
|
+
dim: wrap('2'),
|
|
21
|
+
red: wrap('31'),
|
|
22
|
+
green: wrap('32'),
|
|
23
|
+
yellow: wrap('33'),
|
|
24
|
+
blue: wrap('34'),
|
|
25
|
+
magenta: wrap('35'),
|
|
26
|
+
cyan: wrap('36'),
|
|
27
|
+
grey: wrap('90'),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Symbols that survive a plain terminal. */
|
|
31
|
+
export const mark = {
|
|
32
|
+
pass: tty ? '✓' : 'ok',
|
|
33
|
+
fail: tty ? '✗' : 'X',
|
|
34
|
+
warn: tty ? '!' : '!',
|
|
35
|
+
info: tty ? '·' : '-',
|
|
36
|
+
arrow: tty ? '→' : '->',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let quiet = false;
|
|
40
|
+
let verbose = false;
|
|
41
|
+
|
|
42
|
+
/** @param {{quiet?: boolean, verbose?: boolean}} opts */
|
|
43
|
+
export function setLogLevel(opts) {
|
|
44
|
+
if (opts.quiet !== undefined) quiet = opts.quiet;
|
|
45
|
+
if (opts.verbose !== undefined) verbose = opts.verbose;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isVerbose() {
|
|
49
|
+
return verbose;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {...unknown} args */
|
|
53
|
+
export function say(...args) {
|
|
54
|
+
if (!quiet) process.stdout.write(args.map(String).join(' ') + '\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {...unknown} args */
|
|
58
|
+
export function detail(...args) {
|
|
59
|
+
if (verbose && !quiet) process.stdout.write(paint.grey(args.map(String).join(' ')) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {...unknown} args */
|
|
63
|
+
export function warn(...args) {
|
|
64
|
+
process.stderr.write(paint.yellow(`${mark.warn} ` + args.map(String).join(' ')) + '\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {...unknown} args */
|
|
68
|
+
export function fail(...args) {
|
|
69
|
+
process.stderr.write(paint.red(`${mark.fail} ` + args.map(String).join(' ')) + '\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {...unknown} args */
|
|
73
|
+
export function ok(...args) {
|
|
74
|
+
if (!quiet) process.stdout.write(paint.green(`${mark.pass} `) + args.map(String).join(' ') + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function blank() {
|
|
78
|
+
if (!quiet) process.stdout.write('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A heading with a rule under it.
|
|
83
|
+
* @param {string} text
|
|
84
|
+
*/
|
|
85
|
+
export function heading(text) {
|
|
86
|
+
if (quiet) return;
|
|
87
|
+
process.stdout.write('\n' + paint.bold(text) + '\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Print rows as an aligned table. Values are strings already painted if needed.
|
|
92
|
+
* @param {string[][]} rows
|
|
93
|
+
* @param {{indent?: number}} [opts]
|
|
94
|
+
*/
|
|
95
|
+
export function table(rows, opts = {}) {
|
|
96
|
+
if (quiet || rows.length === 0) return;
|
|
97
|
+
const indent = ' '.repeat(opts.indent ?? 0);
|
|
98
|
+
const widths = /** @type {number[]} */ ([]);
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
row.forEach((cell, i) => {
|
|
101
|
+
const w = visibleWidth(cell);
|
|
102
|
+
if (widths[i] === undefined || w > widths[i]) widths[i] = w;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
const line = row
|
|
107
|
+
.map((cell, i) => (i === row.length - 1 ? cell : cell + ' '.repeat(widths[i] - visibleWidth(cell))))
|
|
108
|
+
.join(' ');
|
|
109
|
+
process.stdout.write(indent + line.trimEnd() + '\n');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Width ignoring ANSI colour codes.
|
|
115
|
+
* @param {string} s
|
|
116
|
+
*/
|
|
117
|
+
export function visibleWidth(s) {
|
|
118
|
+
// eslint-disable-next-line no-control-regex
|
|
119
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Human duration: 900 -> "0.9s", 65000 -> "1m 5s".
|
|
124
|
+
* @param {number} ms
|
|
125
|
+
*/
|
|
126
|
+
export function duration(ms) {
|
|
127
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
128
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
129
|
+
const m = Math.floor(ms / 60_000);
|
|
130
|
+
const s = Math.round((ms % 60_000) / 1000);
|
|
131
|
+
return `${m}m ${s}s`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A path shortened for reading, relative to cwd when it is inside it.
|
|
136
|
+
* @param {string} p
|
|
137
|
+
*/
|
|
138
|
+
export function shortPath(p) {
|
|
139
|
+
const cwd = process.cwd();
|
|
140
|
+
if (p.startsWith(cwd + '/')) return p.slice(cwd.length + 1);
|
|
141
|
+
const home = process.env.HOME;
|
|
142
|
+
if (home && p.startsWith(home + '/')) return '~/' + p.slice(home.length + 1);
|
|
143
|
+
return p;
|
|
144
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where everything lives inside a project.
|
|
3
|
+
*
|
|
4
|
+
* Committed (belongs in git): approved/ guards/ markers/ fixtures/ config
|
|
5
|
+
* Not committed (throwaway): results/ diffs/ report.html last-run.json
|
|
6
|
+
*
|
|
7
|
+
* The split matters: approved pictures are the promise, results are just the
|
|
8
|
+
* evidence from the last run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import fsp from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
export const CONFIG_NAMES = [
|
|
16
|
+
'staysfixed.config.js',
|
|
17
|
+
'staysfixed.config.mjs',
|
|
18
|
+
'staysfixed.config.json',
|
|
19
|
+
'.staysfixed/config.js',
|
|
20
|
+
'.staysfixed/config.mjs',
|
|
21
|
+
'.staysfixed/config.json',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_DIR = '.staysfixed';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Walk up from `from` looking for a config file.
|
|
28
|
+
* @param {string} [from]
|
|
29
|
+
* @returns {string|null} absolute path to the config file, or null
|
|
30
|
+
*/
|
|
31
|
+
export function findConfigFile(from = process.cwd()) {
|
|
32
|
+
let dir = path.resolve(from);
|
|
33
|
+
for (;;) {
|
|
34
|
+
for (const name of CONFIG_NAMES) {
|
|
35
|
+
const candidate = path.join(dir, name);
|
|
36
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
37
|
+
}
|
|
38
|
+
const parent = path.dirname(dir);
|
|
39
|
+
if (parent === dir) return null;
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The project root for a config file: the folder holding it, or its parent
|
|
46
|
+
* when the config lives inside `.staysfixed/`.
|
|
47
|
+
* @param {string} configFile
|
|
48
|
+
*/
|
|
49
|
+
export function rootForConfig(configFile) {
|
|
50
|
+
const dir = path.dirname(configFile);
|
|
51
|
+
return path.basename(dir) === DEFAULT_DIR ? path.dirname(dir) : dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} root
|
|
56
|
+
* @param {string} configFile
|
|
57
|
+
* @param {string} [dirName]
|
|
58
|
+
* @returns {import('../types.js').ProjectPaths}
|
|
59
|
+
*/
|
|
60
|
+
export function pathsFor(root, configFile, dirName = DEFAULT_DIR) {
|
|
61
|
+
const dir = path.isAbsolute(dirName) ? dirName : path.join(root, dirName);
|
|
62
|
+
return {
|
|
63
|
+
root,
|
|
64
|
+
dir,
|
|
65
|
+
approved: path.join(dir, 'approved'),
|
|
66
|
+
results: path.join(dir, 'results'),
|
|
67
|
+
diffs: path.join(dir, 'results', 'diffs'),
|
|
68
|
+
markers: path.join(dir, 'markers'),
|
|
69
|
+
guards: path.join(dir, 'guards'),
|
|
70
|
+
fixtures: path.join(dir, 'fixtures'),
|
|
71
|
+
historyFile: path.join(dir, 'history.json'),
|
|
72
|
+
reportFile: path.join(dir, 'report.html'),
|
|
73
|
+
configFile,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create the folders that must exist before a run.
|
|
79
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
80
|
+
*/
|
|
81
|
+
export async function ensureDirs(paths) {
|
|
82
|
+
for (const d of [paths.dir, paths.approved, paths.results, paths.diffs, paths.markers]) {
|
|
83
|
+
await fsp.mkdir(d, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* File name for an approved picture and its metadata.
|
|
89
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
90
|
+
* @param {string} name
|
|
91
|
+
*/
|
|
92
|
+
export function approvedPicture(paths, name) {
|
|
93
|
+
return {
|
|
94
|
+
png: path.join(paths.approved, `${safeName(name)}.png`),
|
|
95
|
+
json: path.join(paths.approved, `${safeName(name)}.json`),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* File names for this run's output for one screen.
|
|
101
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
102
|
+
* @param {string} name
|
|
103
|
+
*/
|
|
104
|
+
export function resultPicture(paths, name) {
|
|
105
|
+
return {
|
|
106
|
+
png: path.join(paths.results, `${safeName(name)}.png`),
|
|
107
|
+
diff: path.join(paths.diffs, `${safeName(name)}.diff.png`),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Screen names become file names, so keep them boring.
|
|
113
|
+
* @param {string} name
|
|
114
|
+
*/
|
|
115
|
+
export function safeName(name) {
|
|
116
|
+
return String(name)
|
|
117
|
+
.trim()
|
|
118
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
119
|
+
.replace(/^-+|-+$/g, '')
|
|
120
|
+
.slice(0, 120) || 'unnamed';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clear the previous run's evidence so a stale diff can never be mistaken for a fresh one.
|
|
125
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
126
|
+
*/
|
|
127
|
+
export async function clearResults(paths) {
|
|
128
|
+
await fsp.rm(paths.results, { recursive: true, force: true });
|
|
129
|
+
await fsp.mkdir(paths.diffs, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The .gitignore lines a project needs. Written by `init`, checked by `doctor`.
|
|
134
|
+
*/
|
|
135
|
+
export const GITIGNORE_LINES = ['# Stays Fixed — evidence from the last run, not the promise', '.staysfixed/results/', '.staysfixed/report.html'];
|