@sdods/core 0.8.0 → 0.10.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 (55) 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/api/template.js +1 -1
  5. package/dist/config/defaults.js +2 -0
  6. package/dist/config/resolve.js +1 -1
  7. package/dist/data/index.d.ts +1 -0
  8. package/dist/data/index.js +1 -0
  9. package/dist/data/pool-capacity.d.ts +58 -0
  10. package/dist/data/pool-capacity.js +138 -0
  11. package/dist/data/user-pool.d.ts +43 -2
  12. package/dist/data/user-pool.js +170 -40
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/fixtures/auth.js +1 -1
  15. package/dist/fixtures/scenario.d.ts +11 -0
  16. package/dist/fixtures/scenario.js +15 -0
  17. package/dist/fixtures/test.js +24 -3
  18. package/dist/fixtures/types.d.ts +2 -0
  19. package/dist/har/api-har.js +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/matrix/index.d.ts +13 -0
  23. package/dist/matrix/index.js +25 -4
  24. package/dist/perf/vitals.d.ts +118 -0
  25. package/dist/perf/vitals.js +236 -0
  26. package/dist/quality/a11y-scenario.d.ts +110 -0
  27. package/dist/quality/a11y-scenario.js +229 -0
  28. package/dist/quality/gates.d.ts +87 -0
  29. package/dist/quality/gates.js +233 -0
  30. package/dist/quality/hooks.d.ts +2 -0
  31. package/dist/quality/hooks.js +51 -0
  32. package/dist/quality/index.d.ts +4 -0
  33. package/dist/quality/index.js +2 -0
  34. package/dist/quality/perf-scenario.d.ts +149 -0
  35. package/dist/quality/perf-scenario.js +265 -0
  36. package/dist/shots/baselines.d.ts +72 -0
  37. package/dist/shots/baselines.js +201 -0
  38. package/dist/shots/hooks.js +2 -2
  39. package/dist/shots/index.d.ts +1 -0
  40. package/dist/shots/index.js +1 -0
  41. package/dist/shots/narrator.d.ts +10 -2
  42. package/dist/shots/narrator.js +106 -10
  43. package/dist/shots/policy.d.ts +5 -1
  44. package/dist/shots/policy.js +2 -0
  45. package/dist/steps/a11y.steps.d.ts +18 -51
  46. package/dist/steps/a11y.steps.js +6 -107
  47. package/dist/steps/data.steps.js +17 -5
  48. package/dist/steps/hooks.steps.d.ts +1 -0
  49. package/dist/steps/hooks.steps.js +3 -1
  50. package/dist/steps/index.d.ts +1 -0
  51. package/dist/steps/index.js +1 -0
  52. package/dist/steps/perf.steps.d.ts +3 -129
  53. package/dist/steps/perf.steps.js +4 -227
  54. package/dist/steps/ui.steps.js +5 -0
  55. package/package.json +7 -7
