@sdods/core 0.8.0 → 0.9.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 (52) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/a11y/audit.d.ts +88 -0
  3. package/dist/a11y/audit.js +134 -0
  4. package/dist/config/defaults.js +2 -0
  5. package/dist/config/resolve.js +1 -1
  6. package/dist/data/index.d.ts +1 -0
  7. package/dist/data/index.js +1 -0
  8. package/dist/data/pool-capacity.d.ts +58 -0
  9. package/dist/data/pool-capacity.js +138 -0
  10. package/dist/data/user-pool.d.ts +43 -2
  11. package/dist/data/user-pool.js +170 -40
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/fixtures/scenario.d.ts +11 -0
  14. package/dist/fixtures/scenario.js +15 -0
  15. package/dist/fixtures/test.js +24 -3
  16. package/dist/fixtures/types.d.ts +2 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/matrix/index.d.ts +13 -0
  20. package/dist/matrix/index.js +24 -3
  21. package/dist/perf/vitals.d.ts +118 -0
  22. package/dist/perf/vitals.js +236 -0
  23. package/dist/quality/a11y-scenario.d.ts +68 -0
  24. package/dist/quality/a11y-scenario.js +63 -0
  25. package/dist/quality/gates.d.ts +54 -0
  26. package/dist/quality/gates.js +144 -0
  27. package/dist/quality/hooks.d.ts +2 -0
  28. package/dist/quality/hooks.js +38 -0
  29. package/dist/quality/index.d.ts +4 -0
  30. package/dist/quality/index.js +2 -0
  31. package/dist/quality/perf-scenario.d.ts +132 -0
  32. package/dist/quality/perf-scenario.js +241 -0
  33. package/dist/shots/baselines.d.ts +72 -0
  34. package/dist/shots/baselines.js +201 -0
  35. package/dist/shots/hooks.js +2 -2
  36. package/dist/shots/index.d.ts +1 -0
  37. package/dist/shots/index.js +1 -0
  38. package/dist/shots/narrator.d.ts +10 -2
  39. package/dist/shots/narrator.js +106 -10
  40. package/dist/shots/policy.d.ts +5 -1
  41. package/dist/shots/policy.js +2 -0
  42. package/dist/steps/a11y.steps.d.ts +18 -51
  43. package/dist/steps/a11y.steps.js +6 -107
  44. package/dist/steps/data.steps.js +17 -5
  45. package/dist/steps/hooks.steps.d.ts +1 -0
  46. package/dist/steps/hooks.steps.js +3 -1
  47. package/dist/steps/index.d.ts +1 -0
  48. package/dist/steps/index.js +1 -0
  49. package/dist/steps/perf.steps.d.ts +3 -129
  50. package/dist/steps/perf.steps.js +4 -227
  51. package/dist/steps/ui.steps.js +5 -0
  52. package/package.json +3 -3
