@sdods/core 0.2.1 → 0.3.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/analyze/detectors.js +236 -24
- package/dist/analyze/propose.js +32 -10
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +21 -7
- package/dist/auth/index.js +71 -12
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +3 -0
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/auth.d.ts +9 -1
- package/dist/fixtures/auth.js +13 -5
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/har/api-har.d.ts +1 -0
- package/dist/har/api-har.js +1 -1
- package/dist/har/index.d.ts +1 -0
- package/dist/har/index.js +1 -0
- package/dist/har/scrub.d.ts +17 -0
- package/dist/har/scrub.js +60 -0
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +10 -0
- package/dist/steps/index.js +10 -0
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -4
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { expect } from '@playwright/test';
|
|
3
|
+
import './params.js';
|
|
4
|
+
import { Then, When } from '../fixtures/test.js';
|
|
5
|
+
import { attachmentNames, pad2, scenarioFiles } from '@sdods/contracts';
|
|
6
|
+
import { render, renderJson } from '../api/template.js';
|
|
7
|
+
import { SdodsError } from '../errors.js';
|
|
8
|
+
/**
|
|
9
|
+
* Performance budgets.
|
|
10
|
+
*
|
|
11
|
+
* `PerfBudgetsSchema` (pageLoadMs / lcpMs / fcpMs / ttfbMs / apiP95Ms), the `perf.budgets` block in
|
|
12
|
+
* the project and env yaml, the `perfBudgets: true` release gate and `attachmentNames.perf` all
|
|
13
|
+
* existed before this file; nothing read any of them. These steps close that loop.
|
|
14
|
+
*
|
|
15
|
+
* Two rules run through every step here, both learned from hand-written project steps that got
|
|
16
|
+
* them wrong:
|
|
17
|
+
*
|
|
18
|
+
* 1. A BUDGET IS READ FROM CONFIG, NEVER FROM THE FEATURE FILE. A number typed into a `.feature`
|
|
19
|
+
* is a number that drifts from the environment it runs on — staging and a laptop do not share
|
|
20
|
+
* a page-load budget. `Then the response time should be under {int} ms` (api.steps.ts) is the
|
|
21
|
+
* literal-threshold form and stays exactly as it is; the budget-driven forms below are new
|
|
22
|
+
* patterns, not a widening of it. A local literal is still reachable where a scenario genuinely
|
|
23
|
+
* needs one — `should be under {int} ms` — so the escape hatch is visible in the step text.
|
|
24
|
+
* 2. AN UNMEASURED VITAL FAILS. LCP does not fire on a page with no contentful paint, and WebKit
|
|
25
|
+
* does not implement it at all; paint timings and the navigation entry can be missing too.
|
|
26
|
+
* Recording those as `0` makes every budget assertion pass — a whole perf module
|
|
27
|
+
* goes green while measuring nothing. So a vital that did not fire is recorded as `null`
|
|
28
|
+
* together with the reason, and asserting on it FAILS naming that reason. A real navigation
|
|
29
|
+
* cannot produce a genuine 0 ms vital (they are all offsets from navigation start), so treating
|
|
30
|
+
* "absent or 0" as "not measured" loses no honest signal.
|
|
31
|
+
*
|
|
32
|
+
* A budget that is not configured is likewise a hard `CONFIG_INVALID` naming the missing key, not
|
|
33
|
+
* a skip: silently skipping is the same vacuous green in a different costume.
|
|
34
|
+
*
|
|
35
|
+
* The two comparison operators are deliberately different and deliberately visible in the step
|
|
36
|
+
* text: `under {int} ms` is strict `<` (matching `the response time should be under {int} ms` in
|
|
37
|
+
* api.steps.ts), `within its configured budget` is `<=` — a budget is a ceiling you may sit on.
|
|
38
|
+
*/
|
|
39
|
+
/* ── budgets ──────────────────────────────────────────────────────────── */
|
|
40
|
+
/** The page vitals SDODS records. Each name is also its key in `perf.budgets`. */
|
|
41
|
+
export const VITAL_KEYS = ['pageLoadMs', 'lcpMs', 'fcpMs', 'ttfbMs'];
|
|
42
|
+
/** The `perf.budgets` key a sampled API latency distribution is judged against. */
|
|
43
|
+
export const API_P95_BUDGET_KEY = 'apiP95Ms';
|
|
44
|
+
/**
|
|
45
|
+
* Effective budgets, env overriding project per key.
|
|
46
|
+
*
|
|
47
|
+
* `resolveConfig` already deep-merges `env.perf` into `config.project.perf`, so on a resolved
|
|
48
|
+
* config the project side is the whole answer. Merging the env side again is idempotent and keeps
|
|
49
|
+
* this correct for a config assembled by hand. Non-numeric values are dropped rather than spread —
|
|
50
|
+
* an explicit `undefined` in the env layer would otherwise erase a real project budget and turn a
|
|
51
|
+
* configured assertion into an unconfigured one.
|
|
52
|
+
*/
|
|
53
|
+
export function resolvedBudgets(config) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const layer of [config.project.perf?.budgets, config.env.perf?.budgets]) {
|
|
56
|
+
for (const [key, value] of Object.entries(layer ?? {})) {
|
|
57
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
58
|
+
out[key] = value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The configured budget for one key, or a hard error naming it.
|
|
65
|
+
*
|
|
66
|
+
* PROVES the assertion that follows is gated on something real. A missing budget must never
|
|
67
|
+
* degrade to a skip or to `Infinity`: an unconfigured budget is the single failure mode that lets
|
|
68
|
+
* an entire perf suite report success while asserting nothing at all.
|
|
69
|
+
*/
|
|
70
|
+
export function requireBudget(config, key) {
|
|
71
|
+
const budget = resolvedBudgets(config)[key];
|
|
72
|
+
const envName = config.env.name ?? 'unknown';
|
|
73
|
+
if (typeof budget !== 'number' || !(budget > 0)) {
|
|
74
|
+
throw new SdodsError('CONFIG_INVALID', `Perf budget "${key}" is not configured for environment "${envName}".`, {
|
|
75
|
+
hint: `Set perf.budgets.${key} in the project's sdods.project.yaml, or override it in envs/${envName}.yaml. Without it this assertion would pass no matter how slow the application is.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return budget;
|
|
79
|
+
}
|
|
80
|
+
function requireVitalKey(name) {
|
|
81
|
+
if (VITAL_KEYS.includes(name))
|
|
82
|
+
return name;
|
|
83
|
+
throw new SdodsError('CONFIG_INVALID', `"${name}" is not a page vital SDODS records.`, {
|
|
84
|
+
hint: `Known vitals: ${VITAL_KEYS.join(', ')}. Each is also its key in perf.budgets.`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Measurements have to survive from a `When` step to a `Then` step. They do not go in
|
|
89
|
+
* `apiContext.vars`: that store doubles as a `{{…}}` template scope, so an array of samples parked
|
|
90
|
+
* there would leak into every later rendering. Keyed on the per-test `scenario` object, so two
|
|
91
|
+
* scenarios in parallel workers can never read each other's numbers and nothing outlives the
|
|
92
|
+
* scenario that measured it.
|
|
93
|
+
*/
|
|
94
|
+
const stores = new WeakMap();
|
|
95
|
+
export function perfStore(key) {
|
|
96
|
+
let store = stores.get(key);
|
|
97
|
+
if (!store) {
|
|
98
|
+
store = { recordings: [], sampleSets: [] };
|
|
99
|
+
stores.set(key, store);
|
|
100
|
+
}
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
function lastRecording(key) {
|
|
104
|
+
const recording = perfStore(key).recordings.at(-1);
|
|
105
|
+
if (!recording) {
|
|
106
|
+
throw new SdodsError('RUN_FAILED', 'No page vitals have been recorded in this scenario.', {
|
|
107
|
+
hint: 'Add `When I record the page vitals` after the navigation you want to measure.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return recording;
|
|
111
|
+
}
|
|
112
|
+
function lastSampleSet(key) {
|
|
113
|
+
const set = perfStore(key).sampleSets.at(-1);
|
|
114
|
+
if (!set) {
|
|
115
|
+
throw new SdodsError('RUN_FAILED', 'No latency samples have been taken in this scenario.', {
|
|
116
|
+
hint: 'Add `When I sample the latency of GET "/path" over 20 requests` before asserting a p95.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return set;
|
|
120
|
+
}
|
|
121
|
+
/* ── statistics ───────────────────────────────────────────────────────── */
|
|
122
|
+
/**
|
|
123
|
+
* Nearest-rank p95 — deterministic for a given set of samples, with no interpolation to argue
|
|
124
|
+
* about. Below 20 samples the nearest rank IS the maximum; the sample count travels in the
|
|
125
|
+
* attachment and in every failure message so a reader can see when "p95" means "slowest of five".
|
|
126
|
+
*
|
|
127
|
+
* Throws rather than returning 0 on an empty sample: a p95 of 0 slides under every budget.
|
|
128
|
+
*/
|
|
129
|
+
export function p95Of(msValues) {
|
|
130
|
+
if (msValues.length === 0) {
|
|
131
|
+
throw new SdodsError('RUN_FAILED', 'Cannot compute a p95 over an empty sample.', {
|
|
132
|
+
hint: 'Sample at least one request before asserting a p95.',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const sorted = [...msValues].sort((a, b) => a - b);
|
|
136
|
+
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1));
|
|
137
|
+
return sorted[index];
|
|
138
|
+
}
|
|
139
|
+
/* ── browser-side collection ──────────────────────────────────────────── */
|
|
140
|
+
/**
|
|
141
|
+
* How long the LCP observer is given to settle. LCP arrives as a stream of ever-larger candidates;
|
|
142
|
+
* `buffered: true` replays the ones already emitted and this window catches any still in flight.
|
|
143
|
+
* A fixed constant rather than a computed wait, so two runs of one scenario observe the same
|
|
144
|
+
* window and a baseline stays comparable.
|
|
145
|
+
*/
|
|
146
|
+
const LCP_SETTLE_MS = 500;
|
|
147
|
+
/** Bounded wait for the load event before reading `loadEventEnd`. */
|
|
148
|
+
const LOAD_WAIT_MS = 5_000;
|
|
149
|
+
/**
|
|
150
|
+
* Runs inside the page. Serialised by Playwright, so it closes over nothing but its argument — the
|
|
151
|
+
* type aliases above are erased at compile time and are safe to reference; the constants are not,
|
|
152
|
+
* which is why the settle window is passed in.
|
|
153
|
+
*/
|
|
154
|
+
export async function collectRawVitals(settleMs) {
|
|
155
|
+
const perf = performance;
|
|
156
|
+
const Observer = globalThis
|
|
157
|
+
.PerformanceObserver;
|
|
158
|
+
// Every vital is an offset from navigation start, so a genuine measurement is always > 0.
|
|
159
|
+
const positive = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null;
|
|
160
|
+
const supported = Observer?.supportedEntryTypes ?? [];
|
|
161
|
+
const lcpSupported = supported.includes('largest-contentful-paint');
|
|
162
|
+
const fcpSupported = supported.includes('paint');
|
|
163
|
+
// TRAP: paint timings can land AFTER the load event. Reading the paint entry eagerly while only
|
|
164
|
+
// LCP waited for the settle window reported `fcpMs: null` beside a real `lcpMs` on a page that
|
|
165
|
+
// had painted perfectly well — impossible, since first paint always precedes the largest one —
|
|
166
|
+
// and a budget assertion would then have failed it as "not measured". So nothing is read until
|
|
167
|
+
// the window closes, and the window is opened whether or not LCP is supported.
|
|
168
|
+
let largestPaint = 0;
|
|
169
|
+
await new Promise((resolve) => {
|
|
170
|
+
let observer;
|
|
171
|
+
try {
|
|
172
|
+
if (Observer && lcpSupported) {
|
|
173
|
+
observer = new Observer((list) => {
|
|
174
|
+
for (const entry of list.getEntries()) {
|
|
175
|
+
const start = entry.startTime;
|
|
176
|
+
if (typeof start === 'number' && start > largestPaint)
|
|
177
|
+
largestPaint = start;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
observer = undefined;
|
|
185
|
+
}
|
|
186
|
+
setTimeout(() => {
|
|
187
|
+
try {
|
|
188
|
+
observer?.disconnect();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* already gone */
|
|
192
|
+
}
|
|
193
|
+
resolve();
|
|
194
|
+
}, settleMs);
|
|
195
|
+
});
|
|
196
|
+
const nav = perf.getEntriesByType('navigation')[0];
|
|
197
|
+
const fcpEntry = perf.getEntriesByName('first-contentful-paint')[0];
|
|
198
|
+
const lcpMs = largestPaint > 0 ? Math.round(largestPaint) : null;
|
|
199
|
+
let totalResources = 0;
|
|
200
|
+
let totalBytes = 0;
|
|
201
|
+
for (const entry of perf.getEntriesByType('resource')) {
|
|
202
|
+
totalResources++;
|
|
203
|
+
const size = entry.transferSize;
|
|
204
|
+
if (typeof size === 'number' && Number.isFinite(size))
|
|
205
|
+
totalBytes += size;
|
|
206
|
+
}
|
|
207
|
+
const startTime = nav && typeof nav.startTime === 'number' ? nav.startTime : 0;
|
|
208
|
+
return {
|
|
209
|
+
hasNavigationEntry: Boolean(nav),
|
|
210
|
+
navigationStartEpochMs: nav && typeof perf.timeOrigin === 'number' ? Math.round(perf.timeOrigin + startTime) : null,
|
|
211
|
+
pageLoadMs: nav ? positive(nav.loadEventEnd) : null,
|
|
212
|
+
ttfbMs: nav ? positive(nav.responseStart) : null,
|
|
213
|
+
fcpMs: fcpEntry ? positive(fcpEntry.startTime) : null,
|
|
214
|
+
lcpMs,
|
|
215
|
+
domContentLoadedMs: nav ? positive(nav.domContentLoadedEventEnd) : null,
|
|
216
|
+
totalResources,
|
|
217
|
+
totalResourceSizeKB: Math.round(totalBytes / 1024),
|
|
218
|
+
lcpSupported,
|
|
219
|
+
fcpSupported,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Turns the page's answer into a recording, capturing WHY a vital is null while the browser's
|
|
224
|
+
* capabilities are still in hand. By the time a `Then` step fails, "lcpMs was not measured" on its
|
|
225
|
+
* own sends the reader hunting for a bug in a page that is merely running in a browser that does
|
|
226
|
+
* not implement LCP (WebKit). Support is read from `PerformanceObserver.supportedEntryTypes`, never
|
|
227
|
+
* assumed from the browser name.
|
|
228
|
+
*/
|
|
229
|
+
export function toRecording(raw, url, browserName) {
|
|
230
|
+
const vitals = {
|
|
231
|
+
pageLoadMs: raw.pageLoadMs,
|
|
232
|
+
lcpMs: raw.lcpMs,
|
|
233
|
+
fcpMs: raw.fcpMs,
|
|
234
|
+
ttfbMs: raw.ttfbMs,
|
|
235
|
+
};
|
|
236
|
+
const where = browserName ? ` in ${browserName}` : ' in this browser';
|
|
237
|
+
const unmeasured = {};
|
|
238
|
+
if (raw.pageLoadMs === null)
|
|
239
|
+
unmeasured.pageLoadMs = `the load event had not fired ${LOAD_WAIT_MS} ms after the vitals step began`;
|
|
240
|
+
if (raw.ttfbMs === null)
|
|
241
|
+
unmeasured.ttfbMs = 'the navigation entry reported no response start';
|
|
242
|
+
if (raw.fcpMs === null)
|
|
243
|
+
unmeasured.fcpMs = raw.fcpSupported
|
|
244
|
+
? 'the page painted no contentful frame'
|
|
245
|
+
: `paint timings are not implemented${where}`;
|
|
246
|
+
if (raw.lcpMs === null)
|
|
247
|
+
unmeasured.lcpMs = raw.lcpSupported
|
|
248
|
+
? 'the page produced no largest-contentful-paint candidate'
|
|
249
|
+
: `largest-contentful-paint is not implemented${where} — restrict the scenario to a browser that supports it`;
|
|
250
|
+
// The contract's three non-nullable fields carry 0 for an unmeasured value; the nullable
|
|
251
|
+
// `budgets.measured` block written alongside is what the assertions read, so a 0 here can never
|
|
252
|
+
// be mistaken for a measurement.
|
|
253
|
+
const metrics = {
|
|
254
|
+
url,
|
|
255
|
+
timestamp: new Date(raw.navigationStartEpochMs ?? Date.now()).toISOString(),
|
|
256
|
+
domContentLoaded: raw.domContentLoadedMs ?? 0,
|
|
257
|
+
pageLoadTime: raw.pageLoadMs ?? 0,
|
|
258
|
+
timeToFirstByte: raw.ttfbMs ?? 0,
|
|
259
|
+
totalResources: raw.totalResources,
|
|
260
|
+
totalResourceSizeKB: raw.totalResourceSizeKB,
|
|
261
|
+
firstContentfulPaint: raw.fcpMs,
|
|
262
|
+
largestContentfulPaint: raw.lcpMs,
|
|
263
|
+
};
|
|
264
|
+
return { url, vitals, unmeasured, metrics };
|
|
265
|
+
}
|
|
266
|
+
/** The measured value, or a failure naming why it is absent. Never returns 0-for-absent. */
|
|
267
|
+
function measured(recording, metric) {
|
|
268
|
+
const value = recording.vitals[metric];
|
|
269
|
+
expect(value, `${metric} was not measured on ${recording.url} — ${recording.unmeasured[metric] ?? 'the page reported no value'}. A budget cannot be asserted against a vital that never fired.`).not.toBeNull();
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* PROVES that the current navigation produced real page vitals, and pins them to this step so a
|
|
274
|
+
* later assertion has something that can actually fail. Records LCP / FCP / TTFB / load plus the
|
|
275
|
+
* resource count and weight, as the `PerformanceMetrics` contract, under `sdods/perf/<step>` — the
|
|
276
|
+
* name `attachmentNames.perf` has always defined and the DB ingest has always read into
|
|
277
|
+
* `steps.perf_json`, and which nothing had ever written.
|
|
278
|
+
*
|
|
279
|
+
* TRAP: recording before anything navigated. `performance.getEntriesByType('navigation')` is empty
|
|
280
|
+
* on a page that has not loaded a document, so every vital comes back null. That is step misuse
|
|
281
|
+
* rather than a slow application, so it fails here as `RUN_FAILED` naming the fix, instead of as
|
|
282
|
+
* four confusing assertion failures later.
|
|
283
|
+
*/
|
|
284
|
+
export const recordPageVitals = async ({ page, config, scenario, $bddContext, $testInfo, }) => {
|
|
285
|
+
// `pageLoadMs` reads `loadEventEnd`, which stays 0 until the load event fires — and the core
|
|
286
|
+
// navigation step settles on `domcontentloaded`. So wait, but briefly and never fatally: a page
|
|
287
|
+
// that never fires load leaves pageLoadMs null, which lets a scenario asserting only on TTFB run
|
|
288
|
+
// and makes a scenario asserting on pageLoadMs fail with the reason rather than the symptom.
|
|
289
|
+
await page.waitForLoadState('load', { timeout: LOAD_WAIT_MS }).catch(() => undefined);
|
|
290
|
+
const raw = await page.evaluate(collectRawVitals, LCP_SETTLE_MS);
|
|
291
|
+
if (!raw.hasNavigationEntry) {
|
|
292
|
+
throw new SdodsError('RUN_FAILED', `No navigation timing on ${page.url()} — nothing has been navigated to in this scenario.`, {
|
|
293
|
+
hint: 'Record the vitals AFTER the navigation you want to measure (for example `Given I navigate to the "home" page`).',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const recording = toRecording(raw, page.url(), scenario.data.browser);
|
|
297
|
+
perfStore(scenario).recordings.push(recording);
|
|
298
|
+
const file = scenario.file(scenarioFiles.perfJson($bddContext.stepIndex));
|
|
299
|
+
writeFileSync(file, JSON.stringify({
|
|
300
|
+
...recording.metrics,
|
|
301
|
+
// Budget-facing view. The keys are exactly the `perf.budgets` keys, `null` means NOT
|
|
302
|
+
// MEASURED, and the configured ceilings travel alongside so the artefact reads on its own —
|
|
303
|
+
// nobody has to go and find the yaml to know what failed and against what.
|
|
304
|
+
budgets: {
|
|
305
|
+
measured: recording.vitals,
|
|
306
|
+
unmeasured: recording.unmeasured,
|
|
307
|
+
configured: resolvedBudgets(config),
|
|
308
|
+
},
|
|
309
|
+
}, null, 2));
|
|
310
|
+
await $testInfo.attach(attachmentNames.perf($bddContext.stepIndex), {
|
|
311
|
+
path: file,
|
|
312
|
+
contentType: 'application/json',
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
When('I record the page vitals', recordPageVitals);
|
|
316
|
+
/**
|
|
317
|
+
* PROVES a recorded vital sits inside the ceiling THIS environment declares. The budget is read
|
|
318
|
+
* from `perf.budgets` and is never a step argument: a threshold written into a feature file ships
|
|
319
|
+
* unchanged to every environment, which is exactly what a per-environment budget exists to prevent.
|
|
320
|
+
*
|
|
321
|
+
* Ordered so the cheapest fix surfaces first: forgot the recording → typo'd the vital name →
|
|
322
|
+
* budget missing from config → vital never fired → the comparison itself.
|
|
323
|
+
*/
|
|
324
|
+
export const assertVitalWithinBudget = async ({ scenario, config, apiContext, env }, metricName) => {
|
|
325
|
+
const recording = lastRecording(scenario);
|
|
326
|
+
const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
|
|
327
|
+
const budget = requireBudget(config, metric);
|
|
328
|
+
const value = measured(recording, metric);
|
|
329
|
+
expect(value, `${metric} measured ${value} ms on ${recording.url}, against a budget of ${budget} ms for environment "${config.env.name ?? 'unknown'}"`).toBeLessThanOrEqual(budget);
|
|
330
|
+
};
|
|
331
|
+
Then('the recorded {string} should be within its configured budget', assertVitalWithinBudget);
|
|
332
|
+
/**
|
|
333
|
+
* PROVES a recorded vital sits under a limit this scenario owns. The visible escape hatch from the
|
|
334
|
+
* configured budget, for a page whose limit the environment-wide budget cannot express. Strictly
|
|
335
|
+
* `<`, matching `the response time should be under {int} ms`.
|
|
336
|
+
*/
|
|
337
|
+
export const assertVitalUnderThreshold = async ({ scenario, apiContext, env }, metricName, maxMs) => {
|
|
338
|
+
const recording = lastRecording(scenario);
|
|
339
|
+
const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
|
|
340
|
+
const value = measured(recording, metric);
|
|
341
|
+
expect(value, `${metric} measured ${value} ms on ${recording.url}, against a scenario-local limit of ${maxMs} ms`).toBeLessThan(maxMs);
|
|
342
|
+
};
|
|
343
|
+
Then('the recorded {string} should be under {int} ms', assertVitalUnderThreshold);
|
|
344
|
+
/**
|
|
345
|
+
* PROVES a second navigation is not slower than the first — the caching / warm-path claim.
|
|
346
|
+
* Compares the last two recordings of one vital, so a scenario records, acts, and records again.
|
|
347
|
+
*
|
|
348
|
+
* Carries an explicit tolerance because both sides are live measurements: the zero-tolerance form
|
|
349
|
+
* (`plus 0 ms`) is available, but it is the author's decision in the feature file rather than a
|
|
350
|
+
* hidden fudge factor here. Fails outright with fewer than two recordings, because comparing a
|
|
351
|
+
* recording with itself always passes.
|
|
352
|
+
*/
|
|
353
|
+
export const assertVitalNoWorseThanPrevious = async ({ scenario, apiContext, env }, metricName, toleranceMs) => {
|
|
354
|
+
const metric = requireVitalKey(render(metricName, apiContext.vars.toObject(), env.vars));
|
|
355
|
+
const recordings = perfStore(scenario).recordings;
|
|
356
|
+
if (recordings.length < 2) {
|
|
357
|
+
throw new SdodsError('RUN_FAILED', `Only ${recordings.length} page-vitals recording(s) in this scenario; comparing needs two.`, {
|
|
358
|
+
hint: 'Record the vitals once, do the thing that should be faster, then record them again.',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const previous = measured(recordings[recordings.length - 2], metric);
|
|
362
|
+
const latest = measured(recordings[recordings.length - 1], metric);
|
|
363
|
+
expect(latest, `${metric} went from ${previous} ms to ${latest} ms, beyond the ${toleranceMs} ms tolerance — the second pass did more work than the first`).toBeLessThanOrEqual(previous + toleranceMs);
|
|
364
|
+
};
|
|
365
|
+
Then('the recorded {string} should be no worse than the previous recording plus {int} ms', assertVitalNoWorseThanPrevious);
|
|
366
|
+
/**
|
|
367
|
+
* PROVES the budgets a perf suite depends on actually exist for the environment being run.
|
|
368
|
+
*
|
|
369
|
+
* This step guards the other steps. A missing or partial `perf.budgets` block is invisible
|
|
370
|
+
* everywhere else in a run: the release gate reads `perfBudgets: true`, the suite goes green, and
|
|
371
|
+
* nobody learns the ceiling was never set. Put this in the first scenario of a perf module and the
|
|
372
|
+
* gap fails loudly, once, naming the key.
|
|
373
|
+
*/
|
|
374
|
+
export const assertBudgetsConfigured = async ({ config, apiContext, env }, names) => {
|
|
375
|
+
const wanted = render(names, apiContext.vars.toObject(), env.vars)
|
|
376
|
+
.split(/[,\s]+/)
|
|
377
|
+
.filter(Boolean);
|
|
378
|
+
if (wanted.length === 0) {
|
|
379
|
+
throw new SdodsError('CONFIG_INVALID', 'No budget names were given to check.', {
|
|
380
|
+
hint: `Name the budgets the suite depends on, for example "pageLoadMs, lcpMs, ${API_P95_BUDGET_KEY}".`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
for (const key of wanted)
|
|
384
|
+
requireBudget(config, key);
|
|
385
|
+
};
|
|
386
|
+
Then('the perf budgets {string} should be configured', assertBudgetsConfigured);
|
|
387
|
+
/**
|
|
388
|
+
* Sequential, deliberately. Firing N requests in parallel measures how the server behaves under a
|
|
389
|
+
* burst of N, which is a different question from how long one request takes; a p95 built that way
|
|
390
|
+
* moves with the sample size and is not comparable between runs.
|
|
391
|
+
*
|
|
392
|
+
* Goes through `api.send` rather than a raw fetch, so every sample inherits the env base URL, the
|
|
393
|
+
* env and scenario headers, the query parameters and the auth — whatever authenticated the
|
|
394
|
+
* scenario authenticates the sample. Two options are pinned:
|
|
395
|
+
* · `silent: true` — samples stay out of `apiContext.history`, so `Then the response status
|
|
396
|
+
* should be …` still refers to the request the scenario actually made, and twenty samples do
|
|
397
|
+
* not produce forty attachments.
|
|
398
|
+
* · `retries: 0` — the client retries once on CI by default. A retried sample would time the
|
|
399
|
+
* second attempt and quietly swallow the first failure, which is the opposite of measuring.
|
|
400
|
+
*/
|
|
401
|
+
async function collectSamples(fx, method, pathTemplate, times, bodyTemplate) {
|
|
402
|
+
if (!Number.isInteger(times) || times < 1) {
|
|
403
|
+
throw new SdodsError('RUN_FAILED', `Cannot sample a latency distribution ${times} times.`, {
|
|
404
|
+
hint: 'Sample at least once. Below 20 requests the nearest-rank p95 is simply the slowest sample.',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const scopes = [fx.apiContext.vars.toObject(), fx.env.vars];
|
|
408
|
+
const path = render(pathTemplate, ...scopes);
|
|
409
|
+
const body = bodyTemplate === undefined ? undefined : renderJson(bodyTemplate, ...scopes);
|
|
410
|
+
const samples = [];
|
|
411
|
+
for (let i = 0; i < times; i++) {
|
|
412
|
+
const snapshot = await fx.api.send(method, path, { body, silent: true, retries: 0 });
|
|
413
|
+
// TRAP: `silent` suppresses recording but NOT HAR replay, so under `--har-replay` every sample
|
|
414
|
+
// would return the response time baked into the recording. A p95 over a HAR file measures the
|
|
415
|
+
// file. This is reachable exactly where it matters — `release:check` runs `--har-replay
|
|
416
|
+
// --strict` and the release gate declares `perfBudgets: true` — so it fails loudly instead.
|
|
417
|
+
if (snapshot.replayedFromHar) {
|
|
418
|
+
throw new SdodsError('NOT_SUPPORTED', `${method} ${path} was replayed from a HAR, so its ${snapshot.response.responseTime} ms is the recording's, not this environment's.`, {
|
|
419
|
+
hint: 'Take latency samples against a live environment: drop @har from the scenario, or run it without --har-replay.',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
samples.push({ status: snapshot.response.status, ms: snapshot.response.responseTime });
|
|
423
|
+
}
|
|
424
|
+
const set = {
|
|
425
|
+
method,
|
|
426
|
+
path,
|
|
427
|
+
samples,
|
|
428
|
+
p95Ms: p95Of(samples.map((s) => s.ms)),
|
|
429
|
+
};
|
|
430
|
+
perfStore(fx.scenario).sampleSets.push(set);
|
|
431
|
+
// Not `attachmentNames.perf`: that name is typed as `PerformanceMetrics` all the way into
|
|
432
|
+
// `steps.perf_json`, and a latency distribution is a different shape. This lands as a plain
|
|
433
|
+
// attachment until the shared contract grows a name for it.
|
|
434
|
+
const stepIndex = fx.$bddContext.stepIndex;
|
|
435
|
+
const file = fx.scenario.file(`perf/${pad2(stepIndex)}-latency-sample.json`);
|
|
436
|
+
const durations = samples.map((s) => s.ms);
|
|
437
|
+
writeFileSync(file, JSON.stringify({
|
|
438
|
+
method,
|
|
439
|
+
path,
|
|
440
|
+
count: samples.length,
|
|
441
|
+
p95Ms: set.p95Ms,
|
|
442
|
+
minMs: Math.min(...durations),
|
|
443
|
+
maxMs: Math.max(...durations),
|
|
444
|
+
statuses: samples.map((s) => s.status),
|
|
445
|
+
samples,
|
|
446
|
+
}, null, 2));
|
|
447
|
+
await fx.$testInfo.attach(`sdods/perf-sample/${pad2(stepIndex)}`, {
|
|
448
|
+
path: file,
|
|
449
|
+
contentType: 'application/json',
|
|
450
|
+
});
|
|
451
|
+
return set;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* PROVES an endpoint's latency DISTRIBUTION rather than one lucky request. `the response time
|
|
455
|
+
* should be under {int} ms` (api.steps.ts) times a single call — the measurement most likely to be
|
|
456
|
+
* a cold start or a cache hit. A p95 needs a sample, and SDODS had no way to take one.
|
|
457
|
+
*/
|
|
458
|
+
export const sampleLatency = async ({ api, apiContext, env, scenario, $bddContext, $testInfo }, method, path, times) => {
|
|
459
|
+
await collectSamples({ api, apiContext, env, scenario, $bddContext, $testInfo }, method, path, times);
|
|
460
|
+
};
|
|
461
|
+
When('I sample the latency of {method} {string} over {int} requests', sampleLatency);
|
|
462
|
+
/** PROVES the same for a write path, where the latency that matters belongs to a real payload. */
|
|
463
|
+
export const sampleLatencyWithBody = async ({ api, apiContext, env, scenario, $bddContext, $testInfo }, method, path, times, body) => {
|
|
464
|
+
await collectSamples({ api, apiContext, env, scenario, $bddContext, $testInfo }, method, path, times, body);
|
|
465
|
+
};
|
|
466
|
+
When('I sample the latency of {method} {string} over {int} requests with body:', sampleLatencyWithBody);
|
|
467
|
+
/**
|
|
468
|
+
* PROVES the sampled p95 sits inside the environment's `apiP95Ms` ceiling. The budget key is fixed
|
|
469
|
+
* and read from config — nothing to mistype in the feature file, nothing to drift.
|
|
470
|
+
*
|
|
471
|
+
* Says nothing about whether the responses were correct: a p95 over twenty fast 500s passes this,
|
|
472
|
+
* and should. Pair it with `every latency sample should have returned status 200`, which is the
|
|
473
|
+
* step that proves the server was doing the work being timed.
|
|
474
|
+
*/
|
|
475
|
+
export const assertP95WithinBudget = async ({ scenario, config, }) => {
|
|
476
|
+
const set = lastSampleSet(scenario);
|
|
477
|
+
const budget = requireBudget(config, API_P95_BUDGET_KEY);
|
|
478
|
+
expect(set.p95Ms, `p95 was ${set.p95Ms} ms over ${set.samples.length} samples of ${set.method} ${set.path}, against an ${API_P95_BUDGET_KEY} budget of ${budget} ms for environment "${config.env.name ?? 'unknown'}"`).toBeLessThanOrEqual(budget);
|
|
479
|
+
};
|
|
480
|
+
Then('the sampled p95 should be within the configured apiP95Ms budget', assertP95WithinBudget);
|
|
481
|
+
/** PROVES the sampled p95 sits under a limit this scenario owns. Strictly `<`, as with the vitals. */
|
|
482
|
+
export const assertP95UnderThreshold = async ({ scenario }, maxMs) => {
|
|
483
|
+
const set = lastSampleSet(scenario);
|
|
484
|
+
expect(set.p95Ms, `p95 was ${set.p95Ms} ms over ${set.samples.length} samples of ${set.method} ${set.path}, against a scenario-local limit of ${maxMs} ms`).toBeLessThan(maxMs);
|
|
485
|
+
};
|
|
486
|
+
Then('the sampled p95 should be under {int} ms', assertP95UnderThreshold);
|
|
487
|
+
/**
|
|
488
|
+
* PROVES that whatever changed between two samples did not make the endpoint slower — a warmed
|
|
489
|
+
* cache, a new index, a refusal being cheaper to serve than a response. Compares the last two
|
|
490
|
+
* sample sets in this scenario; fails outright with only one, because comparing a sample with
|
|
491
|
+
* itself always passes.
|
|
492
|
+
*/
|
|
493
|
+
export const assertP95NoWorseThanPrevious = async ({ scenario }, toleranceMs) => {
|
|
494
|
+
const sets = perfStore(scenario).sampleSets;
|
|
495
|
+
if (sets.length < 2) {
|
|
496
|
+
throw new SdodsError('RUN_FAILED', `Only ${sets.length} latency sample(s) in this scenario; comparing needs two.`, { hint: 'Sample once, change the condition under test, then sample again.' });
|
|
497
|
+
}
|
|
498
|
+
const previous = sets[sets.length - 2].p95Ms;
|
|
499
|
+
const latest = sets[sets.length - 1].p95Ms;
|
|
500
|
+
expect(latest, `p95 went from ${previous} ms to ${latest} ms over ${sets[sets.length - 1].samples.length} samples, beyond the ${toleranceMs} ms tolerance`).toBeLessThanOrEqual(previous + toleranceMs);
|
|
501
|
+
};
|
|
502
|
+
Then('the sampled p95 should be no worse than the previous sample plus {int} ms', assertP95NoWorseThanPrevious);
|
|
503
|
+
/**
|
|
504
|
+
* PROVES the timed responses were the ones the scenario meant to time. Without it a latency budget
|
|
505
|
+
* is happily met by an endpoint that 500s in 3 ms, or that 401s because the sample lost the
|
|
506
|
+
* scenario's auth. Lists the offending statuses, because "not all 200" sends nobody anywhere.
|
|
507
|
+
*/
|
|
508
|
+
export const assertEverySampleStatus = async ({ scenario }, status) => {
|
|
509
|
+
const set = lastSampleSet(scenario);
|
|
510
|
+
const wrong = set.samples.map((s) => s.status).filter((s) => s !== status);
|
|
511
|
+
expect(wrong, `${wrong.length} of ${set.samples.length} samples of ${set.method} ${set.path} did not return ${status} (got ${JSON.stringify([...new Set(wrong)])})`).toHaveLength(0);
|
|
512
|
+
};
|
|
513
|
+
Then('every latency sample should have returned status {int}', assertEverySampleStatus);
|
|
514
|
+
//# sourceMappingURL=perf.steps.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import './params.js';
|
|
3
|
+
import { Given, Then, When } from '../fixtures/test.js';
|
|
4
|
+
import { render } from '../api/template.js';
|
|
5
|
+
import { SdodsError } from '../errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Multi-tab and popup steps.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN — the ACTION THAT OPENS the tab and the WAIT for it are one step, not
|
|
10
|
+
* two. `waitForEvent('page')` after the click is a race: a fast popup opens
|
|
11
|
+
* before the listener is attached and the wait then times out on a tab that is
|
|
12
|
+
* already sitting there. Every step here that expects a new tab installs the
|
|
13
|
+
* listener first and performs the action second, which is the only ordering
|
|
14
|
+
* that is correct for both fast and slow popups.
|
|
15
|
+
*
|
|
16
|
+
* The suite's `page` fixture stays pointed at the original tab throughout.
|
|
17
|
+
* Switching is explicit — `I switch to the tab ...` — because a step library
|
|
18
|
+
* that silently re-points `page` makes every later assertion ambiguous about
|
|
19
|
+
* which document it read.
|
|
20
|
+
*/
|
|
21
|
+
const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
|
|
22
|
+
/** The tab a scenario is currently addressing, when it is not the original. */
|
|
23
|
+
const active = new WeakMap();
|
|
24
|
+
/** The page later steps should act on: the switched-to tab, or the original. */
|
|
25
|
+
export function activePage(page) {
|
|
26
|
+
const t = active.get(page);
|
|
27
|
+
return t && !t.isClosed() ? t : page;
|
|
28
|
+
}
|
|
29
|
+
async function openedBy(page, action, what) {
|
|
30
|
+
// Listener first, action second. The reverse loses a popup that opens
|
|
31
|
+
// synchronously, and the resulting timeout blames the wait rather than the
|
|
32
|
+
// ordering.
|
|
33
|
+
const waiter = page.context().waitForEvent('page');
|
|
34
|
+
await action();
|
|
35
|
+
const opened = await waiter.catch(() => undefined);
|
|
36
|
+
if (!opened) {
|
|
37
|
+
throw new SdodsError('NOT_SUPPORTED', `No new tab opened after ${what}.`, {
|
|
38
|
+
hint: 'If the target opens in the same tab, assert the URL instead. If it is blocked by a popup blocker, the context needs to allow it.',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
await opened.waitForLoadState('domcontentloaded');
|
|
42
|
+
active.set(page, opened);
|
|
43
|
+
return opened;
|
|
44
|
+
}
|
|
45
|
+
/* ── opening ──────────────────────────────────────────────────────────── */
|
|
46
|
+
When('I open a new tab by clicking the {role} {string}', async ({ page, apiContext, env }, role, name) => {
|
|
47
|
+
const label = render(name, ...scopesOf(apiContext, env));
|
|
48
|
+
await openedBy(page, () => activePage(page).getByRole(role, { name: label }).first().click(), `clicking the ${role} "${label}"`);
|
|
49
|
+
});
|
|
50
|
+
When('I open a new tab by clicking the element {string}', async ({ page, apiContext, env }, selector) => {
|
|
51
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
52
|
+
await openedBy(page, () => activePage(page).locator(sel).first().click(), `clicking "${sel}"`);
|
|
53
|
+
});
|
|
54
|
+
/* ── switching ────────────────────────────────────────────────────────── */
|
|
55
|
+
Given('I switch to the tab with URL containing {string}', async ({ page, apiContext, env }, part) => {
|
|
56
|
+
const needle = render(part, ...scopesOf(apiContext, env));
|
|
57
|
+
const found = page
|
|
58
|
+
.context()
|
|
59
|
+
.pages()
|
|
60
|
+
.find((p) => p.url().includes(needle));
|
|
61
|
+
if (!found) {
|
|
62
|
+
throw new SdodsError('NOT_SUPPORTED', `No open tab has a URL containing "${needle}".`, {
|
|
63
|
+
hint: `Open tabs: ${page
|
|
64
|
+
.context()
|
|
65
|
+
.pages()
|
|
66
|
+
.map((p) => p.url())
|
|
67
|
+
.join(', ') || '(none)'}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
await found.bringToFront();
|
|
71
|
+
active.set(page, found);
|
|
72
|
+
});
|
|
73
|
+
Given('I switch back to the original tab', async ({ page }) => {
|
|
74
|
+
active.delete(page);
|
|
75
|
+
await page.bringToFront();
|
|
76
|
+
});
|
|
77
|
+
When('I close the current tab', async ({ page }) => {
|
|
78
|
+
const current = activePage(page);
|
|
79
|
+
if (current === page) {
|
|
80
|
+
throw new SdodsError('NOT_SUPPORTED', 'Refusing to close the original tab.', {
|
|
81
|
+
hint: 'The original tab is the scenario’s page fixture; closing it would fail every later step with an unrelated error.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await current.close();
|
|
85
|
+
active.delete(page);
|
|
86
|
+
});
|
|
87
|
+
/* ── asserting ────────────────────────────────────────────────────────── */
|
|
88
|
+
Then('there should be {int} open tab(s)', async ({ page }, count) => {
|
|
89
|
+
await expect
|
|
90
|
+
.poll(() => page
|
|
91
|
+
.context()
|
|
92
|
+
.pages()
|
|
93
|
+
.filter((p) => !p.isClosed()).length, {
|
|
94
|
+
message: `the context should have ${count} open tab(s)`,
|
|
95
|
+
})
|
|
96
|
+
.toBe(count);
|
|
97
|
+
});
|
|
98
|
+
Then('the current tab URL should contain {string}', async ({ page, apiContext, env }, part) => {
|
|
99
|
+
const needle = render(part, ...scopesOf(apiContext, env));
|
|
100
|
+
await expect
|
|
101
|
+
.poll(() => activePage(page).url(), { message: `the tab URL should contain "${needle}"` })
|
|
102
|
+
.toContain(needle);
|
|
103
|
+
});
|
|
104
|
+
Then('the current tab should contain the text {string}', async ({ page, apiContext, env }, text) => {
|
|
105
|
+
await expect(activePage(page)
|
|
106
|
+
.getByText(render(text, ...scopesOf(apiContext, env)))
|
|
107
|
+
.first()).toBeVisible();
|
|
108
|
+
});
|
|
109
|
+
//# sourceMappingURL=tabs.steps.js.map
|