@recursica/adapter-tester 2.2.0 → 3.0.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 (40) hide show
  1. package/README.md +47 -16
  2. package/dist/adapter-tester.schema.json.d.ts +14 -10
  3. package/dist/cli.cjs +20 -24
  4. package/dist/cli.cjs.map +1 -1
  5. package/dist/cli.js +235 -4842
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +46 -6
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/fileConfig.d.ts +9 -2
  10. package/dist/fileConfig.d.ts.map +1 -1
  11. package/dist/golden/diffPng.d.ts +8 -0
  12. package/dist/golden/diffPng.d.ts.map +1 -0
  13. package/dist/golden/manifest.schema.json.d.ts +27 -0
  14. package/dist/golden/manifestStore.d.ts +15 -0
  15. package/dist/golden/manifestStore.d.ts.map +1 -0
  16. package/dist/golden/resolveSourceOfTruthGolden.d.ts +29 -0
  17. package/dist/golden/resolveSourceOfTruthGolden.d.ts.map +1 -0
  18. package/dist/golden/validateManifest.d.ts +7 -0
  19. package/dist/golden/validateManifest.d.ts.map +1 -0
  20. package/dist/index-C6uYPRmx.cjs +9 -0
  21. package/dist/index-C6uYPRmx.cjs.map +1 -0
  22. package/dist/index-DqQzFSAH.js +4667 -0
  23. package/dist/index-DqQzFSAH.js.map +1 -0
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +21 -6
  27. package/dist/index.js.map +1 -1
  28. package/dist/testing/runVisualRegression.d.ts +22 -5
  29. package/dist/testing/runVisualRegression.d.ts.map +1 -1
  30. package/dist/testing.cjs +4 -1
  31. package/dist/testing.cjs.map +1 -1
  32. package/dist/testing.js +221 -151
  33. package/dist/testing.js.map +1 -1
  34. package/package.json +3 -2
  35. package/src/adapter-tester.schema.json +14 -10
  36. package/src/golden/manifest.schema.json +24 -0
  37. package/dist/config-B0Eop8Az.cjs +0 -2
  38. package/dist/config-B0Eop8Az.cjs.map +0 -1
  39. package/dist/config-CDxTeSAY.js +0 -21
  40. package/dist/config-CDxTeSAY.js.map +0 -1