@@ -0,0 +1,233 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { attachmentNames } from '@sdods/contracts';
4
+ /** True when the process declares at least one gate worth evaluating. */
5
+ export function hasGates(gates) {
6
+ return Boolean(gates &&
7
+ (gates.minPassRate !== undefined ||
8
+ gates.maxFlaky !== undefined ||
9
+ gates.a11y === true ||
10
+ gates.perfBudgets === true));
11
+ }
12
+ /** Pure: the verdict for one process's gates over a run's totals and evidence. */
13
+ export function evaluateGates(input) {
14
+ const { gates = {}, totals, evidence } = input;
15
+ const rows = [];
16
+ if (gates.minPassRate !== undefined) {
17
+ const executed = totals ? totals.total - totals.skipped : 0;
18
+ if (!totals || executed <= 0) {
19
+ rows.push({
20
+ gate: 'minPassRate',
21
+ threshold: `≥ ${gates.minPassRate}%`,
22
+ actual: 'n/a',
23
+ passed: false,
24
+ detail: totals
25
+ ? 'no scenario executed, so there is no pass rate to meet the gate'
26
+ : 'the run produced no results to compute a pass rate from',
27
+ });
28
+ }
29
+ else {
30
+ const rate = ((totals.passed + totals.flaky) / executed) * 100;
31
+ rows.push({
32
+ gate: 'minPassRate',
33
+ threshold: `≥ ${gates.minPassRate}%`,
34
+ actual: `${formatPct(rate)}% (${totals.passed + totals.flaky}/${executed})`,
35
+ passed: rate >= gates.minPassRate,
36
+ });
37
+ }
38
+ }
39
+ if (gates.maxFlaky !== undefined) {
40
+ rows.push({
41
+ gate: 'maxFlaky',
42
+ threshold: `≤ ${gates.maxFlaky}`,
43
+ actual: totals ? String(totals.flaky) : 'n/a',
44
+ passed: Boolean(totals) && (totals?.flaky ?? 0) <= gates.maxFlaky,
45
+ detail: totals ? undefined : 'the run produced no results to count flaky scenarios from',
46
+ });
47
+ }
48
+ if (gates.a11y) {
49
+ const reports = evidence.a11y.filter((r) => r.status !== 'skipped');
50
+ // Under `a11y.scope: every-page` one scenario report holds an audit per URL; each is counted.
51
+ const audits = reports.reduce((n, r) => n + auditsIn(r), 0);
52
+ const blocking = reports.reduce((n, r) => n + r.blocking, 0);
53
+ const failing = reports.flatMap((r) => (r.pages ?? [r])
54
+ .filter((p) => p.blocking > 0)
55
+ .map((p) => `${p.url} [${r.runnerProject}]: ${p.blockingRules.join(', ')}`));
56
+ rows.push({
57
+ gate: 'a11y',
58
+ threshold: 'no blocking violation',
59
+ actual: `${blocking} blocking in ${audits} audit(s)`,
60
+ passed: audits > 0 && blocking === 0,
61
+ detail: audits === 0
62
+ ? 'no @a11y audit ran in this run, so the gate has nothing to prove'
63
+ : failing.length
64
+ ? failing.slice(0, 5).join('; ')
65
+ : undefined,
66
+ });
67
+ }
68
+ if (gates.perfBudgets) {
69
+ const judged = evidence.perf.filter((r) => r.status === 'judged' && r.judged > 0);
70
+ const breaches = judged.flatMap((r) => r.breaches.map((b) => ({ ...b, runnerProject: r.runnerProject })));
71
+ rows.push({
72
+ gate: 'perfBudgets',
73
+ threshold: 'no budget breach',
74
+ actual: `${breaches.length} breach(es) in ${judged.length} scenario(s)`,
75
+ passed: judged.length > 0 && breaches.length === 0,
76
+ detail: judged.length === 0
77
+ ? 'no @perf scenario was judged against a budget in this run, so the gate has nothing to prove'
78
+ : breaches.length
79
+ ? breaches
80
+ .slice(0, 5)
81
+ .map((b) => `${b.budget} ${b.measuredMs ?? 'unmeasured'}/${b.limitMs} ms at ${b.where}`)
82
+ .join('; ')
83
+ : undefined,
84
+ });
85
+ }
86
+ return { process: input.process, passed: rows.every((r) => r.passed), rows };
87
+ }
88
+ /** Audits one scenario report stands for: one per URL under `every-page`, otherwise one. */
89
+ function auditsIn(report) {
90
+ if (!report.pages)
91
+ return 1;
92
+ return report.pages.filter((p) => p.status !== 'not-audited').length;
93
+ }
94
+ function formatPct(n) {
95
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
96
+ }
97
+ /**
98
+ * Reads the `@a11y` and `@perf` scenario reports out of one or more run directories
99
+ * (`<runDir>/<slug>/<fingerprint>/r<retry>/{a11y,perf}/scenario--<runner project>.json`), keeping
100
+ * only the highest retry of each scenario on each runner project: an attempt that was retried is not
101
+ * the scenario's verdict. Several directories are how the shards of one run are read together: each
102
+ * shard wrote its own scenarios, and the same scenario never ran on two shards.
103
+ */
104
+ export function collectGateEvidence(runDirs,
105
+ /** Reports read from elsewhere (the attachments of a merged report), judged the same way. */
106
+ extra = []) {
107
+ const latest = new Map();
108
+ const keep = (report) => {
109
+ const key = `${report.kind}|${report.fingerprint}|${report.runnerProject}`;
110
+ const prev = latest.get(key);
111
+ if (prev && prev.retry > report.retry)
112
+ return;
113
+ latest.set(key, report.kind === 'a11y-scenario'
114
+ ? { retry: report.retry, a11y: report }
115
+ : { retry: report.retry, perf: report });
116
+ };
117
+ const dirs = [...new Set(typeof runDirs === 'string' ? [runDirs] : runDirs)];
118
+ for (const file of dirs.flatMap(scenarioReportFiles)) {
119
+ try {
120
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
121
+ if (parsed.kind === 'a11y-scenario' || parsed.kind === 'perf-scenario')
122
+ keep(parsed);
123
+ }
124
+ catch {
125
+ /* a half-written report is not evidence */
126
+ }
127
+ }
128
+ for (const report of extra)
129
+ keep(report);
130
+ const values = [...latest.values()];
131
+ return {
132
+ a11y: values.flatMap((v) => (v.a11y ? [v.a11y] : [])),
133
+ perf: values.flatMap((v) => (v.perf ? [v.perf] : [])),
134
+ };
135
+ }
136
+ /**
137
+ * The `sdods/a11y-scenario` and `sdods/perf-scenario` reports attached to the tests of a runner JSON
138
+ * report. After `playwright merge-reports` their paths point into the directory the blob reports
139
+ * were unpacked into, which lives only as long as the merge, so read them before it is removed. A
140
+ * missing or unreadable file is skipped; the run directories are the other source of the same
141
+ * evidence.
142
+ */
143
+ export function gateReportsFromRunnerJson(report) {
144
+ const names = new Set([attachmentNames.a11yScenario, attachmentNames.perfScenario]);
145
+ const out = [];
146
+ const walk = (suite) => {
147
+ for (const spec of suite.specs ?? [])
148
+ for (const test of spec.tests ?? [])
149
+ for (const result of test.results ?? [])
150
+ for (const a of result.attachments ?? []) {
151
+ if (!names.has(a.name) || !a.path || !existsSync(a.path))
152
+ continue;
153
+ try {
154
+ const parsed = JSON.parse(readFileSync(a.path, 'utf8'));
155
+ if (parsed.kind === 'a11y-scenario' || parsed.kind === 'perf-scenario')
156
+ out.push(parsed);
157
+ }
158
+ catch {
159
+ /* not evidence */
160
+ }
161
+ }
162
+ for (const child of suite.suites ?? [])
163
+ walk(child);
164
+ };
165
+ for (const suite of report?.suites ?? [])
166
+ walk(suite);
167
+ return out;
168
+ }
169
+ /**
170
+ * Gate totals from a runner JSON report's `stats` (what `playwright merge-reports --reporter json`
171
+ * writes for the merged shards). The same counting as the run dashboard: one row per test, retries
172
+ * folded into `flaky`.
173
+ */
174
+ export function gateTotalsFromRunnerStats(stats) {
175
+ if (!stats)
176
+ return undefined;
177
+ const passed = stats.expected ?? 0;
178
+ const failed = stats.unexpected ?? 0;
179
+ const flaky = stats.flaky ?? 0;
180
+ const skipped = stats.skipped ?? 0;
181
+ return { total: passed + failed + flaky + skipped, passed, failed, skipped, flaky };
182
+ }
183
+ function scenarioReportFiles(runDir) {
184
+ const out = [];
185
+ const dirs = (p) => existsSync(p) ? readdirSync(p).filter((n) => statSync(join(p, n)).isDirectory()) : [];
186
+ for (const slug of dirs(runDir)) {
187
+ // Fingerprints are 16 hex characters; this keeps the walk out of the report directories.
188
+ for (const fp of dirs(join(runDir, slug)).filter((n) => /^[0-9a-f]{16}$/.test(n))) {
189
+ for (const attempt of dirs(join(runDir, slug, fp))) {
190
+ if (!/^r\d+$/.test(attempt))
191
+ continue;
192
+ for (const kind of ['a11y', 'perf']) {
193
+ const dir = join(runDir, slug, fp, attempt, kind);
194
+ if (!existsSync(dir))
195
+ continue;
196
+ for (const name of readdirSync(dir))
197
+ if (name.startsWith('scenario--') && name.endsWith('.json'))
198
+ out.push(join(dir, name));
199
+ }
200
+ }
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ /**
206
+ * The gate judge ingest runs when a run directory carries no `gates.json`: the process the run's
207
+ * manifest names, over the totals ingest just computed and the evidence in the run directory.
208
+ * `undefined` when there is nothing it may judge: no process, a process without gates, a cancelled
209
+ * run, or one shard of several (a shard is not the run; `sdods report merge --process` judges it).
210
+ */
211
+ export function ingestGateJudge(input) {
212
+ const { manifest, runDir } = input;
213
+ if (!manifest?.process || !manifest.projectSlug)
214
+ return undefined;
215
+ if ((manifest.shardTotal ?? 1) > 1 || manifest.exitCode === 130)
216
+ return undefined;
217
+ let proc;
218
+ try {
219
+ proc = input.processOf(manifest.projectSlug, manifest.process);
220
+ }
221
+ catch {
222
+ return undefined; // a process this checkout no longer defines has no gates to judge
223
+ }
224
+ if (!hasGates(proc.gates))
225
+ return undefined;
226
+ return (totals) => evaluateGates({
227
+ process: proc.name,
228
+ gates: proc.gates,
229
+ totals,
230
+ evidence: collectGateEvidence(runDir),
231
+ });
232
+ }
233
+ //# sourceMappingURL=gates.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { AfterScenario, AfterStep, BeforeScenario } from '../fixtures/test.js';
2
+ import { auditAfterStep, auditScenarioEnd, watchA11yPages } from './a11y-scenario.js';
3
+ import { judgeScenarioPerf, watchNavigations } from './perf-scenario.js';
4
+ /**
5
+ * `@a11y` and `@perf` tag hooks.
6
+ *
7
+ * Tag-filtered scenario hooks rather than auto fixtures, for the reason `shots/hooks.ts` gives: a
8
+ * hook's fixtures are requested only by the scenarios its tag expression matches (playwright-bdd
9
+ * decides that when it generates the spec), so an `@api @perf` scenario never opens a browser to be
10
+ * judged. playwright-bdd reads the fixture names from the destructuring below, so each hook lists
11
+ * its fixtures literally and hands them to a plain function the unit tests call directly.
12
+ *
13
+ * Registered after the screenshot hooks and before nothing else: after-hooks run in reverse
14
+ * registration order, so these run BEFORE `sdods:finalize` and can leave their verdict on the
15
+ * scenario for it to record. A failure here is otherwise invisible to `meta.json`, because
16
+ * Playwright only marks the test failed once every after-hook has returned.
17
+ */
18
+ const PAGE = '(@ui or @hybrid)';
19
+ async function noting(scenario, run) {
20
+ try {
21
+ return await run();
22
+ }
23
+ catch (e) {
24
+ scenario.noteFailure(e instanceof Error ? e.message : String(e));
25
+ throw e;
26
+ }
27
+ }
28
+ BeforeScenario({ name: 'sdods:perf:watch', tags: `@perf and ${PAGE}` }, async ({ page, scenario }) => {
29
+ await watchNavigations({ page, scenario });
30
+ });
31
+ // Registered before the a11y hook, so it runs after it: the audit's axe injection is not a navigation.
32
+ AfterScenario({ name: 'sdods:perf', tags: '@perf' }, async ({ config, scenario, apiContext, $testInfo }) => {
33
+ await noting(scenario, () => judgeScenarioPerf({ config, scenario, apiContext, testInfo: $testInfo }));
34
+ });
35
+ // `a11y.scope: every-page` only; both are no-ops under the default `final`.
36
+ BeforeScenario({ name: 'sdods:a11y:watch', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario }) => {
37
+ watchA11yPages({ page, config, scenario });
38
+ });
39
+ AfterStep({ name: 'sdods:a11y:page', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario, $bddContext }) => {
40
+ await noting(scenario, () => auditAfterStep({
41
+ page,
42
+ config,
43
+ scenario,
44
+ step: $bddContext.step.title,
45
+ failed: $bddContext.step.error !== undefined,
46
+ }));
47
+ });
48
+ AfterScenario({ name: 'sdods:a11y', tags: `@a11y and ${PAGE}` }, async ({ page, config, scenario, $testInfo }) => {
49
+ await noting(scenario, () => auditScenarioEnd({ page, config, scenario, testInfo: $testInfo }));
50
+ });
51
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,4 @@
1
+ export { collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, type GateEvidence, type GateResult, type GateRow, type GateTotals, } from './gates.js';
2
+ export type { A11yPageReport, A11yScenarioReport, A11yScenarioStatus } from './a11y-scenario.js';
3
+ export type { PerfBreach, PerfScenarioReport } from './perf-scenario.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, } from './gates.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,149 @@
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
+ * Resolves once the document has painted its first content, or after `paintWaitMs` when it never
46
+ * does. Runs in the page: it is serialised into the init script below and into `waitForSettle`, so
47
+ * the `@perf` and `@a11y` hooks agree on when a page has settled.
48
+ */
49
+ export declare function firstPaintOrTimeout(paintWaitMs: number): Promise<void>;
50
+ /**
51
+ * The script every document in the page runs. Only the top-level document reports: after its load
52
+ * event, once it has painted (or `paintWaitMs` has passed), and after the same LCP settle window the
53
+ * explicit `I record the page vitals` step uses, so both measure a page the same way.
54
+ */
55
+ export declare function vitalsInitScript(settleMs?: number, paintWaitMs?: number, binding?: string): string;
56
+ /**
57
+ * Waits, from Node, for the point at which the `@perf` init script reads a document: its load event
58
+ * (bounded by `LOAD_WAIT_MS`), its first contentful paint (bounded by `PAINT_WAIT_MS`), then the LCP
59
+ * settle window. Never throws: a page that will not settle is audited as it stands, and a page that
60
+ * navigates away while this waits is the caller's to notice.
61
+ */
62
+ export declare function waitForSettle(page: Page, opts?: {
63
+ paintWaitMs?: number;
64
+ settleMs?: number;
65
+ loadWaitMs?: number;
66
+ }): Promise<void>;
67
+ /**
68
+ * Starts recording the vitals of every document the page's main frame loads. Must run before the
69
+ * scenario's first navigation, which is why the hook that calls it is a `BeforeScenario`.
70
+ */
71
+ export declare function watchNavigations(deps: {
72
+ page: Page;
73
+ scenario: {
74
+ data: {
75
+ browser?: string;
76
+ };
77
+ };
78
+ }): Promise<void>;
79
+ export interface PerfBreach {
80
+ budget: string;
81
+ limitMs: number;
82
+ /** `null` when the value was never measured. */
83
+ measuredMs: number | null;
84
+ where: string;
85
+ message: string;
86
+ }
87
+ /** What `sdods/perf-scenario` contains, and what the `perfBudgets` process gate reads back. */
88
+ export interface PerfScenarioReport {
89
+ kind: 'perf-scenario';
90
+ runnerProject: string;
91
+ fingerprint: string;
92
+ retry: number;
93
+ env: string;
94
+ status: 'judged' | 'skipped';
95
+ reason?: string;
96
+ budgets: Record<string, number>;
97
+ navigations: {
98
+ url: string;
99
+ vitals: VitalsRecording['vitals'];
100
+ unmeasured: VitalsRecording['unmeasured'];
101
+ }[];
102
+ /** Documents that loaded but were left before they could report. Listed, not failed. */
103
+ unreported: number;
104
+ api: {
105
+ liveCalls: number;
106
+ replayedFromHar: number;
107
+ samples: number;
108
+ p95Ms: number | null;
109
+ };
110
+ /** How many budget comparisons were made. Zero means the scenario proved nothing. */
111
+ judged: number;
112
+ breaches: PerfBreach[];
113
+ }
114
+ export interface PerfScenarioDeps {
115
+ config: BudgetConfig;
116
+ scenario: {
117
+ data: {
118
+ runnerProject: string;
119
+ fingerprint: string;
120
+ retry: number;
121
+ layer?: string;
122
+ };
123
+ file(rel: string): string;
124
+ };
125
+ apiContext: {
126
+ history: readonly ApiSnapshot[];
127
+ };
128
+ testInfo: {
129
+ status?: string;
130
+ attach(name: string, opts: {
131
+ path: string;
132
+ contentType: string;
133
+ }): Promise<void>;
134
+ };
135
+ }
136
+ /** Pure: every comparison and breach for a set of recordings and API timings. */
137
+ export declare function judgeBudgets(input: {
138
+ budgets: Record<string, number>;
139
+ recordings: readonly VitalsRecording[];
140
+ apiMs: readonly number[];
141
+ apiReplayed: number;
142
+ envName: string;
143
+ }): {
144
+ judged: number;
145
+ breaches: PerfBreach[];
146
+ p95Ms: number | null;
147
+ };
148
+ export declare function judgeScenarioPerf(deps: PerfScenarioDeps): Promise<PerfScenarioReport>;
149
+ //# sourceMappingURL=perf-scenario.d.ts.map