@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.
Files changed (55) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/propose.js +32 -10
  4. package/dist/analyze/scan.js +19 -1
  5. package/dist/api/client.js +7 -1
  6. package/dist/auth/capture.js +21 -7
  7. package/dist/auth/index.js +71 -12
  8. package/dist/config/resolve.d.ts +13 -0
  9. package/dist/config/resolve.js +1 -0
  10. package/dist/config/runner.js +3 -0
  11. package/dist/config/tags.d.ts +29 -1
  12. package/dist/config/tags.js +46 -0
  13. package/dist/data/provider.js +5 -1
  14. package/dist/data/user-pool.js +35 -3
  15. package/dist/fixtures/api-context.d.ts +14 -1
  16. package/dist/fixtures/api-context.js +13 -0
  17. package/dist/fixtures/auth.d.ts +9 -1
  18. package/dist/fixtures/auth.js +13 -5
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/har/api-har.d.ts +1 -0
  22. package/dist/har/api-har.js +1 -1
  23. package/dist/har/index.d.ts +1 -0
  24. package/dist/har/index.js +1 -0
  25. package/dist/har/scrub.d.ts +17 -0
  26. package/dist/har/scrub.js +60 -0
  27. package/dist/reporters/dashboard.d.ts +86 -0
  28. package/dist/reporters/dashboard.js +319 -61
  29. package/dist/steps/a11y.steps.d.ts +180 -0
  30. package/dist/steps/a11y.steps.js +598 -0
  31. package/dist/steps/api.steps.js +5 -1
  32. package/dist/steps/browser.steps.d.ts +27 -0
  33. package/dist/steps/browser.steps.js +653 -0
  34. package/dist/steps/clock.steps.d.ts +4 -0
  35. package/dist/steps/clock.steps.js +73 -0
  36. package/dist/steps/data.steps.js +50 -2
  37. package/dist/steps/db.steps.d.ts +5 -0
  38. package/dist/steps/db.steps.js +105 -0
  39. package/dist/steps/dom.steps.d.ts +2 -0
  40. package/dist/steps/dom.steps.js +583 -0
  41. package/dist/steps/iframe.steps.d.ts +2 -0
  42. package/dist/steps/iframe.steps.js +93 -0
  43. package/dist/steps/index.d.ts +10 -0
  44. package/dist/steps/index.js +10 -0
  45. package/dist/steps/net.steps.d.ts +63 -0
  46. package/dist/steps/net.steps.js +728 -0
  47. package/dist/steps/perf.steps.d.ts +248 -0
  48. package/dist/steps/perf.steps.js +514 -0
  49. package/dist/steps/tabs.steps.d.ts +5 -0
  50. package/dist/steps/tabs.steps.js +109 -0
  51. package/dist/steps/webhook.steps.d.ts +46 -0
  52. package/dist/steps/webhook.steps.js +129 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. 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,5 @@
1
+ import { type Page } from '@playwright/test';
2
+ import './params.js';
3
+ /** The page later steps should act on: the switched-to tab, or the original. */
4
+ export declare function activePage(page: Page): Page;
5
+ //# sourceMappingURL=tabs.steps.d.ts.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