@@ -0,0 +1,132 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { type ApiSnapshot } from '@sdods/contracts';
3
+ import { type BudgetConfig, type VitalsRecording } from '../perf/vitals.js';
4
+ /**
5
+ * The budget check an `@perf` tag buys. Before this existed `perf.budgets` was enforced only by
6
+ * explicit steps, `apiP95Ms` only by a sampling step, and the tag itself by nothing.
7
+ *
8
+ * Two halves, because a page has to be watched while the scenario runs and judged after it:
9
+ *
10
+ * · `watchNavigations` (UI and hybrid, before the scenario) installs an init script in the page.
11
+ * Every document the main frame loads reports its own vitals through an exposed binding, once
12
+ * the load event has finished. The document reports itself rather than being read from Node on
13
+ * a `load` event, because a Node-side read races the next navigation: a step that clicks a link
14
+ * straight after the load would have its read land in the NEXT document, whose load has not
15
+ * ended, and report a slow page that does not exist.
16
+ * · `judgeScenarioPerf` (every layer, after the scenario) compares every recorded navigation with
17
+ * the page budgets (`pageLoadMs`, `lcpMs`, `fcpMs`, `ttfbMs`), and the p95 of the scenario's live
18
+ * API response times (plus any latency samples it took) with `apiP95Ms`.
19
+ *
20
+ * The rules are the explicit steps' rules. A vital a budget applies to that did not fire FAILS,
21
+ * naming why. An `@perf` scenario on an environment with no budgets at all FAILS as CONFIG_INVALID.
22
+ * A scenario where no configured budget applied to anything it measured FAILS: it proved nothing.
23
+ * An API call replayed from a HAR is not a measurement of this environment and is not counted.
24
+ *
25
+ * One thing is NOT a failure: a document the scenario left before it could report (a navigation
26
+ * away before the document painted and its LCP window closed). That is not the application being slow; it is
27
+ * listed under `unreported` in the attachment so the gap is visible.
28
+ */
29
+ /**
30
+ * How long a document waits, after its load event, for its first contentful paint before reporting.
31
+ * A client-rendered page (SauceDemo's login is one) routinely paints AFTER `load`: reading its
32
+ * vitals at the load event reports FCP and LCP as never having fired on a page that painted a
33
+ * moment later. A page that has not painted after this long reports what it has, and a budget on
34
+ * the missing vital then fails naming why.
35
+ */
36
+ export declare const PAINT_WAIT_MS = 5000;
37
+ /**
38
+ * How long the judge waits, at scenario end, for documents that loaded but have not reported: the
39
+ * paint wait plus the LCP settle window, with room for the binding round-trip.
40
+ */
41
+ export declare const REPORT_WAIT_MS: number;
42
+ /** The binding each document reports through. Namespaced so it cannot collide with the app. */
43
+ export declare const VITALS_BINDING = "__sdodsReportVitals";
44
+ /**
45
+ * The script every document in the page runs. Only the top-level document reports: after its load
46
+ * event, once it has painted (or `paintWaitMs` has passed), and after the same LCP settle window the
47
+ * explicit `I record the page vitals` step uses, so both measure a page the same way.
48
+ */
49
+ export declare function vitalsInitScript(settleMs?: number, paintWaitMs?: number, binding?: string): string;
50
+ /**
51
+ * Starts recording the vitals of every document the page's main frame loads. Must run before the
52
+ * scenario's first navigation, which is why the hook that calls it is a `BeforeScenario`.
53
+ */
54
+ export declare function watchNavigations(deps: {
55
+ page: Page;
56
+ scenario: {
57
+ data: {
58
+ browser?: string;
59
+ };
60
+ };
61
+ }): Promise<void>;
62
+ export interface PerfBreach {
63
+ budget: string;
64
+ limitMs: number;
65
+ /** `null` when the value was never measured. */
66
+ measuredMs: number | null;
67
+ where: string;
68
+ message: string;
69
+ }
70
+ /** What `sdods/perf-scenario` contains, and what the `perfBudgets` process gate reads back. */
71
+ export interface PerfScenarioReport {
72
+ kind: 'perf-scenario';
73
+ runnerProject: string;
74
+ fingerprint: string;
75
+ retry: number;
76
+ env: string;
77
+ status: 'judged' | 'skipped';
78
+ reason?: string;
79
+ budgets: Record<string, number>;
80
+ navigations: {
81
+ url: string;
82
+ vitals: VitalsRecording['vitals'];
83
+ unmeasured: VitalsRecording['unmeasured'];
84
+ }[];
85
+ /** Documents that loaded but were left before they could report. Listed, not failed. */
86
+ unreported: number;
87
+ api: {
88
+ liveCalls: number;
89
+ replayedFromHar: number;
90
+ samples: number;
91
+ p95Ms: number | null;
92
+ };
93
+ /** How many budget comparisons were made. Zero means the scenario proved nothing. */
94
+ judged: number;
95
+ breaches: PerfBreach[];
96
+ }
97
+ export interface PerfScenarioDeps {
98
+ config: BudgetConfig;
99
+ scenario: {
100
+ data: {
101
+ runnerProject: string;
102
+ fingerprint: string;
103
+ retry: number;
104
+ layer?: string;
105
+ };
106
+ file(rel: string): string;
107
+ };
108
+ apiContext: {
109
+ history: readonly ApiSnapshot[];
110
+ };
111
+ testInfo: {
112
+ status?: string;
113
+ attach(name: string, opts: {
114
+ path: string;
115
+ contentType: string;
116
+ }): Promise<void>;
117
+ };
118
+ }
119
+ /** Pure: every comparison and breach for a set of recordings and API timings. */
120
+ export declare function judgeBudgets(input: {
121
+ budgets: Record<string, number>;
122
+ recordings: readonly VitalsRecording[];
123
+ apiMs: readonly number[];
124
+ apiReplayed: number;
125
+ envName: string;
126
+ }): {
127
+ judged: number;
128
+ breaches: PerfBreach[];
129
+ p95Ms: number | null;
130
+ };
131
+ export declare function judgeScenarioPerf(deps: PerfScenarioDeps): Promise<PerfScenarioReport>;
132
+ //# sourceMappingURL=perf-scenario.d.ts.map
@@ -0,0 +1,241 @@
1
+ import { attachmentNames, scenarioFiles } from '@sdods/contracts';
2
+ import { writeAndAttachJson } from '../a11y/audit.js';
3
+ import { SdodsError } from '../errors.js';
4
+ import { API_P95_BUDGET_KEY, LCP_SETTLE_MS, LOAD_WAIT_MS, VITAL_KEYS, collectRawVitals, p95Of, perfStore, resolvedBudgets, toRecording, } from '../perf/vitals.js';
5
+ /**
6
+ * The budget check an `@perf` tag buys. Before this existed `perf.budgets` was enforced only by
7
+ * explicit steps, `apiP95Ms` only by a sampling step, and the tag itself by nothing.
8
+ *
9
+ * Two halves, because a page has to be watched while the scenario runs and judged after it:
10
+ *
11
+ * · `watchNavigations` (UI and hybrid, before the scenario) installs an init script in the page.
12
+ * Every document the main frame loads reports its own vitals through an exposed binding, once
13
+ * the load event has finished. The document reports itself rather than being read from Node on
14
+ * a `load` event, because a Node-side read races the next navigation: a step that clicks a link
15
+ * straight after the load would have its read land in the NEXT document, whose load has not
16
+ * ended, and report a slow page that does not exist.
17
+ * · `judgeScenarioPerf` (every layer, after the scenario) compares every recorded navigation with
18
+ * the page budgets (`pageLoadMs`, `lcpMs`, `fcpMs`, `ttfbMs`), and the p95 of the scenario's live
19
+ * API response times (plus any latency samples it took) with `apiP95Ms`.
20
+ *
21
+ * The rules are the explicit steps' rules. A vital a budget applies to that did not fire FAILS,
22
+ * naming why. An `@perf` scenario on an environment with no budgets at all FAILS as CONFIG_INVALID.
23
+ * A scenario where no configured budget applied to anything it measured FAILS: it proved nothing.
24
+ * An API call replayed from a HAR is not a measurement of this environment and is not counted.
25
+ *
26
+ * One thing is NOT a failure: a document the scenario left before it could report (a navigation
27
+ * away before the document painted and its LCP window closed). That is not the application being slow; it is
28
+ * listed under `unreported` in the attachment so the gap is visible.
29
+ */
30
+ /**
31
+ * How long a document waits, after its load event, for its first contentful paint before reporting.
32
+ * A client-rendered page (SauceDemo's login is one) routinely paints AFTER `load`: reading its
33
+ * vitals at the load event reports FCP and LCP as never having fired on a page that painted a
34
+ * moment later. A page that has not painted after this long reports what it has, and a budget on
35
+ * the missing vital then fails naming why.
36
+ */
37
+ export const PAINT_WAIT_MS = LOAD_WAIT_MS;
38
+ /**
39
+ * How long the judge waits, at scenario end, for documents that loaded but have not reported: the
40
+ * paint wait plus the LCP settle window, with room for the binding round-trip.
41
+ */
42
+ export const REPORT_WAIT_MS = PAINT_WAIT_MS + LCP_SETTLE_MS + 1_000;
43
+ /** The binding each document reports through. Namespaced so it cannot collide with the app. */
44
+ export const VITALS_BINDING = '__sdodsReportVitals';
45
+ const watches = new WeakMap();
46
+ /**
47
+ * The script every document in the page runs. Only the top-level document reports: after its load
48
+ * event, once it has painted (or `paintWaitMs` has passed), and after the same LCP settle window the
49
+ * explicit `I record the page vitals` step uses, so both measure a page the same way.
50
+ */
51
+ export function vitalsInitScript(settleMs = LCP_SETTLE_MS, paintWaitMs = PAINT_WAIT_MS, binding = VITALS_BINDING) {
52
+ return `(() => {
53
+ if (window.top !== window) return;
54
+ const collect = ${collectRawVitals.toString()};
55
+ const painted = () => new Promise((resolve) => {
56
+ if (performance.getEntriesByName('first-contentful-paint').length) return resolve();
57
+ setTimeout(resolve, ${paintWaitMs});
58
+ try {
59
+ new PerformanceObserver((list) => {
60
+ if (list.getEntries().some((e) => e.name === 'first-contentful-paint')) resolve();
61
+ }).observe({ type: 'paint', buffered: true });
62
+ } catch (e) {
63
+ resolve();
64
+ }
65
+ });
66
+ addEventListener('load', () => {
67
+ setTimeout(() => {
68
+ painted()
69
+ .then(() => collect(${settleMs}))
70
+ .then((raw) => {
71
+ const report = window[${JSON.stringify(binding)}];
72
+ if (typeof report === 'function') report(raw);
73
+ }, () => undefined);
74
+ }, 0);
75
+ }, { once: true });
76
+ })();`;
77
+ }
78
+ /**
79
+ * Starts recording the vitals of every document the page's main frame loads. Must run before the
80
+ * scenario's first navigation, which is why the hook that calls it is a `BeforeScenario`.
81
+ */
82
+ export async function watchNavigations(deps) {
83
+ const { page, scenario } = deps;
84
+ const watch = { loads: 0, recordings: [], notify: [] };
85
+ watches.set(scenario, watch);
86
+ page.on('load', () => {
87
+ watch.loads++;
88
+ });
89
+ await page.exposeBinding(VITALS_BINDING, (source, raw) => {
90
+ if (source.frame !== page.mainFrame())
91
+ return;
92
+ watch.recordings.push(toRecording(raw, source.frame.url(), scenario.data.browser));
93
+ for (const fn of watch.notify.splice(0))
94
+ fn();
95
+ });
96
+ await page.addInitScript({ content: vitalsInitScript() });
97
+ }
98
+ async function waitForReports(watch) {
99
+ const deadline = Date.now() + REPORT_WAIT_MS;
100
+ while (watch.recordings.length < watch.loads && Date.now() < deadline) {
101
+ await new Promise((resolve) => {
102
+ const timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
103
+ watch.notify.push(() => {
104
+ clearTimeout(timer);
105
+ resolve();
106
+ });
107
+ });
108
+ }
109
+ }
110
+ /** Pure: every comparison and breach for a set of recordings and API timings. */
111
+ export function judgeBudgets(input) {
112
+ const { budgets, recordings, apiMs, apiReplayed, envName } = input;
113
+ const breaches = [];
114
+ let judged = 0;
115
+ for (const recording of recordings) {
116
+ for (const key of VITAL_KEYS) {
117
+ const limit = budgets[key];
118
+ if (limit === undefined)
119
+ continue;
120
+ judged++;
121
+ const value = recording.vitals[key];
122
+ if (value === null) {
123
+ breaches.push({
124
+ budget: key,
125
+ limitMs: limit,
126
+ measuredMs: null,
127
+ where: recording.url,
128
+ message: `${key} was not measured on ${recording.url} — ${recording.unmeasured[key] ?? 'the page reported no value'}. A budget cannot be met by a vital that never fired.`,
129
+ });
130
+ }
131
+ else if (value > limit) {
132
+ breaches.push({
133
+ budget: key,
134
+ limitMs: limit,
135
+ measuredMs: value,
136
+ where: recording.url,
137
+ message: `${key} measured ${value} ms on ${recording.url}, against a budget of ${limit} ms for environment "${envName}"`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ let p95Ms = null;
143
+ const apiLimit = budgets[API_P95_BUDGET_KEY];
144
+ if (apiLimit !== undefined && apiMs.length > 0) {
145
+ judged++;
146
+ p95Ms = p95Of(apiMs);
147
+ if (p95Ms > apiLimit)
148
+ breaches.push({
149
+ budget: API_P95_BUDGET_KEY,
150
+ limitMs: apiLimit,
151
+ measuredMs: p95Ms,
152
+ where: `${apiMs.length} API call(s)`,
153
+ message: `API p95 was ${p95Ms} ms over ${apiMs.length} call(s), against an ${API_P95_BUDGET_KEY} budget of ${apiLimit} ms for environment "${envName}"`,
154
+ });
155
+ }
156
+ else if (apiLimit !== undefined && apiReplayed > 0) {
157
+ judged++;
158
+ breaches.push({
159
+ budget: API_P95_BUDGET_KEY,
160
+ limitMs: apiLimit,
161
+ measuredMs: null,
162
+ where: `${apiReplayed} API call(s)`,
163
+ message: `All ${apiReplayed} API call(s) were replayed from a HAR, so their response times are the recording's, not environment "${envName}"'s. Run @perf scenarios without @har or --har-replay.`,
164
+ });
165
+ }
166
+ else if (apiMs.length > 0) {
167
+ p95Ms = p95Of(apiMs);
168
+ }
169
+ return { judged, breaches, p95Ms };
170
+ }
171
+ export async function judgeScenarioPerf(deps) {
172
+ const { config, scenario, apiContext, testInfo } = deps;
173
+ const envName = config.env.name ?? 'unknown';
174
+ const budgets = resolvedBudgets(config);
175
+ const watch = watches.get(scenario);
176
+ if (watch)
177
+ await waitForReports(watch);
178
+ const recordings = watch?.recordings ?? [];
179
+ const live = apiContext.history.filter((s) => !s.replayedFromHar);
180
+ const samples = perfStore(scenario).sampleSets.flatMap((set) => set.samples.map((s) => s.ms));
181
+ const apiMs = [...live.map((s) => s.response.responseTime), ...samples];
182
+ const apiReplayed = apiContext.history.length - live.length;
183
+ const report = {
184
+ kind: 'perf-scenario',
185
+ runnerProject: scenario.data.runnerProject,
186
+ fingerprint: scenario.data.fingerprint,
187
+ retry: scenario.data.retry,
188
+ env: envName,
189
+ status: 'judged',
190
+ budgets,
191
+ navigations: recordings.map((r) => ({
192
+ url: r.url,
193
+ vitals: r.vitals,
194
+ unmeasured: r.unmeasured,
195
+ })),
196
+ unreported: watch ? Math.max(0, watch.loads - recordings.length) : 0,
197
+ api: {
198
+ liveCalls: live.length,
199
+ replayedFromHar: apiReplayed,
200
+ samples: samples.length,
201
+ p95Ms: null,
202
+ },
203
+ judged: 0,
204
+ breaches: [],
205
+ };
206
+ const file = scenario.file(scenarioFiles.perfScenarioJson(scenario.data.runnerProject));
207
+ const write = () => writeAndAttachJson(testInfo, attachmentNames.perfScenario, file, report);
208
+ if (testInfo.status && testInfo.status !== 'passed') {
209
+ report.status = 'skipped';
210
+ report.reason = `the scenario ${testInfo.status} before its budgets could be judged`;
211
+ await write();
212
+ return report;
213
+ }
214
+ const verdict = judgeBudgets({ budgets, recordings, apiMs, apiReplayed, envName });
215
+ report.judged = verdict.judged;
216
+ report.breaches = verdict.breaches;
217
+ report.api.p95Ms = verdict.p95Ms;
218
+ if (Object.keys(budgets).length === 0) {
219
+ report.status = 'skipped';
220
+ report.reason = 'no perf budgets are configured';
221
+ await write();
222
+ throw new SdodsError('CONFIG_INVALID', `This @perf scenario runs on environment "${envName}", which has no perf budgets configured.`, {
223
+ hint: `Set perf.budgets (${[...VITAL_KEYS, API_P95_BUDGET_KEY].join(', ')}) in the project's sdods.project.yaml, or in envs/${envName}.yaml. Without one the @perf tag would pass no matter how slow the application is.`,
224
+ });
225
+ }
226
+ await write();
227
+ if (verdict.judged === 0) {
228
+ const measured = [
229
+ recordings.length ? `${recordings.length} page navigation(s)` : 'no page navigation',
230
+ apiMs.length ? `${apiMs.length} API call(s)` : 'no API call',
231
+ ].join(' and ');
232
+ throw new SdodsError('RUN_FAILED', `This @perf scenario measured ${measured}, and none of the configured budgets (${Object.keys(budgets).join(', ')}) applies to that, so it proved nothing.`, {
233
+ hint: 'Configure a budget for what the scenario exercises (page vitals for a UI flow, apiP95Ms for API calls), or drop @perf from it.',
234
+ });
235
+ }
236
+ if (verdict.breaches.length) {
237
+ throw new SdodsError('RUN_FAILED', `@perf: ${verdict.breaches.length} budget breach(es):\n${verdict.breaches.map((b) => ` ${b.message}`).join('\n')}`, { hint: 'The full measurements are attached as sdods/perf-scenario.' });
238
+ }
239
+ return report;
240
+ }
241
+ //# sourceMappingURL=perf-scenario.js.map
@@ -0,0 +1,72 @@
1
+ import type { VisualFailure } from '@sdods/contracts';
2
+ import type { ShotPolicyResolved } from './policy.js';
3
+ /** Where a project's baselines live; accept refuses to write anywhere else. */
4
+ export declare const BASELINES_DIR: string;
5
+ /** Baseline name as the config keys it: the step argument without `.png`. */
6
+ export declare function baselineKey(name: string): string;
7
+ /**
8
+ * Mask and threshold for one baseline check: the project/env `screenshots.mask`, plus
9
+ * `screenshots.baselines.<name>.mask`, plus the selectors the step passed. Duplicates are dropped.
10
+ * The threshold is the baseline's own `maxDiffPixelRatio`, else the project/env one.
11
+ */
12
+ export declare function visualCheckOptions(name: string, policy: Pick<ShotPolicyResolved, 'mask' | 'maxDiffPixelRatio' | 'baselines'>, extraMask?: readonly string[]): {
13
+ mask: string[];
14
+ maxDiffPixelRatio: number;
15
+ };
16
+ /**
17
+ * Selectors from the `masking {string}` step argument. Comma-separated, so
18
+ * `"[data-test=badge], .timestamp"` masks both. A selector that itself contains a comma
19
+ * (`:is(a, b)`, `[title="a, b"]`) belongs in `screenshots.baselines.<name>.mask` instead.
20
+ */
21
+ export declare function parseMaskArgument(value: string): string[];
22
+ /** What Playwright's `toHaveScreenshot` error says about why the check failed. */
23
+ export declare function parseVisualError(message: string): {
24
+ reason: VisualFailure['reason'];
25
+ diffPixels?: number;
26
+ };
27
+ /** Width and height from a PNG header, without decoding it. */
28
+ export declare function pngSize(file: string): {
29
+ width: number;
30
+ height: number;
31
+ } | undefined;
32
+ /**
33
+ * The visual checks that failed in a run and still need a decision: every
34
+ * `visual/<target>/<name>.failure.json` under the run's scenario directories, keeping only the
35
+ * last attempt of each check. A check that passed on a later retry is not listed.
36
+ */
37
+ export declare function listVisualFailures(runDir: string): VisualFailure[];
38
+ /**
39
+ * Pick failures by name. A name matches `inventory`, `inventory.png`, or
40
+ * `<run target>/inventory` to narrow it to one browser.
41
+ */
42
+ export declare function selectVisualFailures(failures: readonly VisualFailure[], sel: {
43
+ names?: readonly string[];
44
+ all?: boolean;
45
+ }): VisualFailure[];
46
+ export interface AcceptedBaseline {
47
+ name: string;
48
+ project: string;
49
+ runnerProject: string;
50
+ platform: string;
51
+ reason: VisualFailure['reason'];
52
+ diffRatio?: number;
53
+ /** image copied, absolute */
54
+ from: string;
55
+ /** baseline written, absolute */
56
+ to: string;
57
+ /** false when a baseline existed and was replaced */
58
+ created: boolean;
59
+ /** the diff image a person reviewed, absolute, when there was one */
60
+ diff?: string;
61
+ }
62
+ /**
63
+ * Copy each failure's actual image over its baseline: `features/__screenshots__/<run
64
+ * target>/<platform>/<name>.png` of the project, for the platform the run happened on. Writes
65
+ * into the project tree the way `--update-snapshots` does, so a person runs it, not an agent.
66
+ */
67
+ export declare function acceptVisualFailures(opts: {
68
+ runDir: string;
69
+ failures: readonly VisualFailure[];
70
+ projectRoot: (slug: string) => string;
71
+ }): AcceptedBaseline[];
72
+ //# sourceMappingURL=baselines.d.ts.map
@@ -0,0 +1,201 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { SdodsError } from '../errors.js';
4
+ /** Where a project's baselines live; accept refuses to write anywhere else. */
5
+ export const BASELINES_DIR = join('features', '__screenshots__');
6
+ /** Baseline name as the config keys it: the step argument without `.png`. */
7
+ export function baselineKey(name) {
8
+ return name.replace(/\.png$/i, '');
9
+ }
10
+ /**
11
+ * Mask and threshold for one baseline check: the project/env `screenshots.mask`, plus
12
+ * `screenshots.baselines.<name>.mask`, plus the selectors the step passed. Duplicates are dropped.
13
+ * The threshold is the baseline's own `maxDiffPixelRatio`, else the project/env one.
14
+ */
15
+ export function visualCheckOptions(name, policy, extraMask = []) {
16
+ const own = policy.baselines?.[baselineKey(name)];
17
+ const mask = [...new Set([...policy.mask, ...(own?.mask ?? []), ...extraMask])].filter((s) => s.trim() !== '');
18
+ return { mask, maxDiffPixelRatio: own?.maxDiffPixelRatio ?? policy.maxDiffPixelRatio ?? 0.01 };
19
+ }
20
+ /**
21
+ * Selectors from the `masking {string}` step argument. Comma-separated, so
22
+ * `"[data-test=badge], .timestamp"` masks both. A selector that itself contains a comma
23
+ * (`:is(a, b)`, `[title="a, b"]`) belongs in `screenshots.baselines.<name>.mask` instead.
24
+ */
25
+ export function parseMaskArgument(value) {
26
+ return value
27
+ .split(',')
28
+ .map((s) => s.trim())
29
+ .filter(Boolean);
30
+ }
31
+ /** ANSI colour sequences, built by code point so the source carries no control character. */
32
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
33
+ /** What Playwright's `toHaveScreenshot` error says about why the check failed. */
34
+ export function parseVisualError(message) {
35
+ const plain = message.replace(ANSI, '');
36
+ if (/snapshot doesn't exist|writing actual/i.test(plain))
37
+ return { reason: 'missing' };
38
+ if (/Expected an image \d+px by \d+px, received/i.test(plain))
39
+ return { reason: 'size' };
40
+ const px = /(\d+) pixels \(ratio [\d.]+ of all image pixels\) are different/.exec(plain);
41
+ return { reason: 'changed', diffPixels: px ? Number(px[1]) : undefined };
42
+ }
43
+ /** Width and height from a PNG header, without decoding it. */
44
+ export function pngSize(file) {
45
+ try {
46
+ const buf = readFileSync(file);
47
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47)
48
+ return undefined;
49
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ /**
56
+ * The visual checks that failed in a run and still need a decision: every
57
+ * `visual/<target>/<name>.failure.json` under the run's scenario directories, keeping only the
58
+ * last attempt of each check. A check that passed on a later retry is not listed.
59
+ */
60
+ export function listVisualFailures(runDir) {
61
+ if (!existsSync(runDir))
62
+ return [];
63
+ const latest = new Map();
64
+ const walk = (dir, depth) => {
65
+ if (depth > 6)
66
+ return;
67
+ let names;
68
+ try {
69
+ names = readdirSync(dir);
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ for (const n of names) {
75
+ const abs = join(dir, n);
76
+ let isDir = false;
77
+ try {
78
+ isDir = statSync(abs).isDirectory();
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ if (isDir) {
84
+ // Runner scratch and reports never hold records.
85
+ if (depth === 0 &&
86
+ /^(runner-output|pw-output|html-report|shard-reports|blob-report)$/.test(n))
87
+ continue;
88
+ walk(abs, depth + 1);
89
+ continue;
90
+ }
91
+ const m = /^(.*)\.(failure|passed)\.json$/.exec(n);
92
+ if (!m || !abs.split(sep).includes('visual'))
93
+ continue;
94
+ let rec;
95
+ try {
96
+ rec = JSON.parse(readFileSync(abs, 'utf8'));
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ if (!rec || typeof rec.name !== 'string' || typeof rec.runnerProject !== 'string')
102
+ continue;
103
+ const key = JSON.stringify([rec.fingerprint, rec.runnerProject, rec.name]);
104
+ const prev = latest.get(key);
105
+ const retry = Number(rec.retry ?? 0);
106
+ if (prev && prev.retry > retry)
107
+ continue;
108
+ if (prev && prev.retry === retry && m[2] === 'passed')
109
+ continue;
110
+ latest.set(key, { retry, failure: m[2] === 'failure' ? rec : undefined });
111
+ }
112
+ };
113
+ walk(runDir, 0);
114
+ return [...latest.values()]
115
+ .map((v) => v.failure)
116
+ .filter((f) => !!f)
117
+ .sort((a, b) => a.project.localeCompare(b.project) ||
118
+ a.runnerProject.localeCompare(b.runnerProject) ||
119
+ a.name.localeCompare(b.name));
120
+ }
121
+ /**
122
+ * Pick failures by name. A name matches `inventory`, `inventory.png`, or
123
+ * `<run target>/inventory` to narrow it to one browser.
124
+ */
125
+ export function selectVisualFailures(failures, sel) {
126
+ if (sel.all)
127
+ return [...failures];
128
+ const names = sel.names ?? [];
129
+ if (!names.length)
130
+ throw new SdodsError('CONFIG_INVALID', 'Name the baselines to accept, or pass --all.', {
131
+ exitCode: 2,
132
+ });
133
+ const picked = [];
134
+ const unknown = [];
135
+ for (const raw of names) {
136
+ const slash = raw.lastIndexOf('/');
137
+ const target = slash > 0 ? raw.slice(0, slash) : undefined;
138
+ const key = baselineKey(slash > 0 ? raw.slice(slash + 1) : raw);
139
+ const hits = failures.filter((f) => baselineKey(f.name) === key && (!target || f.runnerProject === target));
140
+ if (!hits.length)
141
+ unknown.push(raw);
142
+ for (const h of hits)
143
+ if (!picked.includes(h))
144
+ picked.push(h);
145
+ }
146
+ if (unknown.length) {
147
+ const available = [...new Set(failures.map((f) => `${f.runnerProject}/${f.name}`))];
148
+ throw new SdodsError('CONFIG_INVALID', `No failed visual check named ${unknown.map((u) => `"${u}"`).join(', ')} in this run.`, {
149
+ hint: available.length
150
+ ? `Failed in this run: ${available.join(', ')}`
151
+ : 'No visual check failed in this run.',
152
+ exitCode: 2,
153
+ });
154
+ }
155
+ return picked;
156
+ }
157
+ /**
158
+ * Copy each failure's actual image over its baseline: `features/__screenshots__/<run
159
+ * target>/<platform>/<name>.png` of the project, for the platform the run happened on. Writes
160
+ * into the project tree the way `--update-snapshots` does, so a person runs it, not an agent.
161
+ */
162
+ export function acceptVisualFailures(opts) {
163
+ const plan = opts.failures.map((f) => {
164
+ const root = resolve(opts.projectRoot(f.project));
165
+ const guard = resolve(root, BASELINES_DIR);
166
+ const to = resolve(root, f.baseline);
167
+ const inside = relative(guard, to);
168
+ if (!inside || inside.startsWith('..') || isAbsolute(inside) || !/\.png$/i.test(to))
169
+ throw new SdodsError('CONFIG_INVALID', `Refusing to write ${f.baseline}: baselines live under ${BASELINES_DIR}/.`, { exitCode: 2 });
170
+ const runDir = resolve(opts.runDir);
171
+ const from = resolve(runDir, f.actual);
172
+ const back = relative(runDir, from);
173
+ if (back.startsWith('..') || isAbsolute(back))
174
+ throw new SdodsError('CONFIG_INVALID', `Actual image ${f.actual} is outside the run.`, {
175
+ exitCode: 2,
176
+ });
177
+ if (!existsSync(from))
178
+ throw new SdodsError('CONFIG_NOT_FOUND', `The actual image for ${f.runnerProject}/${f.name} is missing: ${from}`, { exitCode: 2 });
179
+ const diff = f.diff ? resolve(runDir, f.diff) : undefined;
180
+ return { f, from, to, diff: diff && existsSync(diff) ? diff : undefined };
181
+ });
182
+ // Validate everything first, then write: a bad entry must not leave half the set accepted.
183
+ return plan.map(({ f, from, to, diff }) => {
184
+ const created = !existsSync(to);
185
+ mkdirSync(dirname(to), { recursive: true });
186
+ copyFileSync(from, to);
187
+ return {
188
+ name: f.name,
189
+ project: f.project,
190
+ runnerProject: f.runnerProject,
191
+ platform: f.platform,
192
+ reason: f.reason,
193
+ diffRatio: f.diffRatio,
194
+ from,
195
+ to,
196
+ created,
197
+ diff,
198
+ };
199
+ });
200
+ }
201
+ //# sourceMappingURL=baselines.js.map
@@ -27,8 +27,8 @@ AfterScenario({ name: 'sdods:shots:end', tags: UI }, async ({ shots, $testInfo }
27
27
  });
28
28
  AfterScenario({ name: 'sdods:finalize' }, async ({ scenario, apiContext, heal, $testInfo }) => {
29
29
  scenario.finalize({
30
- status: $testInfo.status ?? 'unknown',
31
- errorMessage: $testInfo.error?.message,
30
+ status: scenario.hookFailure ? 'failed' : ($testInfo.status ?? 'unknown'),
31
+ errorMessage: $testInfo.error?.message ?? scenario.hookFailure,
32
32
  apiCalls: apiContext.history.length,
33
33
  heals: heal.events.length,
34
34
  });
@@ -1,3 +1,4 @@
1
1
  export * from './policy.js';
2
2
  export * from './narrator.js';
3
+ export * from './baselines.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,4 @@
1
1
  export * from './policy.js';
2
2
  export * from './narrator.js';
3
+ export * from './baselines.js';
3
4
  //# sourceMappingURL=index.js.map