@sdods/core 0.7.2 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -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
|
package/dist/shots/hooks.js
CHANGED
|
@@ -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
|
});
|
package/dist/shots/index.d.ts
CHANGED
package/dist/shots/index.js
CHANGED