@sdods/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/a11y/audit.d.ts +88 -0
  3. package/dist/a11y/audit.js +134 -0
  4. package/dist/config/defaults.js +2 -0
  5. package/dist/config/resolve.js +1 -1
  6. package/dist/data/index.d.ts +1 -0
  7. package/dist/data/index.js +1 -0
  8. package/dist/data/pool-capacity.d.ts +58 -0
  9. package/dist/data/pool-capacity.js +138 -0
  10. package/dist/data/user-pool.d.ts +43 -2
  11. package/dist/data/user-pool.js +170 -40
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/fixtures/scenario.d.ts +11 -0
  14. package/dist/fixtures/scenario.js +15 -0
  15. package/dist/fixtures/test.js +24 -3
  16. package/dist/fixtures/types.d.ts +2 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/matrix/index.d.ts +13 -0
  20. package/dist/matrix/index.js +24 -3
  21. package/dist/perf/vitals.d.ts +118 -0
  22. package/dist/perf/vitals.js +236 -0
  23. package/dist/quality/a11y-scenario.d.ts +68 -0
  24. package/dist/quality/a11y-scenario.js +63 -0
  25. package/dist/quality/gates.d.ts +54 -0
  26. package/dist/quality/gates.js +144 -0
  27. package/dist/quality/hooks.d.ts +2 -0
  28. package/dist/quality/hooks.js +38 -0
  29. package/dist/quality/index.d.ts +4 -0
  30. package/dist/quality/index.js +2 -0
  31. package/dist/quality/perf-scenario.d.ts +132 -0
  32. package/dist/quality/perf-scenario.js +241 -0
  33. package/dist/shots/baselines.d.ts +72 -0
  34. package/dist/shots/baselines.js +201 -0
  35. package/dist/shots/hooks.js +2 -2
  36. package/dist/shots/index.d.ts +1 -0
  37. package/dist/shots/index.js +1 -0
  38. package/dist/shots/narrator.d.ts +10 -2
  39. package/dist/shots/narrator.js +106 -10
  40. package/dist/shots/policy.d.ts +5 -1
  41. package/dist/shots/policy.js +2 -0
  42. package/dist/steps/a11y.steps.d.ts +18 -51
  43. package/dist/steps/a11y.steps.js +6 -107
  44. package/dist/steps/data.steps.js +17 -5
  45. package/dist/steps/hooks.steps.d.ts +1 -0
  46. package/dist/steps/hooks.steps.js +3 -1
  47. package/dist/steps/index.d.ts +1 -0
  48. package/dist/steps/index.js +1 -0
  49. package/dist/steps/perf.steps.d.ts +3 -129
  50. package/dist/steps/perf.steps.js +4 -227
  51. package/dist/steps/ui.steps.js +5 -0
  52. package/package.json +3 -3
@@ -5,6 +5,10 @@ import { Then, When } from '../fixtures/test.js';
5
5
  import { attachmentNames, pad2, scenarioFiles } from '@sdods/contracts';
6
6
  import { renderJson, renderStrict } from '../api/template.js';
7
7
  import { SdodsError } from '../errors.js';
8
+ import { API_P95_BUDGET_KEY, LCP_SETTLE_MS, LOAD_WAIT_MS, collectRawVitals, lastRecording, lastSampleSet, p95Of, perfStore, requireBudget, requireVitalKey, resolvedBudgets, toRecording, } from '../perf/vitals.js';
9
+ // The measurement and budget plumbing lives in `../perf/vitals.ts` so the `@perf` tag hook can
10
+ // share it without importing (and so re-registering) this step file. Re-exported for importers.
11
+ export { API_P95_BUDGET_KEY, VITAL_KEYS, collectRawVitals, p95Of, perfStore, requireBudget, resolvedBudgets, toRecording, } from '../perf/vitals.js';
8
12
  /**
9
13
  * Performance budgets.
10
14
  *
@@ -36,233 +40,6 @@ import { SdodsError } from '../errors.js';
36
40
  * text: `under {int} ms` is strict `<` (matching `the response time should be under {int} ms` in
37
41
  * api.steps.ts), `within its configured budget` is `<=` — a budget is a ceiling you may sit on.
38
42
  */
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
43
  /** The measured value, or a failure naming why it is absent. Never returns 0-for-absent. */
267
44
  function measured(recording, metric) {
268
45
  const value = recording.vitals[metric];
@@ -4,6 +4,7 @@ import { Given, Then, When } from '../fixtures/test.js';
4
4
  import { render, renderJson, renderStrict } from '../api/template.js';
5
5
  import { withActionRole } from '../heal/strategies.js';
6
6
  import { SdodsError } from '../errors.js';
7
+ import { parseMaskArgument } from '../shots/baselines.js';
7
8
  const scopesOf = (apiContext, env) => [
8
9
  apiContext.vars.toObject(),
9
10
  env.vars,
@@ -152,6 +153,10 @@ Then('the page title should contain {string}', async ({ page, apiContext, env },
152
153
  Then('the page should match the visual baseline {string}', async ({ shots, $bddContext, apiContext, env }, name) => {
153
154
  await shots.visual(arg({ apiContext, env }, name), $bddContext.stepIndex);
154
155
  });
156
+ /** Masks comma-separated selectors on top of `screenshots.mask` and the baseline's own mask. */
157
+ Then('the page should match the visual baseline {string} masking {string}', async ({ shots, $bddContext, apiContext, env }, name, selectors) => {
158
+ await shots.visual(arg({ apiContext, env }, name), $bddContext.stepIndex, parseMaskArgument(arg({ apiContext, env }, selectors)));
159
+ });
155
160
  /* ── network mocking ──────────────────────────────────────────────────── */
156
161
  When('I mock {string} with JSON:', async ({ page, apiContext, env }, urlGlob, json) => {
157
162
  const body = renderJson(json, ...scopesOf(apiContext, env));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -43,7 +43,7 @@
43
43
  "playwright-bdd": ">=9"
44
44
  },
45
45
  "dependencies": {
46
- "@sdods/contracts": "0.8.0",
46
+ "@sdods/contracts": "0.9.0",
47
47
  "@cucumber/cucumber-expressions": "19.0.0",
48
48
  "@cucumber/gherkin": "^42.0.1",
49
49
  "@cucumber/messages": "^34.2.1",
@@ -61,7 +61,7 @@
61
61
  "tsx": "^4.23.0",
62
62
  "yaml": "^2.9.0",
63
63
  "zod": "^4.5.4",
64
- "@sdods/db": "0.8.0"
64
+ "@sdods/db": "0.9.0"
65
65
  },
66
66
  "homepage": "https://sdods.com",
67
67
  "bugs": {