package/dist/testing.js CHANGED
@@ -1,171 +1,241 @@
1
- import { test as x, expect as U } from "@playwright/test";
2
- import z from "pixelmatch";
3
- import { PNG as m } from "pngjs";
4
- import w from "node:fs";
5
- import { g as I } from "./config-CDxTeSAY.js";
6
- const R = ["Theme", "Tokens", "Introduction"], E = [
7
- "color",
8
- "background-color",
9
- "padding-top",
10
- "padding-right",
11
- "padding-bottom",
12
- "padding-left",
13
- "margin-top",
14
- "margin-right",
15
- "margin-bottom",
16
- "margin-left",
17
- "border-top-left-radius",
18
- "border-top-right-radius",
19
- "border-bottom-left-radius",
20
- "border-bottom-right-radius",
21
- "font-size",
22
- "font-weight",
23
- "display",
24
- "flex-direction",
25
- "gap",
26
- "height"
27
- ];
28
- function C(t) {
29
- return t.toLowerCase().replace(/[^a-z0-9]+/g, "-");
1
+ import { test as I, expect as M } from "@playwright/test";
2
+ import { existsSync as l, readFileSync as g, mkdirSync as w, writeFileSync as y } from "node:fs";
3
+ import L from "pixelmatch";
4
+ import { PNG as O } from "pngjs";
5
+ import { join as P } from "node:path";
6
+ import { a as R, i as U } from "./index-DqQzFSAH.js";
7
+ function G(e, t) {
8
+ const r = O.sync.read(e), s = O.sync.read(t);
9
+ if (r.width !== s.width || r.height !== s.height)
10
+ return 1 / 0;
11
+ const o = new O({ width: r.width, height: r.height });
12
+ return L(r.data, s.data, o.data, r.width, r.height, {
13
+ threshold: 0.1
14
+ });
30
15
  }
31
- async function A(t, e) {
16
+ const V = "http://json-schema.org/draft-07/schema#", _ = "https://github.com/borderux/recursica/tree/main/packages/adapter-tester/src/golden/manifest.schema.json", J = "test/golden/manifest.json", W = "Tracks golden (baseline) image metadata for @recursica/adapter-tester's golden-image visual regression checks. One manifest lives alongside its adapter's test/golden/<story-id>.png files, keyed by story id.", H = "object", K = { type: "object", additionalProperties: !1, required: ["createdAt"], properties: { createdAt: { type: "string", format: "date-time", description: "When this story's own golden PNG was last captured, via --update-golden, --approve-divergence, or first-run auto-create." }, sourceOfTruthCreatedAt: { type: "string", format: "date-time", description: "The source-of-truth adapter's (mantine) manifest `createdAt` for this story at the time this adapter's divergence from it was last reviewed via --approve-divergence. Omitted on the source-of-truth adapter's own manifest, and omitted here until the first approval. If mantine's current `createdAt` for this story is newer than this value, the divergence is flagged again for re-review." } } }, X = {
17
+ $schema: V,
18
+ $id: _,
19
+ title: J,
20
+ description: W,
21
+ type: H,
22
+ additionalProperties: K
23
+ }, q = U, C = new R.Ajv({ allErrors: !0, strict: !0 });
24
+ q(C);
25
+ const N = C.compile(X);
26
+ function S(e, t) {
27
+ if (N(e)) return;
28
+ const r = (N.errors ?? []).map((s) => {
29
+ var c;
30
+ const o = (c = s.params) != null && c.additionalProperty ? ` '${s.params.additionalProperty}'` : "";
31
+ return ` - ${s.instancePath || "root"} ${s.message}${o}`;
32
+ }).join(`
33
+ `);
34
+ throw new Error(`Invalid ${t}:
35
+ ${r}`);
36
+ }
37
+ function v(e) {
38
+ return P(e, "manifest.json");
39
+ }
40
+ function $(e, t) {
41
+ return P(e, `${t}.png`);
42
+ }
43
+ function b(e) {
44
+ const t = v(e);
45
+ if (!l(t)) return {};
46
+ const r = JSON.parse(g(t, "utf8"));
47
+ return S(r, t), r;
48
+ }
49
+ function z(e, t) {
50
+ const r = v(e);
51
+ S(t, r);
52
+ const s = {};
53
+ for (const o of Object.keys(t).sort())
54
+ s[o] = t[o];
55
+ w(e, { recursive: !0 }), y(r, JSON.stringify(s, null, 2) + `
56
+ `);
57
+ }
58
+ function Q(e, t, r) {
59
+ w(e, { recursive: !0 }), y($(e, t), r);
60
+ }
61
+ const Y = "borderux/recursica";
62
+ async function Z(e, t) {
63
+ var c, u;
64
+ const r = await fetch(`https://registry.npmjs.org/${e}`);
65
+ if (!r.ok)
66
+ throw new Error(
67
+ `Could not reach npm registry for ${e}: ${r.statusText}`
68
+ );
69
+ const s = await r.json(), o = ((c = s["dist-tags"]) == null ? void 0 : c[t]) ?? ((u = s.versions) != null && u[t] ? t : void 0);
70
+ if (!o)
71
+ throw new Error(
72
+ `${e} has no version or dist-tag "${t}" on the npm registry.`
73
+ );
74
+ return o;
75
+ }
76
+ function ee(e) {
77
+ return `packages/${e.split("/").pop()}`;
78
+ }
79
+ async function te(e) {
80
+ if (e.type === "local")
81
+ return l(v(e.dir)) ? {
82
+ manifest: b(e.dir),
83
+ async readImage(i) {
84
+ const d = $(e.dir, i);
85
+ return l(d) ? g(d) : null;
86
+ }
87
+ } : (console.warn(
88
+ `No golden baseline found yet at ${e.dir} — source-of-truth divergence check skipped for this run.`
89
+ ), null);
90
+ let t;
91
+ try {
92
+ t = await Z(
93
+ e.packageName,
94
+ e.versionSpec
95
+ );
96
+ } catch (a) {
97
+ return console.warn(
98
+ `Could not resolve ${e.packageName}@${e.versionSpec} — source-of-truth divergence check skipped for this run.`,
99
+ a
100
+ ), null;
101
+ }
102
+ const r = P(e.cacheDir, t), s = `${e.packageName}@${t}`, o = `https://raw.githubusercontent.com/${Y}/${s}/${ee(e.packageName)}/test/golden`;
103
+ let c;
104
+ const u = v(r);
105
+ if (l(u))
106
+ c = b(r);
107
+ else {
108
+ let a;
109
+ try {
110
+ a = await fetch(`${o}/manifest.json`);
111
+ } catch (n) {
112
+ return console.warn(
113
+ `Could not reach GitHub to fetch ${s}'s golden baseline — source-of-truth divergence check skipped for this run.`,
114
+ n
115
+ ), null;
116
+ }
117
+ if (!a.ok)
118
+ return console.warn(
119
+ `No golden baseline published for ${s} — source-of-truth divergence check skipped for this run.`
120
+ ), null;
121
+ const i = await a.text(), d = JSON.parse(i);
122
+ S(d, `${o}/manifest.json`), w(r, { recursive: !0 }), y(u, i), c = d;
123
+ }
124
+ return {
125
+ manifest: c,
126
+ async readImage(a) {
127
+ const i = $(r, a);
128
+ if (l(i)) return g(i);
129
+ const d = await fetch(`${o}/${a}.png`);
130
+ if (!d.ok) return null;
131
+ const n = Buffer.from(await d.arrayBuffer());
132
+ return w(r, { recursive: !0 }), y(i, n), n;
133
+ }
134
+ };
135
+ }
136
+ const re = ["Theme", "Tokens", "Introduction"];
137
+ function D(e, t) {
138
+ return e === t || e.startsWith(t);
139
+ }
140
+ async function se(e, t, r) {
32
141
  let s;
33
142
  try {
34
- const i = await fetch(`${t.url}/index.json`);
35
- if (!i.ok)
143
+ const o = await fetch(`${e.url}/index.json`);
144
+ if (!o.ok)
36
145
  throw new Error(
37
- `Failed to fetch Storybook index: ${i.statusText}`
146
+ `Failed to fetch Storybook index: ${o.statusText}`
38
147
  );
39
- const a = (await i.json()).entries || {};
40
- s = Object.values(a).filter(
41
- (n) => n.type === "story" && !e.some(
42
- (d) => n.title === d || n.title.startsWith(`${d}/`)
43
- )
44
- ), s.sort((n, d) => n.id.localeCompare(d.id));
45
- } catch (i) {
148
+ const u = (await o.json()).entries || {};
149
+ s = Object.values(u).filter(
150
+ (a) => a.type === "story" && !t.some(
151
+ (i) => a.title === i || a.title.startsWith(`${i}/`)
152
+ ) && !r.some((i) => D(a.id, i))
153
+ ), s.sort((a, i) => a.id.localeCompare(i.id));
154
+ } catch (o) {
46
155
  throw console.error(
47
156
  "Failed to load Storybook index from",
48
- `${t.url}/index.json`,
49
- i
157
+ `${e.url}/index.json`,
158
+ o
50
159
  ), new Error(
51
- `Storybook source of truth "${t.name}" is not responsive or index.json is missing. Please ensure its Storybook is running.`
160
+ `Storybook target "${e.name}" is not responsive or index.json is missing. Please ensure its Storybook is running.`
52
161
  );
53
162
  }
54
163
  return s;
55
164
  }
56
- async function B(t) {
57
- try {
58
- const e = await fetch(`${t.url}/index.json`);
59
- if (!e.ok)
60
- return console.warn(
61
- `Could not load Storybook index from target "${t.name}" (${e.status} ${e.statusText}) — every story will be reported as skipped for this target.`
62
- ), /* @__PURE__ */ new Set();
63
- const i = (await e.json()).entries || {}, g = Object.values(i).filter((a) => a.type === "story").map((a) => a.id);
64
- return new Set(g);
65
- } catch (e) {
66
- return console.warn(
67
- `Could not load Storybook index from target "${t.name}" — every story will be reported as skipped for this target.`,
68
- e
69
- ), /* @__PURE__ */ new Set();
70
- }
165
+ function oe(e, t, r) {
166
+ let s;
167
+ for (const o of Object.keys(t))
168
+ D(e, o) && (!s || o.length > s.length) && (s = o);
169
+ return s !== void 0 ? t[s] : r;
71
170
  }
72
- async function H(t) {
73
- const e = I(t), s = t.targets.filter((r) => !r.sourceOfTruth), i = t.excludeTitlePrefixes ?? R, g = t.relaxedThresholdStoryIds ?? [], a = t.relaxedThresholdPixels, n = await A(e, i), d = /* @__PURE__ */ new Map();
74
- for (const r of s)
75
- d.set(r.name, await B(r));
76
- for (const r of s) {
77
- const N = d.get(r.name) ?? /* @__PURE__ */ new Set();
78
- x.describe(`${e.name} vs ${r.name} Dynamic Visual Regression Suite`, () => {
79
- for (const o of n)
80
- x(`Visual regression for: ${o.title} - ${o.name} (${o.id})`, async ({
81
- browser: O
82
- }, h) => {
83
- x.skip(
84
- !N.has(o.id),
85
- `${r.name} has no story with id "${o.id}" yet — not yet implemented, not a failure.`
86
- );
87
- const c = await O.newPage(), l = await O.newPage();
88
- await c.setViewportSize({ width: 800, height: 600 }), await l.setViewportSize({ width: 800, height: 600 }), await Promise.all([
89
- c.goto(
90
- `${e.url}/iframe.html?id=${o.id}&viewMode=story`,
91
- { waitUntil: "networkidle" }
92
- ),
93
- l.goto(
94
- `${r.url}/iframe.html?id=${o.id}&viewMode=story`,
95
- { waitUntil: "networkidle" }
96
- )
97
- ]), await Promise.all([
98
- c.waitForSelector("#storybook-root"),
99
- l.waitForSelector("#storybook-root")
100
- ]), await Promise.all([
101
- c.waitForTimeout(300),
102
- l.waitForTimeout(300)
103
- ]);
104
- const k = ($) => {
105
- function M(T) {
106
- const b = {
107
- tag: T.tagName.toLowerCase(),
108
- styles: {},
109
- children: []
110
- }, _ = window.getComputedStyle(T);
111
- for (const y of $)
112
- b.styles[y] = _.getPropertyValue(y);
113
- for (const y of Array.from(T.children))
114
- b.children.push(M(y));
115
- return b;
116
- }
117
- const D = document.querySelector("#storybook-root");
118
- return D ? M(D) : null;
119
- }, P = await c.evaluate(
120
- k,
121
- E
122
- ), F = await l.evaluate(
123
- k,
124
- E
125
- );
126
- await h.attach(`${e.name} DOM Tree JSON`, {
127
- body: JSON.stringify(P, null, 2),
128
- contentType: "application/json"
129
- }), await h.attach(`${r.name} DOM Tree JSON`, {
130
- body: JSON.stringify(F, null, 2),
131
- contentType: "application/json"
171
+ async function le(e) {
172
+ const t = e.isSourceOfTruthAdapter ? e.targets[0] : e.targets.find((n) => !n.sourceOfTruth);
173
+ if (!t)
174
+ throw new Error(
175
+ "adapter-tester config has no non-sourceOfTruth target to run the golden check against."
176
+ );
177
+ const r = e.excludeTitlePrefixes ?? re, s = e.excludeStoryIds ?? [], o = e.storyThresholds ?? {}, c = e.goldenDir, u = e.goldenMode, a = await se(
178
+ t,
179
+ r,
180
+ s
181
+ ), i = b(c), d = e.isSourceOfTruthAdapter || !e.sourceOfTruthGolden ? null : await te(e.sourceOfTruthGolden);
182
+ I.describe(`${t.name} — Golden Image Visual Regression`, () => {
183
+ for (const n of a)
184
+ I(`Golden regression for: ${n.title} - ${n.name} (${n.id})`, async ({
185
+ browser: B
186
+ }, T) => {
187
+ const p = await B.newPage();
188
+ await p.setViewportSize({ width: 800, height: 600 }), await p.goto(
189
+ `${t.url}/iframe.html?id=${n.id}&viewMode=story`,
190
+ { waitUntil: "networkidle" }
191
+ ), await p.waitForSelector("#storybook-root"), await p.waitForTimeout(300);
192
+ const j = await p.screenshot(), k = $(c, n.id), f = i[n.id];
193
+ if (u !== "check" || !f || !l(k))
194
+ Q(c, n.id, j), i[n.id] = f != null && f.sourceOfTruthCreatedAt ? {
195
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
196
+ sourceOfTruthCreatedAt: f.sourceOfTruthCreatedAt
197
+ } : { createdAt: (/* @__PURE__ */ new Date()).toISOString() }, f || T.annotations.push({
198
+ type: "golden-created",
199
+ description: `No golden existed yet for "${n.id}" — captured one from this run.`
132
200
  });
133
- const j = C(e.name), v = C(r.name);
134
- w.writeFileSync(
135
- `/tmp/${o.id}-${j}DOM.json`,
136
- JSON.stringify(P, null, 2)
137
- ), w.writeFileSync(
138
- `/tmp/${o.id}-${v}DOM.json`,
139
- JSON.stringify(F, null, 2)
140
- );
141
- const f = await c.screenshot(), p = await l.screenshot(), u = m.sync.read(f), L = m.sync.read(p), S = new m({ width: u.width, height: u.height }), V = z(
142
- u.data,
143
- L.data,
144
- S.data,
145
- u.width,
146
- u.height,
147
- { threshold: 0.1 }
201
+ else {
202
+ const h = g(k), m = G(j, h);
203
+ await T.attach("Live vs Golden Diff", {
204
+ body: `${m} mismatched pixels`,
205
+ contentType: "text/plain"
206
+ });
207
+ const x = oe(
208
+ n.id,
209
+ o,
210
+ e.diffThresholdPixels
148
211
  );
149
- w.writeFileSync(`/tmp/${o.id}-diff.png`, m.sync.write(S)), w.writeFileSync(`/tmp/${o.id}-${j}.png`, f), w.writeFileSync(`/tmp/${o.id}-${v}.png`, p), await h.attach("Visual Diff Overlay", {
150
- body: m.sync.write(S),
151
- contentType: "image/png"
152
- }), await h.attach(
153
- `${e.name} Screenshot (Source of Truth)`,
154
- {
155
- body: f,
156
- contentType: "image/png"
212
+ M.soft(
213
+ m,
214
+ `"${n.id}" has drifted from its own golden image`
215
+ ).toBeLessThan(x);
216
+ }
217
+ const A = i[n.id];
218
+ if (d) {
219
+ const h = d.manifest[n.id], m = h ? await d.readImage(n.id) : null;
220
+ if (h && m)
221
+ if (u === "approve-divergence")
222
+ i[n.id] = {
223
+ ...A,
224
+ sourceOfTruthCreatedAt: h.createdAt
225
+ };
226
+ else {
227
+ const x = g(k), F = G(x, m), E = A.sourceOfTruthCreatedAt;
228
+ F === 0 || E !== void 0 && E >= h.createdAt || T.annotations.push({
229
+ type: "source-of-truth-divergence",
230
+ description: `"${n.id}" differs from the source of truth's golden and hasn't been reviewed — run with --approve-divergence if this is intentional, or fix the adapter styling.`
231
+ });
157
232
  }
158
- ), await h.attach(`${r.name} Screenshot (Target)`, {
159
- body: p,
160
- contentType: "image/png"
161
- });
162
- const J = a !== void 0 && g.some(($) => o.id.startsWith($)) ? a : t.diffThresholdPixels;
163
- U.soft(V).toBeLessThan(J);
164
- });
165
- });
166
- }
233
+ }
234
+ z(c, i);
235
+ });
236
+ });
167
237
  }
168
238
  export {
169
- H as runVisualRegression
239
+ le as runVisualRegression
170
240
  };
171
241
  //# sourceMappingURL=testing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sources":["../src/testing/runVisualRegression.ts"],"sourcesContent":["import { test, expect } from \"@playwright/test\";\nimport pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport fs from \"node:fs\";\nimport type { AdapterTarget, AdapterTesterConfig } from \"../config.js\";\nimport { getSourceOfTruth } from \"../config.js\";\n\nconst DEFAULT_EXCLUDE_TITLE_PREFIXES = [\"Theme\", \"Tokens\", \"Introduction\"];\n\nconst COMMON_STYLES_TO_CAPTURE = [\n \"color\",\n \"background-color\",\n \"padding-top\",\n \"padding-right\",\n \"padding-bottom\",\n \"padding-left\",\n \"margin-top\",\n \"margin-right\",\n \"margin-bottom\",\n \"margin-left\",\n \"border-top-left-radius\",\n \"border-top-right-radius\",\n \"border-bottom-left-radius\",\n \"border-bottom-right-radius\",\n \"font-size\",\n \"font-weight\",\n \"display\",\n \"flex-direction\",\n \"gap\",\n \"height\",\n];\n\ninterface StorybookEntry {\n type: string;\n id: string;\n name: string;\n title: string;\n}\n\nfunction slug(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n}\n\nasync function fetchStories(\n sourceOfTruth: AdapterTarget,\n excludeTitlePrefixes: string[],\n): Promise<StorybookEntry[]> {\n let stories: StorybookEntry[];\n try {\n const response = await fetch(`${sourceOfTruth.url}/index.json`);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch Storybook index: ${response.statusText}`,\n );\n }\n const data = (await response.json()) as any;\n const entries = data.entries || {};\n stories = Object.values(entries).filter(\n (entry: any) =>\n entry.type === \"story\" &&\n !excludeTitlePrefixes.some(\n (prefix) =>\n entry.title === prefix || entry.title.startsWith(`${prefix}/`),\n ),\n ) as StorybookEntry[];\n stories.sort((a, b) => a.id.localeCompare(b.id));\n } catch (error) {\n console.error(\n \"Failed to load Storybook index from\",\n `${sourceOfTruth.url}/index.json`,\n error,\n );\n throw new Error(\n `Storybook source of truth \"${sourceOfTruth.name}\" is not responsive or index.json is missing. Please ensure its Storybook is running.`,\n );\n }\n return stories;\n}\n\n/**\n * Fetches just the set of story ids a target's own Storybook actually has —\n * used to skip a comparison up front rather than let it burn the full\n * per-test timeout navigating to a story id the target doesn't implement\n * yet. A target with a partially-built component set (a fresh adapter with\n * a handful of real components and many still-stub ones, for example) is\n * the normal case, not an error, so a failed/empty fetch here degrades to\n * \"assume nothing exists yet\" (every story against this target gets\n * skipped) rather than throwing — unlike `fetchStories` above, where the\n * source of truth being unreachable really is fatal to the whole run.\n */\nasync function fetchTargetStoryIds(target: AdapterTarget): Promise<Set<string>> {\n try {\n const response = await fetch(`${target.url}/index.json`);\n if (!response.ok) {\n console.warn(\n `Could not load Storybook index from target \"${target.name}\" (${response.status} ${response.statusText}) — every story will be reported as skipped for this target.`,\n );\n return new Set();\n }\n const data = (await response.json()) as any;\n const entries = data.entries || {};\n const ids = Object.values(entries)\n .filter((entry: any) => entry.type === \"story\")\n .map((entry: any) => entry.id as string);\n return new Set(ids);\n } catch (error) {\n console.warn(\n `Could not load Storybook index from target \"${target.name}\" — every story will be reported as skipped for this target.`,\n error,\n );\n return new Set();\n }\n}\n\n/**\n * Defines a Playwright suite that diffs every target in `config.targets`\n * against `config.targets`' single `sourceOfTruth` entry, story by story.\n * Call this with a top-level `await` from a Playwright `*.spec.ts` file — it\n * calls `test.describe` at module scope, so it must run inside Playwright's\n * test runner during test-graph compilation.\n */\nexport async function runVisualRegression(\n config: AdapterTesterConfig,\n): Promise<void> {\n const sourceOfTruth = getSourceOfTruth(config);\n const targets = config.targets.filter((target) => !target.sourceOfTruth);\n const excludeTitlePrefixes =\n config.excludeTitlePrefixes ?? DEFAULT_EXCLUDE_TITLE_PREFIXES;\n const relaxedThresholdStoryIds = config.relaxedThresholdStoryIds ?? [];\n const relaxedThresholdPixels = config.relaxedThresholdPixels;\n\n // Fetched once at test-graph compilation time and shared by every target's\n // describe block below.\n const stories = await fetchStories(sourceOfTruth, excludeTitlePrefixes);\n\n // One index fetch per target, not per story — a fresh/partially-built adapter is expected to\n // be missing most of the source of truth's stories, and each miss should be a cheap, fast\n // skip rather than a full-timeout navigation to a story id that will never resolve.\n const targetStoryIds = new Map<string, Set<string>>();\n for (const target of targets) {\n targetStoryIds.set(target.name, await fetchTargetStoryIds(target));\n }\n\n for (const target of targets) {\n const storyIds = targetStoryIds.get(target.name) ?? new Set<string>();\n test.describe(`${sourceOfTruth.name} vs ${target.name} — Dynamic Visual Regression Suite`, () => {\n for (const story of stories) {\n test(`Visual regression for: ${story.title} - ${story.name} (${story.id})`, async ({\n browser,\n }, testInfo) => {\n test.skip(\n !storyIds.has(story.id),\n `${target.name} has no story with id \"${story.id}\" yet — not yet implemented, not a failure.`,\n );\n\n const sourcePage = await browser.newPage();\n const targetPage = await browser.newPage();\n\n await sourcePage.setViewportSize({ width: 800, height: 600 });\n await targetPage.setViewportSize({ width: 800, height: 600 });\n\n await Promise.all([\n sourcePage.goto(\n `${sourceOfTruth.url}/iframe.html?id=${story.id}&viewMode=story`,\n { waitUntil: \"networkidle\" },\n ),\n targetPage.goto(\n `${target.url}/iframe.html?id=${story.id}&viewMode=story`,\n { waitUntil: \"networkidle\" },\n ),\n ]);\n\n await Promise.all([\n sourcePage.waitForSelector(\"#storybook-root\"),\n targetPage.waitForSelector(\"#storybook-root\"),\n ]);\n\n await Promise.all([\n sourcePage.waitForTimeout(300),\n targetPage.waitForTimeout(300),\n ]);\n\n // 1. EXTRACT DOM STRUCTURE AND STYLES\n const extractFn = (stylesToCapture: string[]) => {\n function crawl(el: Element): any {\n const data: any = {\n tag: el.tagName.toLowerCase(),\n styles: {},\n children: [],\n };\n const computed = window.getComputedStyle(el);\n for (const prop of stylesToCapture) {\n data.styles[prop] = computed.getPropertyValue(prop);\n }\n for (const child of Array.from(el.children)) {\n data.children.push(crawl(child));\n }\n return data;\n }\n const root = document.querySelector(\"#storybook-root\");\n return root ? crawl(root) : null;\n };\n\n const sourceDOM = await sourcePage.evaluate(\n extractFn,\n COMMON_STYLES_TO_CAPTURE,\n );\n const targetDOM = await targetPage.evaluate(\n extractFn,\n COMMON_STYLES_TO_CAPTURE,\n );\n\n await testInfo.attach(`${sourceOfTruth.name} DOM Tree JSON`, {\n body: JSON.stringify(sourceDOM, null, 2),\n contentType: \"application/json\",\n });\n await testInfo.attach(`${target.name} DOM Tree JSON`, {\n body: JSON.stringify(targetDOM, null, 2),\n contentType: \"application/json\",\n });\n\n const sourceSlug = slug(sourceOfTruth.name);\n const targetSlug = slug(target.name);\n fs.writeFileSync(\n `/tmp/${story.id}-${sourceSlug}DOM.json`,\n JSON.stringify(sourceDOM, null, 2),\n );\n fs.writeFileSync(\n `/tmp/${story.id}-${targetSlug}DOM.json`,\n JSON.stringify(targetDOM, null, 2),\n );\n\n // 2. CAPTURE Headless Viewport Screenshots\n const sourceBuffer = await sourcePage.screenshot();\n const targetBuffer = await targetPage.screenshot();\n\n // 3. PIXEL-BY-PIXEL COMPARISON\n const img1 = PNG.sync.read(sourceBuffer);\n const img2 = PNG.sync.read(targetBuffer);\n const diff = new PNG({ width: img1.width, height: img1.height });\n\n const diffPixels = pixelmatch(\n img1.data,\n img2.data,\n diff.data,\n img1.width,\n img1.height,\n { threshold: 0.1 },\n );\n\n fs.writeFileSync(`/tmp/${story.id}-diff.png`, PNG.sync.write(diff));\n fs.writeFileSync(`/tmp/${story.id}-${sourceSlug}.png`, sourceBuffer);\n fs.writeFileSync(`/tmp/${story.id}-${targetSlug}.png`, targetBuffer);\n\n await testInfo.attach(\"Visual Diff Overlay\", {\n body: PNG.sync.write(diff),\n contentType: \"image/png\",\n });\n await testInfo.attach(\n `${sourceOfTruth.name} Screenshot (Source of Truth)`,\n {\n body: sourceBuffer,\n contentType: \"image/png\",\n },\n );\n await testInfo.attach(`${target.name} Screenshot (Target)`, {\n body: targetBuffer,\n contentType: \"image/png\",\n });\n\n const threshold =\n relaxedThresholdPixels !== undefined &&\n relaxedThresholdStoryIds.some((id) => story.id.startsWith(id))\n ? relaxedThresholdPixels\n : config.diffThresholdPixels;\n\n // Perform soft assertions so failures are logged but other stories continue\n expect.soft(diffPixels).toBeLessThan(threshold);\n });\n }\n });\n }\n}\n"],"names":["DEFAULT_EXCLUDE_TITLE_PREFIXES","COMMON_STYLES_TO_CAPTURE","slug","name","fetchStories","sourceOfTruth","excludeTitlePrefixes","stories","response","entries","entry","prefix","a","b","error","fetchTargetStoryIds","target","ids","runVisualRegression","config","getSourceOfTruth","targets","relaxedThresholdStoryIds","relaxedThresholdPixels","targetStoryIds","storyIds","test","story","browser","testInfo","sourcePage","targetPage","extractFn","stylesToCapture","crawl","el","data","computed","prop","child","root","sourceDOM","targetDOM","sourceSlug","targetSlug","fs","sourceBuffer","targetBuffer","img1","PNG","img2","diff","diffPixels","pixelmatch","threshold","id","expect"],"mappings":"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"}
1
+ {"version":3,"file":"testing.js","sources":["../src/golden/diffPng.ts","../src/golden/validateManifest.ts","../src/golden/manifestStore.ts","../src/golden/resolveSourceOfTruthGolden.ts","../src/testing/runVisualRegression.ts"],"sourcesContent":["import pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\n\n/**\n * Pixel-diffs two PNG buffers. Returns the mismatched-pixel count, or\n * `Infinity` if the two images aren't even the same dimensions — pixelmatch\n * itself throws on a size mismatch, and a size mismatch is itself a real\n * difference, not something to swallow.\n */\nexport function diffPngBuffers(a: Buffer, b: Buffer): number {\n const imgA = PNG.sync.read(a);\n const imgB = PNG.sync.read(b);\n if (imgA.width !== imgB.width || imgA.height !== imgB.height) {\n return Infinity;\n }\n const diff = new PNG({ width: imgA.width, height: imgA.height });\n return pixelmatch(imgA.data, imgB.data, diff.data, imgA.width, imgA.height, {\n threshold: 0.1,\n });\n}\n","import { Ajv } from \"ajv\";\nimport * as ajvFormatsModule from \"ajv-formats\";\nimport type { FormatsPlugin } from \"ajv-formats\";\nimport schema from \"./manifest.schema.json\" with { type: \"json\" };\n\n// See validateFileConfig.ts for why `.default` has to be unwrapped by hand.\nconst addFormats = (ajvFormatsModule as unknown as { default: FormatsPlugin })\n .default;\n\nconst ajv = new Ajv({ allErrors: true, strict: true });\naddFormats(ajv);\nconst validate = ajv.compile(schema);\n\n/**\n * Validates a parsed `test/golden/manifest.json` against `manifest.schema.json`.\n * Throws with every violation listed — callers must not silently coerce or\n * drop invalid entries.\n */\nexport function validateGoldenManifest(data: unknown, path: string): void {\n if (validate(data)) return;\n\n const errors = (validate.errors ?? [])\n .map((error) => {\n const extra = error.params?.additionalProperty\n ? ` '${error.params.additionalProperty}'`\n : \"\";\n return ` - ${error.instancePath || \"root\"} ${error.message}${extra}`;\n })\n .join(\"\\n\");\n throw new Error(`Invalid ${path}:\\n${errors}`);\n}\n","import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { validateGoldenManifest } from \"./validateManifest.js\";\n\nexport interface GoldenManifestEntry {\n createdAt: string;\n sourceOfTruthCreatedAt?: string;\n}\n\nexport type GoldenManifest = Record<string, GoldenManifestEntry>;\n\nexport function manifestPath(goldenDir: string): string {\n return join(goldenDir, \"manifest.json\");\n}\n\nexport function goldenImagePath(goldenDir: string, storyId: string): string {\n return join(goldenDir, `${storyId}.png`);\n}\n\n/** Returns `{}` if no manifest exists yet — a fresh adapter with no goldens\n * captured is the normal starting state, not an error. */\nexport function loadManifest(goldenDir: string): GoldenManifest {\n const path = manifestPath(goldenDir);\n if (!existsSync(path)) return {};\n const data = JSON.parse(readFileSync(path, \"utf8\"));\n validateGoldenManifest(data, path);\n return data;\n}\n\n/** Validates before writing, and sorts keys so the diff on a reviewed PR is\n * stable regardless of the order stories happened to run in. */\nexport function saveManifest(\n goldenDir: string,\n manifest: GoldenManifest,\n): void {\n const path = manifestPath(goldenDir);\n validateGoldenManifest(manifest, path);\n const sorted: GoldenManifest = {};\n for (const key of Object.keys(manifest).sort()) {\n sorted[key] = manifest[key]!;\n }\n mkdirSync(goldenDir, { recursive: true });\n writeFileSync(path, JSON.stringify(sorted, null, 2) + \"\\n\");\n}\n\nexport function saveGoldenImage(\n goldenDir: string,\n storyId: string,\n buffer: Buffer,\n): void {\n mkdirSync(goldenDir, { recursive: true });\n writeFileSync(goldenImagePath(goldenDir, storyId), buffer);\n}\n","import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SourceOfTruthGoldenLocation } from \"../config.js\";\nimport {\n goldenImagePath,\n loadManifest,\n manifestPath,\n type GoldenManifest,\n} from \"./manifestStore.js\";\nimport { validateGoldenManifest } from \"./validateManifest.js\";\n\nconst GITHUB_REPO = \"borderux/recursica\";\n\nexport interface SourceOfTruthGolden {\n manifest: GoldenManifest;\n /** Returns the golden PNG bytes for a story, or `null` if the source of\n * truth has no golden captured for it yet. */\n readImage(storyId: string): Promise<Buffer | null>;\n}\n\nasync function resolveNpmVersion(\n packageName: string,\n versionSpec: string,\n): Promise<string> {\n const response = await fetch(`https://registry.npmjs.org/${packageName}`);\n if (!response.ok) {\n throw new Error(\n `Could not reach npm registry for ${packageName}: ${response.statusText}`,\n );\n }\n const data = (await response.json()) as {\n \"dist-tags\"?: Record<string, string>;\n versions?: Record<string, unknown>;\n };\n const resolved =\n data[\"dist-tags\"]?.[versionSpec] ??\n (data.versions?.[versionSpec] ? versionSpec : undefined);\n if (!resolved) {\n throw new Error(\n `${packageName} has no version or dist-tag \"${versionSpec}\" on the npm registry.`,\n );\n }\n return resolved;\n}\n\n// This monorepo's packages all live at `packages/<unscoped-name>` — mirrors\n// the same convention `mantineSourceOfTruthHarness` and every existing\n// `packages/*/package.json`'s `repository.directory` field already assume.\nfunction packageDirectory(packageName: string): string {\n return `packages/${packageName.split(\"/\").pop()}`;\n}\n\n/**\n * Resolves the source-of-truth adapter's golden images for the divergence\n * check. Never boots a Storybook — both location types resolve to plain\n * files, fetched once and cached, not re-diffed per pixel over the wire.\n *\n * `location.type === \"local\"`: a sibling package already checked out (this\n * monorepo's own `sourceOfTruth.type: \"url\"` mode) — read its\n * `test/golden/` directly, including any uncommitted local changes.\n *\n * `location.type === \"npm\"`: no local checkout (the default, standalone-repo\n * mode) — resolve the installed version against the npm registry, then fetch\n * that exact version's `test/golden/` from the public GitHub repo at the\n * matching release tag (changesets tags every release as\n * `<packageName>@<version>`), caching what's downloaded under `cacheDir`.\n *\n * Returns `null` — degrading the divergence check to a skip, not a failure —\n * when no golden baseline exists yet for this version, or the registry/repo\n * is unreachable.\n */\nexport async function resolveSourceOfTruthGolden(\n location: SourceOfTruthGoldenLocation,\n): Promise<SourceOfTruthGolden | null> {\n if (location.type === \"local\") {\n if (!existsSync(manifestPath(location.dir))) {\n console.warn(\n `No golden baseline found yet at ${location.dir} — source-of-truth divergence check skipped for this run.`,\n );\n return null;\n }\n const manifest = loadManifest(location.dir);\n return {\n manifest,\n async readImage(storyId) {\n const path = goldenImagePath(location.dir, storyId);\n return existsSync(path) ? readFileSync(path) : null;\n },\n };\n }\n\n let version: string;\n try {\n version = await resolveNpmVersion(\n location.packageName,\n location.versionSpec,\n );\n } catch (error) {\n console.warn(\n `Could not resolve ${location.packageName}@${location.versionSpec} — source-of-truth divergence check skipped for this run.`,\n error,\n );\n return null;\n }\n\n const cacheDir = join(location.cacheDir, version);\n const tag = `${location.packageName}@${version}`;\n const rawBase = `https://raw.githubusercontent.com/${GITHUB_REPO}/${tag}/${packageDirectory(location.packageName)}/test/golden`;\n\n let manifest: GoldenManifest;\n const cachedManifestPath = manifestPath(cacheDir);\n if (existsSync(cachedManifestPath)) {\n manifest = loadManifest(cacheDir);\n } else {\n let response: Response;\n try {\n response = await fetch(`${rawBase}/manifest.json`);\n } catch (error) {\n console.warn(\n `Could not reach GitHub to fetch ${tag}'s golden baseline — source-of-truth divergence check skipped for this run.`,\n error,\n );\n return null;\n }\n if (!response.ok) {\n console.warn(\n `No golden baseline published for ${tag} — source-of-truth divergence check skipped for this run.`,\n );\n return null;\n }\n const text = await response.text();\n const parsed = JSON.parse(text);\n validateGoldenManifest(parsed, `${rawBase}/manifest.json`);\n mkdirSync(cacheDir, { recursive: true });\n writeFileSync(cachedManifestPath, text);\n manifest = parsed;\n }\n\n return {\n manifest,\n async readImage(storyId) {\n const cachedImagePath = goldenImagePath(cacheDir, storyId);\n if (existsSync(cachedImagePath)) return readFileSync(cachedImagePath);\n const response = await fetch(`${rawBase}/${storyId}.png`);\n if (!response.ok) return null;\n const buffer = Buffer.from(await response.arrayBuffer());\n mkdirSync(cacheDir, { recursive: true });\n writeFileSync(cachedImagePath, buffer);\n return buffer;\n },\n };\n}\n","import { test, expect } from \"@playwright/test\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport type { AdapterTesterConfig } from \"../config.js\";\nimport { diffPngBuffers } from \"../golden/diffPng.js\";\nimport {\n goldenImagePath,\n loadManifest,\n saveGoldenImage,\n saveManifest,\n} from \"../golden/manifestStore.js\";\nimport { resolveSourceOfTruthGolden } from \"../golden/resolveSourceOfTruthGolden.js\";\n\nconst DEFAULT_EXCLUDE_TITLE_PREFIXES = [\"Theme\", \"Tokens\", \"Introduction\"];\n\ninterface StorybookEntry {\n type: string;\n id: string;\n name: string;\n title: string;\n}\n\nfunction matchesPrefix(id: string, prefix: string): boolean {\n return id === prefix || id.startsWith(prefix);\n}\n\nasync function fetchStories(\n target: { name: string; url: string },\n excludeTitlePrefixes: string[],\n excludeStoryIds: string[],\n): Promise<StorybookEntry[]> {\n let stories: StorybookEntry[];\n try {\n const response = await fetch(`${target.url}/index.json`);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch Storybook index: ${response.statusText}`,\n );\n }\n const data = (await response.json()) as any;\n const entries = data.entries || {};\n stories = Object.values(entries).filter(\n (entry: any) =>\n entry.type === \"story\" &&\n !excludeTitlePrefixes.some(\n (prefix) =>\n entry.title === prefix || entry.title.startsWith(`${prefix}/`),\n ) &&\n !excludeStoryIds.some((prefix) => matchesPrefix(entry.id, prefix)),\n ) as StorybookEntry[];\n stories.sort((a, b) => a.id.localeCompare(b.id));\n } catch (error) {\n console.error(\n \"Failed to load Storybook index from\",\n `${target.url}/index.json`,\n error,\n );\n throw new Error(\n `Storybook target \"${target.name}\" is not responsive or index.json is missing. Please ensure its Storybook is running.`,\n );\n }\n return stories;\n}\n\n/** Resolves the diff threshold for `storyId`: the longest (most specific)\n * `storyThresholds` key matching by prefix, falling back to\n * `diffThresholdPixels` when nothing matches. */\nfunction resolveThreshold(\n storyId: string,\n storyThresholds: Record<string, number>,\n diffThresholdPixels: number,\n): number {\n let bestMatch: string | undefined;\n for (const prefix of Object.keys(storyThresholds)) {\n if (\n matchesPrefix(storyId, prefix) &&\n (!bestMatch || prefix.length > bestMatch.length)\n ) {\n bestMatch = prefix;\n }\n }\n return bestMatch !== undefined\n ? storyThresholds[bestMatch]!\n : diffThresholdPixels;\n}\n\n/**\n * Defines a Playwright suite that golden-image-tests every story in\n * `config`'s own target (the one target in `config.targets` not marked\n * `sourceOfTruth`). Call this with a top-level `await` from a Playwright\n * `*.spec.ts` file — it calls `test.describe` at module scope, so it must\n * run inside Playwright's test runner during test-graph compilation.\n *\n * Two independent checks per story, neither of which boots the\n * source-of-truth adapter's own Storybook — the divergence check below\n * compares stored golden files, not live pages:\n *\n * 1. **Own-drift (hard fail):** this run's live render vs this project's own\n * stored `test/golden/<story-id>.png`. No golden yet for a story is not a\n * failure — one is captured from this run instead (same as\n * `--update-golden`, scoped to just that story).\n * 2. **Source-of-truth divergence (soft flag, never fails the run):** this\n * project's own golden vs the source-of-truth's golden (`config`'s\n * `sourceOfTruthGolden`). Skipped entirely when\n * `config.isSourceOfTruthAdapter` is true — the source-of-truth adapter\n * has nothing above it to diverge from — and skipped per-story when\n * neither side has a baseline yet. A once-flagged divergence stays quiet\n * after `--approve-divergence`, until the source of truth's own golden\n * changes again.\n */\nexport async function runVisualRegression(\n config: AdapterTesterConfig,\n): Promise<void> {\n const ownTarget = config.isSourceOfTruthAdapter\n ? config.targets[0]\n : config.targets.find((target) => !target.sourceOfTruth);\n if (!ownTarget) {\n throw new Error(\n \"adapter-tester config has no non-sourceOfTruth target to run the golden check against.\",\n );\n }\n const excludeTitlePrefixes =\n config.excludeTitlePrefixes ?? DEFAULT_EXCLUDE_TITLE_PREFIXES;\n const excludeStoryIds = config.excludeStoryIds ?? [];\n const storyThresholds = config.storyThresholds ?? {};\n const goldenDir = config.goldenDir;\n const goldenMode = config.goldenMode;\n\n const stories = await fetchStories(\n ownTarget,\n excludeTitlePrefixes,\n excludeStoryIds,\n );\n const manifest = loadManifest(goldenDir);\n\n const sourceOfTruthGolden =\n config.isSourceOfTruthAdapter || !config.sourceOfTruthGolden\n ? null\n : await resolveSourceOfTruthGolden(config.sourceOfTruthGolden);\n\n test.describe(`${ownTarget.name} — Golden Image Visual Regression`, () => {\n for (const story of stories) {\n test(`Golden regression for: ${story.title} - ${story.name} (${story.id})`, async ({\n browser,\n }, testInfo) => {\n const page = await browser.newPage();\n await page.setViewportSize({ width: 800, height: 600 });\n await page.goto(\n `${ownTarget.url}/iframe.html?id=${story.id}&viewMode=story`,\n { waitUntil: \"networkidle\" },\n );\n await page.waitForSelector(\"#storybook-root\");\n await page.waitForTimeout(300);\n const liveBuffer = await page.screenshot();\n\n const imagePath = goldenImagePath(goldenDir, story.id);\n const entry = manifest[story.id];\n const capturingNewGolden =\n goldenMode !== \"check\" || !entry || !existsSync(imagePath);\n\n if (capturingNewGolden) {\n saveGoldenImage(goldenDir, story.id, liveBuffer);\n manifest[story.id] = entry?.sourceOfTruthCreatedAt\n ? {\n createdAt: new Date().toISOString(),\n sourceOfTruthCreatedAt: entry.sourceOfTruthCreatedAt,\n }\n : { createdAt: new Date().toISOString() };\n if (!entry) {\n testInfo.annotations.push({\n type: \"golden-created\",\n description: `No golden existed yet for \"${story.id}\" — captured one from this run.`,\n });\n }\n } else {\n const goldenBuffer = readFileSync(imagePath);\n const diffPixels = diffPngBuffers(liveBuffer, goldenBuffer);\n await testInfo.attach(\"Live vs Golden Diff\", {\n body: `${diffPixels} mismatched pixels`,\n contentType: \"text/plain\",\n });\n const threshold = resolveThreshold(\n story.id,\n storyThresholds,\n config.diffThresholdPixels,\n );\n expect\n .soft(\n diffPixels,\n `\"${story.id}\" has drifted from its own golden image`,\n )\n .toBeLessThan(threshold);\n }\n\n // Guaranteed set by the branch above — either just captured, or\n // already present since !capturingNewGolden implies `entry` existed.\n const currentEntry = manifest[story.id]!;\n\n if (sourceOfTruthGolden) {\n const sourceOfTruthEntry = sourceOfTruthGolden.manifest[story.id];\n const sourceOfTruthImage = sourceOfTruthEntry\n ? await sourceOfTruthGolden.readImage(story.id)\n : null;\n\n if (sourceOfTruthEntry && sourceOfTruthImage) {\n if (goldenMode === \"approve-divergence\") {\n manifest[story.id] = {\n ...currentEntry,\n sourceOfTruthCreatedAt: sourceOfTruthEntry.createdAt,\n };\n } else {\n const ownImage = readFileSync(imagePath);\n const diffPixels = diffPngBuffers(ownImage, sourceOfTruthImage);\n const approvedAt = currentEntry.sourceOfTruthCreatedAt;\n const isKnownDivergence =\n diffPixels === 0 ||\n (approvedAt !== undefined &&\n approvedAt >= sourceOfTruthEntry.createdAt);\n if (!isKnownDivergence) {\n testInfo.annotations.push({\n type: \"source-of-truth-divergence\",\n description: `\"${story.id}\" differs from the source of truth's golden and hasn't been reviewed — run with --approve-divergence if this is intentional, or fix the adapter styling.`,\n });\n }\n }\n }\n }\n\n // Playwright runs this generated spec with workers: 1 specifically so\n // this read-modify-write is never racing another story's test body.\n saveManifest(goldenDir, manifest);\n });\n }\n });\n}\n"],"names":["diffPngBuffers","a","b","imgA","PNG","imgB","diff","pixelmatch","addFormats","ajvFormatsModule.default","ajv","Ajv","validate","schema","validateGoldenManifest","data","path","errors","error","extra","_a","manifestPath","goldenDir","join","goldenImagePath","storyId","loadManifest","existsSync","readFileSync","saveManifest","manifest","sorted","key","mkdirSync","writeFileSync","saveGoldenImage","buffer","GITHUB_REPO","resolveNpmVersion","packageName","versionSpec","response","resolved","_b","packageDirectory","resolveSourceOfTruthGolden","location","version","cacheDir","tag","rawBase","cachedManifestPath","text","parsed","cachedImagePath","DEFAULT_EXCLUDE_TITLE_PREFIXES","matchesPrefix","id","prefix","fetchStories","target","excludeTitlePrefixes","excludeStoryIds","stories","entries","entry","resolveThreshold","storyThresholds","diffThresholdPixels","bestMatch","runVisualRegression","config","ownTarget","goldenMode","sourceOfTruthGolden","test","story","browser","testInfo","page","liveBuffer","imagePath","goldenBuffer","diffPixels","threshold","expect","currentEntry","sourceOfTruthEntry","sourceOfTruthImage","ownImage","approvedAt"],"mappings":";;;;;;AASO,SAASA,EAAeC,GAAWC,GAAmB;AAC3D,QAAMC,IAAOC,EAAI,KAAK,KAAKH,CAAC,GACtBI,IAAOD,EAAI,KAAK,KAAKF,CAAC;AAC5B,MAAIC,EAAK,UAAUE,EAAK,SAASF,EAAK,WAAWE,EAAK;AACpD,WAAO;AAET,QAAMC,IAAO,IAAIF,EAAI,EAAE,OAAOD,EAAK,OAAO,QAAQA,EAAK,QAAQ;AAC/D,SAAOI,EAAWJ,EAAK,MAAME,EAAK,MAAMC,EAAK,MAAMH,EAAK,OAAOA,EAAK,QAAQ;AAAA,IAC1E,WAAW;AAAA,EAAA,CACZ;AACH;;;;;;;;GCbMK,IAAcC,GAGdC,IAAM,IAAIC,EAAAA,IAAI,EAAE,WAAW,IAAM,QAAQ,IAAM;AACrDH,EAAWE,CAAG;AACd,MAAME,IAAWF,EAAI,QAAQG,CAAM;AAO5B,SAASC,EAAuBC,GAAeC,GAAoB;AACxE,MAAIJ,EAASG,CAAI,EAAG;AAEpB,QAAME,KAAUL,EAAS,UAAU,CAAA,GAChC,IAAI,CAACM,MAAU;;AACd,UAAMC,KAAQC,IAAAF,EAAM,WAAN,QAAAE,EAAc,qBACxB,KAAKF,EAAM,OAAO,kBAAkB,MACpC;AACJ,WAAO,OAAOA,EAAM,gBAAgB,MAAM,IAAIA,EAAM,OAAO,GAAGC,CAAK;AAAA,EACrE,CAAC,EACA,KAAK;AAAA,CAAI;AACZ,QAAM,IAAI,MAAM,WAAWH,CAAI;AAAA,EAAMC,CAAM,EAAE;AAC/C;ACnBO,SAASI,EAAaC,GAA2B;AACtD,SAAOC,EAAKD,GAAW,eAAe;AACxC;AAEO,SAASE,EAAgBF,GAAmBG,GAAyB;AAC1E,SAAOF,EAAKD,GAAW,GAAGG,CAAO,MAAM;AACzC;AAIO,SAASC,EAAaJ,GAAmC;AAC9D,QAAMN,IAAOK,EAAaC,CAAS;AACnC,MAAI,CAACK,EAAWX,CAAI,UAAU,CAAA;AAC9B,QAAMD,IAAO,KAAK,MAAMa,EAAaZ,GAAM,MAAM,CAAC;AAClD,SAAAF,EAAuBC,GAAMC,CAAI,GAC1BD;AACT;AAIO,SAASc,EACdP,GACAQ,GACM;AACN,QAAMd,IAAOK,EAAaC,CAAS;AACnC,EAAAR,EAAuBgB,GAAUd,CAAI;AACrC,QAAMe,IAAyB,CAAA;AAC/B,aAAWC,KAAO,OAAO,KAAKF,CAAQ,EAAE;AACtC,IAAAC,EAAOC,CAAG,IAAIF,EAASE,CAAG;AAE5B,EAAAC,EAAUX,GAAW,EAAE,WAAW,GAAA,CAAM,GACxCY,EAAclB,GAAM,KAAK,UAAUe,GAAQ,MAAM,CAAC,IAAI;AAAA,CAAI;AAC5D;AAEO,SAASI,EACdb,GACAG,GACAW,GACM;AACN,EAAAH,EAAUX,GAAW,EAAE,WAAW,GAAA,CAAM,GACxCY,EAAcV,EAAgBF,GAAWG,CAAO,GAAGW,CAAM;AAC3D;ACzCA,MAAMC,IAAc;AASpB,eAAeC,EACbC,GACAC,GACiB;;AACjB,QAAMC,IAAW,MAAM,MAAM,8BAA8BF,CAAW,EAAE;AACxE,MAAI,CAACE,EAAS;AACZ,UAAM,IAAI;AAAA,MACR,oCAAoCF,CAAW,KAAKE,EAAS,UAAU;AAAA,IAAA;AAG3E,QAAM1B,IAAQ,MAAM0B,EAAS,KAAA,GAIvBC,MACJtB,IAAAL,EAAK,WAAW,MAAhB,gBAAAK,EAAoBoB,SACnBG,IAAA5B,EAAK,aAAL,QAAA4B,EAAgBH,KAAeA,IAAc;AAChD,MAAI,CAACE;AACH,UAAM,IAAI;AAAA,MACR,GAAGH,CAAW,gCAAgCC,CAAW;AAAA,IAAA;AAG7D,SAAOE;AACT;AAKA,SAASE,GAAiBL,GAA6B;AACrD,SAAO,YAAYA,EAAY,MAAM,GAAG,EAAE,KAAK;AACjD;AAqBA,eAAsBM,GACpBC,GACqC;AACrC,MAAIA,EAAS,SAAS;AACpB,WAAKnB,EAAWN,EAAayB,EAAS,GAAG,CAAC,IAOnC;AAAA,MACL,UAFepB,EAAaoB,EAAS,GAAG;AAAA,MAGxC,MAAM,UAAUrB,GAAS;AACvB,cAAMT,IAAOQ,EAAgBsB,EAAS,KAAKrB,CAAO;AAClD,eAAOE,EAAWX,CAAI,IAAIY,EAAaZ,CAAI,IAAI;AAAA,MACjD;AAAA,IAAA,KAXA,QAAQ;AAAA,MACN,mCAAmC8B,EAAS,GAAG;AAAA,IAAA,GAE1C;AAYX,MAAIC;AACJ,MAAI;AACF,IAAAA,IAAU,MAAMT;AAAA,MACdQ,EAAS;AAAA,MACTA,EAAS;AAAA,IAAA;AAAA,EAEb,SAAS5B,GAAO;AACd,mBAAQ;AAAA,MACN,qBAAqB4B,EAAS,WAAW,IAAIA,EAAS,WAAW;AAAA,MACjE5B;AAAA,IAAA,GAEK;AAAA,EACT;AAEA,QAAM8B,IAAWzB,EAAKuB,EAAS,UAAUC,CAAO,GAC1CE,IAAM,GAAGH,EAAS,WAAW,IAAIC,CAAO,IACxCG,IAAU,qCAAqCb,CAAW,IAAIY,CAAG,IAAIL,GAAiBE,EAAS,WAAW,CAAC;AAEjH,MAAIhB;AACJ,QAAMqB,IAAqB9B,EAAa2B,CAAQ;AAChD,MAAIrB,EAAWwB,CAAkB;AAC/B,IAAArB,IAAWJ,EAAasB,CAAQ;AAAA,OAC3B;AACL,QAAIP;AACJ,QAAI;AACF,MAAAA,IAAW,MAAM,MAAM,GAAGS,CAAO,gBAAgB;AAAA,IACnD,SAAShC,GAAO;AACd,qBAAQ;AAAA,QACN,mCAAmC+B,CAAG;AAAA,QACtC/B;AAAA,MAAA,GAEK;AAAA,IACT;AACA,QAAI,CAACuB,EAAS;AACZ,qBAAQ;AAAA,QACN,oCAAoCQ,CAAG;AAAA,MAAA,GAElC;AAET,UAAMG,IAAO,MAAMX,EAAS,KAAA,GACtBY,IAAS,KAAK,MAAMD,CAAI;AAC9B,IAAAtC,EAAuBuC,GAAQ,GAAGH,CAAO,gBAAgB,GACzDjB,EAAUe,GAAU,EAAE,WAAW,GAAA,CAAM,GACvCd,EAAciB,GAAoBC,CAAI,GACtCtB,IAAWuB;AAAA,EACb;AAEA,SAAO;AAAA,IACL,UAAAvB;AAAA,IACA,MAAM,UAAUL,GAAS;AACvB,YAAM6B,IAAkB9B,EAAgBwB,GAAUvB,CAAO;AACzD,UAAIE,EAAW2B,CAAe,EAAG,QAAO1B,EAAa0B,CAAe;AACpE,YAAMb,IAAW,MAAM,MAAM,GAAGS,CAAO,IAAIzB,CAAO,MAAM;AACxD,UAAI,CAACgB,EAAS,GAAI,QAAO;AACzB,YAAML,IAAS,OAAO,KAAK,MAAMK,EAAS,aAAa;AACvD,aAAAR,EAAUe,GAAU,EAAE,WAAW,GAAA,CAAM,GACvCd,EAAcoB,GAAiBlB,CAAM,GAC9BA;AAAA,IACT;AAAA,EAAA;AAEJ;AC3IA,MAAMmB,KAAiC,CAAC,SAAS,UAAU,cAAc;AASzE,SAASC,EAAcC,GAAYC,GAAyB;AAC1D,SAAOD,MAAOC,KAAUD,EAAG,WAAWC,CAAM;AAC9C;AAEA,eAAeC,GACbC,GACAC,GACAC,GAC2B;AAC3B,MAAIC;AACJ,MAAI;AACF,UAAMtB,IAAW,MAAM,MAAM,GAAGmB,EAAO,GAAG,aAAa;AACvD,QAAI,CAACnB,EAAS;AACZ,YAAM,IAAI;AAAA,QACR,oCAAoCA,EAAS,UAAU;AAAA,MAAA;AAI3D,UAAMuB,KADQ,MAAMvB,EAAS,KAAA,GACR,WAAW,CAAA;AAChC,IAAAsB,IAAU,OAAO,OAAOC,CAAO,EAAE;AAAA,MAC/B,CAACC,MACCA,EAAM,SAAS,WACf,CAACJ,EAAqB;AAAA,QACpB,CAACH,MACCO,EAAM,UAAUP,KAAUO,EAAM,MAAM,WAAW,GAAGP,CAAM,GAAG;AAAA,MAAA,KAEjE,CAACI,EAAgB,KAAK,CAACJ,MAAWF,EAAcS,EAAM,IAAIP,CAAM,CAAC;AAAA,IAAA,GAErEK,EAAQ,KAAK,CAAC,GAAG7D,MAAM,EAAE,GAAG,cAAcA,EAAE,EAAE,CAAC;AAAA,EACjD,SAASgB,GAAO;AACd,kBAAQ;AAAA,MACN;AAAA,MACA,GAAG0C,EAAO,GAAG;AAAA,MACb1C;AAAA,IAAA,GAEI,IAAI;AAAA,MACR,qBAAqB0C,EAAO,IAAI;AAAA,IAAA;AAAA,EAEpC;AACA,SAAOG;AACT;AAKA,SAASG,GACPzC,GACA0C,GACAC,GACQ;AACR,MAAIC;AACJ,aAAWX,KAAU,OAAO,KAAKS,CAAe;AAC9C,IACEX,EAAc/B,GAASiC,CAAM,MAC5B,CAACW,KAAaX,EAAO,SAASW,EAAU,YAEzCA,IAAYX;AAGhB,SAAOW,MAAc,SACjBF,EAAgBE,CAAS,IACzBD;AACN;AA0BA,eAAsBE,GACpBC,GACe;AACf,QAAMC,IAAYD,EAAO,yBACrBA,EAAO,QAAQ,CAAC,IAChBA,EAAO,QAAQ,KAAK,CAACX,MAAW,CAACA,EAAO,aAAa;AACzD,MAAI,CAACY;AACH,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAGJ,QAAMX,IACJU,EAAO,wBAAwBhB,IAC3BO,IAAkBS,EAAO,mBAAmB,CAAA,GAC5CJ,IAAkBI,EAAO,mBAAmB,CAAA,GAC5CjD,IAAYiD,EAAO,WACnBE,IAAaF,EAAO,YAEpBR,IAAU,MAAMJ;AAAA,IACpBa;AAAA,IACAX;AAAA,IACAC;AAAA,EAAA,GAEIhC,IAAWJ,EAAaJ,CAAS,GAEjCoD,IACJH,EAAO,0BAA0B,CAACA,EAAO,sBACrC,OACA,MAAM1B,GAA2B0B,EAAO,mBAAmB;AAEjE,EAAAI,EAAK,SAAS,GAAGH,EAAU,IAAI,qCAAqC,MAAM;AACxE,eAAWI,KAASb;AAClB,MAAAY,EAAK,0BAA0BC,EAAM,KAAK,MAAMA,EAAM,IAAI,KAAKA,EAAM,EAAE,KAAK,OAAO;AAAA,QACjF,SAAAC;AAAA,MAAA,GACCC,MAAa;AACd,cAAMC,IAAO,MAAMF,EAAQ,QAAA;AAC3B,cAAME,EAAK,gBAAgB,EAAE,OAAO,KAAK,QAAQ,KAAK,GACtD,MAAMA,EAAK;AAAA,UACT,GAAGP,EAAU,GAAG,mBAAmBI,EAAM,EAAE;AAAA,UAC3C,EAAE,WAAW,cAAA;AAAA,QAAc,GAE7B,MAAMG,EAAK,gBAAgB,iBAAiB,GAC5C,MAAMA,EAAK,eAAe,GAAG;AAC7B,cAAMC,IAAa,MAAMD,EAAK,WAAA,GAExBE,IAAYzD,EAAgBF,GAAWsD,EAAM,EAAE,GAC/CX,IAAQnC,EAAS8C,EAAM,EAAE;AAI/B,YAFEH,MAAe,WAAW,CAACR,KAAS,CAACtC,EAAWsD,CAAS;AAGzD,UAAA9C,EAAgBb,GAAWsD,EAAM,IAAII,CAAU,GAC/ClD,EAAS8C,EAAM,EAAE,IAAIX,KAAA,QAAAA,EAAO,yBACxB;AAAA,YACE,YAAW,oBAAI,KAAA,GAAO,YAAA;AAAA,YACtB,wBAAwBA,EAAM;AAAA,UAAA,IAEhC,EAAE,gCAAe,KAAA,GAAO,cAAY,GACnCA,KACHa,EAAS,YAAY,KAAK;AAAA,YACxB,MAAM;AAAA,YACN,aAAa,8BAA8BF,EAAM,EAAE;AAAA,UAAA,CACpD;AAAA,aAEE;AACL,gBAAMM,IAAetD,EAAaqD,CAAS,GACrCE,IAAanF,EAAegF,GAAYE,CAAY;AAC1D,gBAAMJ,EAAS,OAAO,uBAAuB;AAAA,YAC3C,MAAM,GAAGK,CAAU;AAAA,YACnB,aAAa;AAAA,UAAA,CACd;AACD,gBAAMC,IAAYlB;AAAA,YAChBU,EAAM;AAAA,YACNT;AAAA,YACAI,EAAO;AAAA,UAAA;AAET,UAAAc,EACG;AAAA,YACCF;AAAA,YACA,IAAIP,EAAM,EAAE;AAAA,UAAA,EAEb,aAAaQ,CAAS;AAAA,QAC3B;AAIA,cAAME,IAAexD,EAAS8C,EAAM,EAAE;AAEtC,YAAIF,GAAqB;AACvB,gBAAMa,IAAqBb,EAAoB,SAASE,EAAM,EAAE,GAC1DY,IAAqBD,IACvB,MAAMb,EAAoB,UAAUE,EAAM,EAAE,IAC5C;AAEJ,cAAIW,KAAsBC;AACxB,gBAAIf,MAAe;AACjB,cAAA3C,EAAS8C,EAAM,EAAE,IAAI;AAAA,gBACnB,GAAGU;AAAA,gBACH,wBAAwBC,EAAmB;AAAA,cAAA;AAAA,iBAExC;AACL,oBAAME,IAAW7D,EAAaqD,CAAS,GACjCE,IAAanF,EAAeyF,GAAUD,CAAkB,GACxDE,IAAaJ,EAAa;AAKhC,cAHEH,MAAe,KACdO,MAAe,UACdA,KAAcH,EAAmB,aAEnCT,EAAS,YAAY,KAAK;AAAA,gBACxB,MAAM;AAAA,gBACN,aAAa,IAAIF,EAAM,EAAE;AAAA,cAAA,CAC1B;AAAA,YAEL;AAAA,QAEJ;AAIA,QAAA/C,EAAaP,GAAWQ,CAAQ;AAAA,MAClC,CAAC;AAAA,EAEL,CAAC;AACH;"}
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/adapter-tester"
15
15
  },
16
- "version": "2.2.0",
16
+ "version": "3.0.0",
17
17
  "type": "module",
18
18
  "main": "./dist/index.cjs",
19
19
  "module": "./dist/index.js",
@@ -34,7 +34,8 @@
34
34
  "dist",
35
35
  "public",
36
36
  "report-header.txt",
37
- "src/adapter-tester.schema.json"
37
+ "src/adapter-tester.schema.json",
38
+ "src/golden/manifest.schema.json"
38
39
  ],
39
40
  "keywords": [
40
41
  "recursica",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "name": {
14
14
  "type": "string",
15
- "description": "Label for this project's own target. Defaults to the unscoped name in this project's package.json (e.g. \"@recursica/mui-adapter\" -> \"mui-adapter\")."
15
+ "description": "Label for this project's own target. Defaults to the unscoped name in this project's package.json (e.g. \"@recursica/some-adapter\" -> \"some-adapter\")."
16
16
  },
17
17
  "storybook": {
18
18
  "type": "object",
@@ -89,25 +89,29 @@
89
89
  }
90
90
  ]
91
91
  },
92
+ "isSourceOfTruthAdapter": {
93
+ "type": "boolean",
94
+ "description": "True only for the source-of-truth adapter's own config (mantine-adapter). Skips `sourceOfTruth` entirely — there's nothing above it to diverge from — and runs the own-drift golden check standalone, against just this project's own Storybook. Defaults to false."
95
+ },
92
96
  "diffThresholdPixels": {
93
97
  "type": "number",
94
98
  "minimum": 0,
95
99
  "description": "Global visual diff threshold, in mismatched pixels, before a story is considered a failure. Defaults to 3500. AI agents must not modify this field — see AGENT.md."
96
100
  },
97
- "relaxedThresholdStoryIds": {
98
- "type": "array",
99
- "items": { "type": "string" },
100
- "description": "Story id prefixes that use `relaxedThresholdPixels` instead of the global `diffThresholdPixels`."
101
- },
102
- "relaxedThresholdPixels": {
103
- "type": "number",
104
- "minimum": 0,
105
- "description": "Diff threshold applied to stories matching `relaxedThresholdStoryIds`."
101
+ "storyThresholds": {
102
+ "type": "object",
103
+ "additionalProperties": { "type": "number", "minimum": 0 },
104
+ "description": "Per-story diff threshold overrides, keyed by story id prefix (a story matches if its id equals the key or starts with it). Overrides `diffThresholdPixels` for matching stories. When more than one key matches a story, the longest (most specific) key wins."
106
105
  },
107
106
  "excludeTitlePrefixes": {
108
107
  "type": "array",
109
108
  "items": { "type": "string" },
110
109
  "description": "Storybook title prefixes to exclude from comparison, in addition to the built-in denylist (`Theme/*`, `Tokens/*`, `Introduction`)."
110
+ },
111
+ "excludeStoryIds": {
112
+ "type": "array",
113
+ "items": { "type": "string" },
114
+ "description": "Story id prefixes to skip entirely — no own-drift check, no divergence check, no golden captured. For excluding individual stories that don't have a cross-adapter counterpart; use `excludeTitlePrefixes` to drop a whole title instead."
111
115
  }
112
116
  }
113
117
  }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/borderux/recursica/tree/main/packages/adapter-tester/src/golden/manifest.schema.json",
4
+ "title": "test/golden/manifest.json",
5
+ "description": "Tracks golden (baseline) image metadata for @recursica/adapter-tester's golden-image visual regression checks. One manifest lives alongside its adapter's test/golden/<story-id>.png files, keyed by story id.",
6
+ "type": "object",
7
+ "additionalProperties": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "required": ["createdAt"],
11
+ "properties": {
12
+ "createdAt": {
13
+ "type": "string",
14
+ "format": "date-time",
15
+ "description": "When this story's own golden PNG was last captured, via --update-golden, --approve-divergence, or first-run auto-create."
16
+ },
17
+ "sourceOfTruthCreatedAt": {
18
+ "type": "string",
19
+ "format": "date-time",
20
+ "description": "The source-of-truth adapter's (mantine) manifest `createdAt` for this story at the time this adapter's divergence from it was last reviewed via --approve-divergence. Omitted on the source-of-truth adapter's own manifest, and omitted here until the first approval. If mantine's current `createdAt` for this story is newer than this value, the divergence is flagged again for re-review."
21
+ }
22
+ }
23
+ }
24
+ }
@@ -1,2 +0,0 @@
1
- "use strict";function u(t){const r=t.targets.filter(e=>e.sourceOfTruth).length;if(r!==1)throw new Error(`adapter-tester config must mark exactly one target as sourceOfTruth (found ${r})`);return t}function o(t){const r=t.targets.find(e=>e.sourceOfTruth);if(!r)throw new Error("adapter-tester config has no target marked sourceOfTruth");return r}exports.defineAdapterTesterConfig=u;exports.getSourceOfTruth=o;
2
- //# sourceMappingURL=config-B0Eop8Az.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"config-B0Eop8Az.cjs","sources":["../src/config.ts"],"sourcesContent":["export interface AdapterTarget {\n /** Human-readable name used in test titles, report labels, and artifact filenames. */\n name: string;\n /** Base URL the target's Storybook instance is served from. */\n url: string;\n /**\n * Marks this target as the visual/token source of truth every other target\n * is compared against. Exactly one target in a config must set this true.\n */\n sourceOfTruth?: boolean;\n}\n\nexport interface AdapterTesterConfig {\n targets: AdapterTarget[];\n /** Global pixel-diff threshold applied to every story comparison. */\n diffThresholdPixels: number;\n /**\n * Story ids (matched by prefix) that get `relaxedThresholdPixels` instead\n * of `diffThresholdPixels`, for components with acceptable cross-library\n * structural variation (e.g. native control widgets).\n */\n relaxedThresholdStoryIds?: string[];\n /** Pixel-diff threshold applied to `relaxedThresholdStoryIds`. */\n relaxedThresholdPixels?: number;\n /**\n * Storybook entry title categories excluded from the comparison — an\n * entry is excluded if its title equals one of these or starts with\n * `\"<value>/\"`. Defaults to [\"Theme\", \"Tokens\", \"Introduction\"]:\n * `@recursica/storybook-template`'s own default token/theme demo stories\n * (which every adapter's Storybook inherits automatically) plus each\n * adapter's own onboarding \"Introduction\" stories — neither has a\n * cross-adapter counterpart to diff against.\n */\n excludeTitlePrefixes?: string[];\n}\n\nexport function defineAdapterTesterConfig(\n config: AdapterTesterConfig,\n): AdapterTesterConfig {\n const sourceOfTruthCount = config.targets.filter(\n (target) => target.sourceOfTruth,\n ).length;\n if (sourceOfTruthCount !== 1) {\n throw new Error(\n `adapter-tester config must mark exactly one target as sourceOfTruth (found ${sourceOfTruthCount})`,\n );\n }\n return config;\n}\n\nexport function getSourceOfTruth(config: AdapterTesterConfig): AdapterTarget {\n const sourceOfTruth = config.targets.find((target) => target.sourceOfTruth);\n if (!sourceOfTruth) {\n throw new Error(\"adapter-tester config has no target marked sourceOfTruth\");\n }\n return sourceOfTruth;\n}\n"],"names":["defineAdapterTesterConfig","config","sourceOfTruthCount","target","getSourceOfTruth","sourceOfTruth"],"mappings":"aAoCO,SAASA,EACdC,EACqB,CACrB,MAAMC,EAAqBD,EAAO,QAAQ,OACvCE,GAAWA,EAAO,aAAA,EACnB,OACF,GAAID,IAAuB,EACzB,MAAM,IAAI,MACR,8EAA8EA,CAAkB,GAAA,EAGpG,OAAOD,CACT,CAEO,SAASG,EAAiBH,EAA4C,CAC3E,MAAMI,EAAgBJ,EAAO,QAAQ,KAAME,GAAWA,EAAO,aAAa,EAC1E,GAAI,CAACE,EACH,MAAM,IAAI,MAAM,0DAA0D,EAE5E,OAAOA,CACT"}
@@ -1,21 +0,0 @@
1
- function o(t) {
2
- const r = t.targets.filter(
3
- (e) => e.sourceOfTruth
4
- ).length;
5
- if (r !== 1)
6
- throw new Error(
7
- `adapter-tester config must mark exactly one target as sourceOfTruth (found ${r})`
8
- );
9
- return t;
10
- }
11
- function u(t) {
12
- const r = t.targets.find((e) => e.sourceOfTruth);
13
- if (!r)
14
- throw new Error("adapter-tester config has no target marked sourceOfTruth");
15
- return r;
16
- }
17
- export {
18
- o as d,
19
- u as g
20
- };
21
- //# sourceMappingURL=config-CDxTeSAY.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"config-CDxTeSAY.js","sources":["../src/config.ts"],"sourcesContent":["export interface AdapterTarget {\n /** Human-readable name used in test titles, report labels, and artifact filenames. */\n name: string;\n /** Base URL the target's Storybook instance is served from. */\n url: string;\n /**\n * Marks this target as the visual/token source of truth every other target\n * is compared against. Exactly one target in a config must set this true.\n */\n sourceOfTruth?: boolean;\n}\n\nexport interface AdapterTesterConfig {\n targets: AdapterTarget[];\n /** Global pixel-diff threshold applied to every story comparison. */\n diffThresholdPixels: number;\n /**\n * Story ids (matched by prefix) that get `relaxedThresholdPixels` instead\n * of `diffThresholdPixels`, for components with acceptable cross-library\n * structural variation (e.g. native control widgets).\n */\n relaxedThresholdStoryIds?: string[];\n /** Pixel-diff threshold applied to `relaxedThresholdStoryIds`. */\n relaxedThresholdPixels?: number;\n /**\n * Storybook entry title categories excluded from the comparison — an\n * entry is excluded if its title equals one of these or starts with\n * `\"<value>/\"`. Defaults to [\"Theme\", \"Tokens\", \"Introduction\"]:\n * `@recursica/storybook-template`'s own default token/theme demo stories\n * (which every adapter's Storybook inherits automatically) plus each\n * adapter's own onboarding \"Introduction\" stories — neither has a\n * cross-adapter counterpart to diff against.\n */\n excludeTitlePrefixes?: string[];\n}\n\nexport function defineAdapterTesterConfig(\n config: AdapterTesterConfig,\n): AdapterTesterConfig {\n const sourceOfTruthCount = config.targets.filter(\n (target) => target.sourceOfTruth,\n ).length;\n if (sourceOfTruthCount !== 1) {\n throw new Error(\n `adapter-tester config must mark exactly one target as sourceOfTruth (found ${sourceOfTruthCount})`,\n );\n }\n return config;\n}\n\nexport function getSourceOfTruth(config: AdapterTesterConfig): AdapterTarget {\n const sourceOfTruth = config.targets.find((target) => target.sourceOfTruth);\n if (!sourceOfTruth) {\n throw new Error(\"adapter-tester config has no target marked sourceOfTruth\");\n }\n return sourceOfTruth;\n}\n"],"names":["defineAdapterTesterConfig","config","sourceOfTruthCount","target","getSourceOfTruth","sourceOfTruth"],"mappings":"AAoCO,SAASA,EACdC,GACqB;AACrB,QAAMC,IAAqBD,EAAO,QAAQ;AAAA,IACxC,CAACE,MAAWA,EAAO;AAAA,EAAA,EACnB;AACF,MAAID,MAAuB;AACzB,UAAM,IAAI;AAAA,MACR,8EAA8EA,CAAkB;AAAA,IAAA;AAGpG,SAAOD;AACT;AAEO,SAASG,EAAiBH,GAA4C;AAC3E,QAAMI,IAAgBJ,EAAO,QAAQ,KAAK,CAACE,MAAWA,EAAO,aAAa;AAC1E,MAAI,CAACE;AACH,UAAM,IAAI,MAAM,0DAA0D;AAE5E,SAAOA;AACT;"}