@hegemonart/get-design-done 1.22.0 → 1.23.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +62 -0
- package/package.json +2 -1
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* touches-pattern-miner.cjs — auto-crystallization PROPOSALS only
|
|
3
|
+
* (Plan 23-06).
|
|
4
|
+
*
|
|
5
|
+
* Scans archived task markdown across cycles, normalizes their
|
|
6
|
+
* `Touches:` signatures, and emits a JSON proposal file when a
|
|
7
|
+
* signature recurs in ≥ minTasks tasks across ≥ minCycles cycles.
|
|
8
|
+
*
|
|
9
|
+
* NEVER auto-applies. The reflector + `/gdd:apply-reflections`
|
|
10
|
+
* pipeline consumes the proposal JSON separately and asks the user
|
|
11
|
+
* before materializing anything.
|
|
12
|
+
*
|
|
13
|
+
* Reads: cwd/cycleDir/cycle-{slug}/tasks/{name}.md
|
|
14
|
+
* (cycleDir defaults to .design/archive)
|
|
15
|
+
* Writes: cwd/.design/learnings/touches-patterns.json
|
|
16
|
+
* (atomic via .tmp sibling + rename)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const { parseTouches } = require('./touches-analyzer/index.cjs');
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CYCLE_DIR = '.design/archive';
|
|
27
|
+
const DEFAULT_OUT_PATH = '.design/learnings/touches-patterns.json';
|
|
28
|
+
const DEFAULT_MIN_TASKS = 3;
|
|
29
|
+
const DEFAULT_MIN_CYCLES = 2;
|
|
30
|
+
|
|
31
|
+
const CYCLE_DATED_RE = /^cycle-\d{4}-\d{2}-\d{2}.*/i;
|
|
32
|
+
const CYCLE_SLUG_RE = /^cycle-[a-z0-9-]+$/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Canonicalize a glob list into a stable signature string.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} globs
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function canonicalize(globs) {
|
|
41
|
+
if (!Array.isArray(globs) || globs.length === 0) return '';
|
|
42
|
+
const norm = globs
|
|
43
|
+
.map((g) => stripCycleSlugs(String(g).replace(/\\/g, '/').toLowerCase()))
|
|
44
|
+
.filter((g) => g.length > 0);
|
|
45
|
+
const dedup = Array.from(new Set(norm));
|
|
46
|
+
dedup.sort();
|
|
47
|
+
return dedup.join(',');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Replace `cycle-2026-04-01` / `cycle-foo-bar` segments with `<cycle>`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} normalizedPath
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function stripCycleSlugs(normalizedPath) {
|
|
57
|
+
return normalizedPath
|
|
58
|
+
.split('/')
|
|
59
|
+
.map((seg) => (CYCLE_DATED_RE.test(seg) || CYCLE_SLUG_RE.test(seg) ? '<cycle>' : seg))
|
|
60
|
+
.join('/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} TouchesSignature
|
|
65
|
+
* @property {string} signature
|
|
66
|
+
* @property {string[]} globs
|
|
67
|
+
* @property {Array<{cycle: string, task: string}>} occurrences
|
|
68
|
+
* @property {number} cycleCount
|
|
69
|
+
* @property {number} taskCount
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} MinerProposal
|
|
74
|
+
* @property {string} schema_version
|
|
75
|
+
* @property {string} generated_at
|
|
76
|
+
* @property {{minTasks: number, minCycles: number}} thresholds
|
|
77
|
+
* @property {TouchesSignature[]} proposals
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk archived cycles and tally signature occurrences.
|
|
82
|
+
*
|
|
83
|
+
* @param {{cwd?: string, cycleDir?: string, minTasks?: number, minCycles?: number}} [opts]
|
|
84
|
+
* @returns {Promise<MinerProposal>}
|
|
85
|
+
*/
|
|
86
|
+
async function mine(opts = {}) {
|
|
87
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
88
|
+
const cycleDir = opts.cycleDir ?? DEFAULT_CYCLE_DIR;
|
|
89
|
+
const minTasks = opts.minTasks ?? DEFAULT_MIN_TASKS;
|
|
90
|
+
const minCycles = opts.minCycles ?? DEFAULT_MIN_CYCLES;
|
|
91
|
+
|
|
92
|
+
const archiveRoot = path.isAbsolute(cycleDir) ? cycleDir : path.join(cwd, cycleDir);
|
|
93
|
+
/** @type {Map<string, {globs: string[], occurrences: Array<{cycle: string, task: string}>, cycleSet: Set<string>}>} */
|
|
94
|
+
const byKey = new Map();
|
|
95
|
+
|
|
96
|
+
let entries = [];
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
// Archive dir missing → empty proposal envelope.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const ent of entries) {
|
|
104
|
+
if (!ent.isDirectory()) continue;
|
|
105
|
+
if (!ent.name.toLowerCase().startsWith('cycle-')) continue;
|
|
106
|
+
const cycleId = ent.name;
|
|
107
|
+
const tasksDir = path.join(archiveRoot, cycleId, 'tasks');
|
|
108
|
+
let taskFiles = [];
|
|
109
|
+
try {
|
|
110
|
+
taskFiles = fs
|
|
111
|
+
.readdirSync(tasksDir, { withFileTypes: true })
|
|
112
|
+
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.md'))
|
|
113
|
+
.map((d) => d.name);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const taskName of taskFiles) {
|
|
118
|
+
const taskPath = path.join(tasksDir, taskName);
|
|
119
|
+
let md;
|
|
120
|
+
try {
|
|
121
|
+
md = fs.readFileSync(taskPath, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const globs = parseTouches(md);
|
|
126
|
+
if (globs.length === 0) continue;
|
|
127
|
+
const sig = canonicalize(globs);
|
|
128
|
+
if (sig.length === 0) continue;
|
|
129
|
+
let bucket = byKey.get(sig);
|
|
130
|
+
if (!bucket) {
|
|
131
|
+
bucket = {
|
|
132
|
+
globs: sig.split(','),
|
|
133
|
+
occurrences: [],
|
|
134
|
+
cycleSet: new Set(),
|
|
135
|
+
};
|
|
136
|
+
byKey.set(sig, bucket);
|
|
137
|
+
}
|
|
138
|
+
bucket.occurrences.push({ cycle: cycleId, task: taskName });
|
|
139
|
+
bucket.cycleSet.add(cycleId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @type {TouchesSignature[]} */
|
|
144
|
+
const proposals = [];
|
|
145
|
+
for (const [signature, bucket] of byKey) {
|
|
146
|
+
if (bucket.occurrences.length < minTasks) continue;
|
|
147
|
+
if (bucket.cycleSet.size < minCycles) continue;
|
|
148
|
+
proposals.push({
|
|
149
|
+
signature,
|
|
150
|
+
globs: bucket.globs,
|
|
151
|
+
occurrences: bucket.occurrences.slice(),
|
|
152
|
+
cycleCount: bucket.cycleSet.size,
|
|
153
|
+
taskCount: bucket.occurrences.length,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
proposals.sort((a, b) => {
|
|
157
|
+
if (a.taskCount !== b.taskCount) return b.taskCount - a.taskCount;
|
|
158
|
+
return a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
schema_version: '1.0.0',
|
|
163
|
+
generated_at: new Date().toISOString(),
|
|
164
|
+
thresholds: { minTasks, minCycles },
|
|
165
|
+
proposals,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Atomic write of the proposal envelope. Returns the absolute path
|
|
171
|
+
* written.
|
|
172
|
+
*
|
|
173
|
+
* @param {MinerProposal} proposal
|
|
174
|
+
* @param {{cwd?: string, outPath?: string}} [opts]
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function writeProposals(proposal, opts = {}) {
|
|
178
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
179
|
+
const outRel = opts.outPath ?? DEFAULT_OUT_PATH;
|
|
180
|
+
const out = path.isAbsolute(outRel) ? outRel : path.join(cwd, outRel);
|
|
181
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
182
|
+
const tmp = out + '.tmp';
|
|
183
|
+
fs.writeFileSync(tmp, JSON.stringify(proposal, null, 2));
|
|
184
|
+
fs.renameSync(tmp, out);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
mine,
|
|
190
|
+
writeProposals,
|
|
191
|
+
canonicalize,
|
|
192
|
+
stripCycleSlugs,
|
|
193
|
+
DEFAULT_CYCLE_DIR,
|
|
194
|
+
DEFAULT_OUT_PATH,
|
|
195
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visual-baseline/diff.cjs — pixel-diff primitive (Plan 23-07).
|
|
3
|
+
*
|
|
4
|
+
* Compares two PNG buffers. With `pngjs` installed (probeOptional),
|
|
5
|
+
* decodes both and counts pixels whose R/G/B/A channels differ beyond
|
|
6
|
+
* the tolerance. Without `pngjs`, falls back to bytewise equality
|
|
7
|
+
* (SHA-256 hash compare) — ratio is `equal ? 0 : 1`.
|
|
8
|
+
*
|
|
9
|
+
* Dimension mismatch in pixel mode → ratio=1, drifted=true,
|
|
10
|
+
* reason='dimension-mismatch'. Never throws on shape mismatch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { createHash } = require('node:crypto');
|
|
16
|
+
const { probeOptional } = require('../probe-optional.cjs');
|
|
17
|
+
|
|
18
|
+
const _pngjs = probeOptional('pngjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_THRESHOLD = 0.005;
|
|
21
|
+
const DEFAULT_TOLERANCE = 4;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} DiffResult
|
|
25
|
+
* @property {boolean} drifted
|
|
26
|
+
* @property {number} ratio
|
|
27
|
+
* @property {number} diffPixels
|
|
28
|
+
* @property {number} totalPixels
|
|
29
|
+
* @property {'pixel'|'bytewise'} mode
|
|
30
|
+
* @property {string} [reason]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
function bytewiseDiff(a, b, threshold) {
|
|
34
|
+
const ha = createHash('sha256').update(a).digest('hex');
|
|
35
|
+
const hb = createHash('sha256').update(b).digest('hex');
|
|
36
|
+
const equal = ha === hb;
|
|
37
|
+
const ratio = equal ? 0 : 1;
|
|
38
|
+
return {
|
|
39
|
+
drifted: ratio > threshold,
|
|
40
|
+
ratio,
|
|
41
|
+
diffPixels: equal ? 0 : 1,
|
|
42
|
+
totalPixels: 1,
|
|
43
|
+
mode: 'bytewise',
|
|
44
|
+
reason: 'pngjs-not-available',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pixelDiff(a, b, threshold, tolerance) {
|
|
49
|
+
const { PNG } = _pngjs;
|
|
50
|
+
let pa;
|
|
51
|
+
let pb;
|
|
52
|
+
try {
|
|
53
|
+
pa = PNG.sync.read(a);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
drifted: true,
|
|
57
|
+
ratio: 1,
|
|
58
|
+
diffPixels: 0,
|
|
59
|
+
totalPixels: 0,
|
|
60
|
+
mode: 'pixel',
|
|
61
|
+
reason: `decode-a-failed: ${err && err.message ? err.message : String(err)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
pb = PNG.sync.read(b);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
drifted: true,
|
|
69
|
+
ratio: 1,
|
|
70
|
+
diffPixels: 0,
|
|
71
|
+
totalPixels: 0,
|
|
72
|
+
mode: 'pixel',
|
|
73
|
+
reason: `decode-b-failed: ${err && err.message ? err.message : String(err)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (pa.width !== pb.width || pa.height !== pb.height) {
|
|
77
|
+
return {
|
|
78
|
+
drifted: true,
|
|
79
|
+
ratio: 1,
|
|
80
|
+
diffPixels: 0,
|
|
81
|
+
totalPixels: 0,
|
|
82
|
+
mode: 'pixel',
|
|
83
|
+
reason: 'dimension-mismatch',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const total = pa.width * pa.height;
|
|
87
|
+
const A = pa.data;
|
|
88
|
+
const B = pb.data;
|
|
89
|
+
let diffPx = 0;
|
|
90
|
+
for (let i = 0; i < A.length; i += 4) {
|
|
91
|
+
const dr = Math.abs(A[i] - B[i]);
|
|
92
|
+
const dg = Math.abs(A[i + 1] - B[i + 1]);
|
|
93
|
+
const db = Math.abs(A[i + 2] - B[i + 2]);
|
|
94
|
+
const da = Math.abs(A[i + 3] - B[i + 3]);
|
|
95
|
+
if (dr > tolerance || dg > tolerance || db > tolerance || da > tolerance) {
|
|
96
|
+
diffPx += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const ratio = total > 0 ? diffPx / total : 0;
|
|
100
|
+
return {
|
|
101
|
+
drifted: ratio > threshold,
|
|
102
|
+
ratio,
|
|
103
|
+
diffPixels: diffPx,
|
|
104
|
+
totalPixels: total,
|
|
105
|
+
mode: 'pixel',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare two PNG buffers.
|
|
111
|
+
*
|
|
112
|
+
* @param {Buffer} a
|
|
113
|
+
* @param {Buffer} b
|
|
114
|
+
* @param {{threshold?: number, tolerance?: number}} [opts]
|
|
115
|
+
* @returns {DiffResult}
|
|
116
|
+
*/
|
|
117
|
+
function diff(a, b, opts = {}) {
|
|
118
|
+
if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) {
|
|
119
|
+
throw new TypeError('visual-baseline/diff: both inputs must be Buffers');
|
|
120
|
+
}
|
|
121
|
+
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_THRESHOLD;
|
|
122
|
+
const tolerance = typeof opts.tolerance === 'number' ? opts.tolerance : DEFAULT_TOLERANCE;
|
|
123
|
+
if (!_pngjs) return bytewiseDiff(a, b, threshold);
|
|
124
|
+
return pixelDiff(a, b, threshold, tolerance);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Test-only: report whether pngjs is available. */
|
|
128
|
+
function pngjsAvailable() {
|
|
129
|
+
return _pngjs !== null && _pngjs !== undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
diff,
|
|
134
|
+
pngjsAvailable,
|
|
135
|
+
DEFAULT_THRESHOLD,
|
|
136
|
+
DEFAULT_TOLERANCE,
|
|
137
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visual-baseline/index.cjs — baseline manager for PNG drift checks
|
|
3
|
+
* (Plan 23-07).
|
|
4
|
+
*
|
|
5
|
+
* Reads/writes `.design/baselines/<key>.png`. Compare delegates to
|
|
6
|
+
* `./diff.cjs#diff`. Atomic write via `.tmp` sibling + rename.
|
|
7
|
+
*
|
|
8
|
+
* Defers Playwright/Preview MCP screenshot capture orchestration to a
|
|
9
|
+
* later phase — this module only handles "given a PNG buffer, compare it
|
|
10
|
+
* / save it as the baseline".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const { diff, DEFAULT_THRESHOLD, DEFAULT_TOLERANCE } = require('./diff.cjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASELINE_DIR = '.design/baselines';
|
|
21
|
+
const SAFE_KEY_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} CompareOutcome
|
|
25
|
+
* @property {boolean} drifted
|
|
26
|
+
* @property {number} ratio
|
|
27
|
+
* @property {boolean} baselineExists
|
|
28
|
+
* @property {string} baselinePath
|
|
29
|
+
* @property {'pixel'|'bytewise'|'absent'} mode
|
|
30
|
+
* @property {number} [diffPixels]
|
|
31
|
+
* @property {number} [totalPixels]
|
|
32
|
+
* @property {string} [reason]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a baseline key. Rejects path separators, '..', and unsafe
|
|
37
|
+
* characters that could traverse outside the baseline dir.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} key
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function validateKey(key) {
|
|
43
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
44
|
+
throw new TypeError('visual-baseline: key must be a non-empty string');
|
|
45
|
+
}
|
|
46
|
+
if (key.includes('..') || key.includes('/') || key.includes('\\')) {
|
|
47
|
+
throw new RangeError(
|
|
48
|
+
`visual-baseline: key "${key}" contains illegal characters (/, \\, ..)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!SAFE_KEY_RE.test(key)) {
|
|
52
|
+
throw new RangeError(
|
|
53
|
+
`visual-baseline: key "${key}" must match /^[a-z0-9][a-z0-9._-]{0,127}$/i`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return key;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve baseline file path.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} key
|
|
63
|
+
* @param {{cwd?: string, baselineDir?: string}} [opts]
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function baselinePathFor(key, opts = {}) {
|
|
67
|
+
validateKey(key);
|
|
68
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
69
|
+
const dir = opts.baselineDir ?? DEFAULT_BASELINE_DIR;
|
|
70
|
+
const root = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
|
71
|
+
return path.join(root, `${key}.png`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compare a PNG buffer to the on-disk baseline.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} key
|
|
78
|
+
* @param {Buffer} pngBuffer
|
|
79
|
+
* @param {{cwd?: string, threshold?: number, tolerance?: number, baselineDir?: string}} [opts]
|
|
80
|
+
* @returns {CompareOutcome}
|
|
81
|
+
*/
|
|
82
|
+
function compareToBaseline(key, pngBuffer, opts = {}) {
|
|
83
|
+
if (!Buffer.isBuffer(pngBuffer)) {
|
|
84
|
+
throw new TypeError('visual-baseline: pngBuffer must be a Buffer');
|
|
85
|
+
}
|
|
86
|
+
const baselinePath = baselinePathFor(key, opts);
|
|
87
|
+
if (!fs.existsSync(baselinePath)) {
|
|
88
|
+
return {
|
|
89
|
+
drifted: true,
|
|
90
|
+
ratio: NaN,
|
|
91
|
+
baselineExists: false,
|
|
92
|
+
baselinePath,
|
|
93
|
+
mode: 'absent',
|
|
94
|
+
reason: 'baseline-not-found',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const a = fs.readFileSync(baselinePath);
|
|
98
|
+
const r = diff(a, pngBuffer, opts);
|
|
99
|
+
return {
|
|
100
|
+
drifted: r.drifted,
|
|
101
|
+
ratio: r.ratio,
|
|
102
|
+
baselineExists: true,
|
|
103
|
+
baselinePath,
|
|
104
|
+
mode: r.mode,
|
|
105
|
+
diffPixels: r.diffPixels,
|
|
106
|
+
totalPixels: r.totalPixels,
|
|
107
|
+
reason: r.reason,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Persist a PNG buffer as the baseline. Atomic write (.tmp + rename).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} key
|
|
115
|
+
* @param {Buffer} pngBuffer
|
|
116
|
+
* @param {{cwd?: string, baselineDir?: string}} [opts]
|
|
117
|
+
* @returns {string} absolute path written
|
|
118
|
+
*/
|
|
119
|
+
function applyBaseline(key, pngBuffer, opts = {}) {
|
|
120
|
+
if (!Buffer.isBuffer(pngBuffer)) {
|
|
121
|
+
throw new TypeError('visual-baseline: pngBuffer must be a Buffer');
|
|
122
|
+
}
|
|
123
|
+
const out = baselinePathFor(key, opts);
|
|
124
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
125
|
+
const tmp = out + '.tmp';
|
|
126
|
+
fs.writeFileSync(tmp, pngBuffer);
|
|
127
|
+
fs.renameSync(tmp, out);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
compareToBaseline,
|
|
133
|
+
applyBaseline,
|
|
134
|
+
baselinePathFor,
|
|
135
|
+
validateKey,
|
|
136
|
+
DEFAULT_BASELINE_DIR,
|
|
137
|
+
DEFAULT_THRESHOLD,
|
|
138
|
+
DEFAULT_TOLERANCE,
|
|
139
|
+
};
|