@sdods/core 0.2.2 → 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 (52) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/index.d.ts +0 -1
  4. package/dist/analyze/index.js +0 -1
  5. package/dist/analyze/propose.js +80 -38
  6. package/dist/analyze/scan.js +19 -1
  7. package/dist/api/client.js +7 -1
  8. package/dist/auth/capture.js +19 -6
  9. package/dist/auth/index.js +23 -2
  10. package/dist/config/resolve.d.ts +13 -0
  11. package/dist/config/resolve.js +1 -0
  12. package/dist/config/tags.d.ts +29 -1
  13. package/dist/config/tags.js +46 -0
  14. package/dist/data/provider.js +5 -1
  15. package/dist/data/user-pool.js +35 -3
  16. package/dist/fixtures/api-context.d.ts +14 -1
  17. package/dist/fixtures/api-context.js +13 -0
  18. package/dist/fixtures/scenario.js +1 -4
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/reporters/dashboard.d.ts +86 -0
  22. package/dist/reporters/dashboard.js +319 -61
  23. package/dist/shots/hooks.js +0 -10
  24. package/dist/steps/a11y.steps.d.ts +180 -0
  25. package/dist/steps/a11y.steps.js +598 -0
  26. package/dist/steps/api.steps.js +5 -1
  27. package/dist/steps/browser.steps.d.ts +27 -0
  28. package/dist/steps/browser.steps.js +653 -0
  29. package/dist/steps/clock.steps.d.ts +4 -0
  30. package/dist/steps/clock.steps.js +73 -0
  31. package/dist/steps/data.steps.js +50 -2
  32. package/dist/steps/db.steps.d.ts +5 -0
  33. package/dist/steps/db.steps.js +105 -0
  34. package/dist/steps/dom.steps.d.ts +2 -0
  35. package/dist/steps/dom.steps.js +583 -0
  36. package/dist/steps/iframe.steps.d.ts +2 -0
  37. package/dist/steps/iframe.steps.js +93 -0
  38. package/dist/steps/index.d.ts +10 -0
  39. package/dist/steps/index.js +10 -0
  40. package/dist/steps/net.steps.d.ts +63 -0
  41. package/dist/steps/net.steps.js +728 -0
  42. package/dist/steps/perf.steps.d.ts +248 -0
  43. package/dist/steps/perf.steps.js +514 -0
  44. package/dist/steps/tabs.steps.d.ts +5 -0
  45. package/dist/steps/tabs.steps.js +109 -0
  46. package/dist/steps/webhook.steps.d.ts +46 -0
  47. package/dist/steps/webhook.steps.js +129 -0
  48. package/package.json +3 -4
  49. package/dist/analyze/modules.d.ts +0 -74
  50. package/dist/analyze/modules.js +0 -353
  51. package/dist/config/playwright.d.ts +0 -37
  52. package/dist/config/playwright.js +0 -262
@@ -0,0 +1,248 @@
1
+ import './params.js';
2
+ import type { Page, TestInfo } from '@playwright/test';
3
+ import type { EnvConfig, PerformanceMetrics } from '@sdods/contracts';
4
+ import type { ApiClient, HttpMethod } from '../api/client.js';
5
+ import type { ApiContext } from '../fixtures/api-context.js';
6
+ import type { ScenarioMeta } from '../fixtures/scenario.js';
7
+ /**
8
+ * Performance budgets.
9
+ *
10
+ * `PerfBudgetsSchema` (pageLoadMs / lcpMs / fcpMs / ttfbMs / apiP95Ms), the `perf.budgets` block in
11
+ * the project and env yaml, the `perfBudgets: true` release gate and `attachmentNames.perf` all
12
+ * existed before this file; nothing read any of them. These steps close that loop.
13
+ *
14
+ * Two rules run through every step here, both learned from hand-written project steps that got
15
+ * them wrong:
16
+ *
17
+ * 1. A BUDGET IS READ FROM CONFIG, NEVER FROM THE FEATURE FILE. A number typed into a `.feature`
18
+ * is a number that drifts from the environment it runs on — staging and a laptop do not share
19
+ * a page-load budget. `Then the response time should be under {int} ms` (api.steps.ts) is the
20
+ * literal-threshold form and stays exactly as it is; the budget-driven forms below are new
21
+ * patterns, not a widening of it. A local literal is still reachable where a scenario genuinely
22
+ * needs one — `should be under {int} ms` — so the escape hatch is visible in the step text.
23
+ * 2. AN UNMEASURED VITAL FAILS. LCP does not fire on a page with no contentful paint, and WebKit
24
+ * does not implement it at all; paint timings and the navigation entry can be missing too.
25
+ * Recording those as `0` makes every budget assertion pass — a whole perf module
26
+ * goes green while measuring nothing. So a vital that did not fire is recorded as `null`
27
+ * together with the reason, and asserting on it FAILS naming that reason. A real navigation
28
+ * cannot produce a genuine 0 ms vital (they are all offsets from navigation start), so treating
29
+ * "absent or 0" as "not measured" loses no honest signal.
30
+ *
31
+ * A budget that is not configured is likewise a hard `CONFIG_INVALID` naming the missing key, not
32
+ * a skip: silently skipping is the same vacuous green in a different costume.
33
+ *
34
+ * The two comparison operators are deliberately different and deliberately visible in the step
35
+ * text: `under {int} ms` is strict `<` (matching `the response time should be under {int} ms` in
36
+ * api.steps.ts), `within its configured budget` is `<=` — a budget is a ceiling you may sit on.
37
+ */
38
+ /** The page vitals SDODS records. Each name is also its key in `perf.budgets`. */
39
+ export declare const VITAL_KEYS: readonly ["pageLoadMs", "lcpMs", "fcpMs", "ttfbMs"];
40
+ export type VitalKey = (typeof VITAL_KEYS)[number];
41
+ /** The `perf.budgets` key a sampled API latency distribution is judged against. */
42
+ export declare const API_P95_BUDGET_KEY = "apiP95Ms";
43
+ type BudgetLayer = {
44
+ budgets?: Record<string, number | undefined>;
45
+ } | undefined;
46
+ /** The slice of a resolved config these steps read. Nothing here writes config. */
47
+ export interface BudgetConfig {
48
+ project: {
49
+ perf?: BudgetLayer;
50
+ };
51
+ env: {
52
+ name?: string;
53
+ perf?: BudgetLayer;
54
+ };
55
+ }
56
+ /**
57
+ * Effective budgets, env overriding project per key.
58
+ *
59
+ * `resolveConfig` already deep-merges `env.perf` into `config.project.perf`, so on a resolved
60
+ * config the project side is the whole answer. Merging the env side again is idempotent and keeps
61
+ * this correct for a config assembled by hand. Non-numeric values are dropped rather than spread —
62
+ * an explicit `undefined` in the env layer would otherwise erase a real project budget and turn a
63
+ * configured assertion into an unconfigured one.
64
+ */
65
+ export declare function resolvedBudgets(config: BudgetConfig): Record<string, number>;
66
+ /**
67
+ * The configured budget for one key, or a hard error naming it.
68
+ *
69
+ * PROVES the assertion that follows is gated on something real. A missing budget must never
70
+ * degrade to a skip or to `Infinity`: an unconfigured budget is the single failure mode that lets
71
+ * an entire perf suite report success while asserting nothing at all.
72
+ */
73
+ export declare function requireBudget(config: BudgetConfig, key: string): number;
74
+ /** One `When I record the page vitals`. `null` in `vitals` means NOT MEASURED, never "0 ms". */
75
+ export interface VitalsRecording {
76
+ url: string;
77
+ vitals: Record<VitalKey, number | null>;
78
+ /** Why a vital is null, keyed by vital. Surfaced verbatim in the failure message. */
79
+ unmeasured: Partial<Record<VitalKey, string>>;
80
+ /** The contract shape written to `sdods/perf/<step>` and ingested into `steps.perf_json`. */
81
+ metrics: PerformanceMetrics;
82
+ }
83
+ export interface LatencySample {
84
+ status: number;
85
+ ms: number;
86
+ }
87
+ export interface LatencySampleSet {
88
+ method: string;
89
+ path: string;
90
+ samples: LatencySample[];
91
+ p95Ms: number;
92
+ }
93
+ interface PerfStore {
94
+ recordings: VitalsRecording[];
95
+ sampleSets: LatencySampleSet[];
96
+ }
97
+ export declare function perfStore(key: object): PerfStore;
98
+ /**
99
+ * Nearest-rank p95 — deterministic for a given set of samples, with no interpolation to argue
100
+ * about. Below 20 samples the nearest rank IS the maximum; the sample count travels in the
101
+ * attachment and in every failure message so a reader can see when "p95" means "slowest of five".
102
+ *
103
+ * Throws rather than returning 0 on an empty sample: a p95 of 0 slides under every budget.
104
+ */
105
+ export declare function p95Of(msValues: readonly number[]): number;
106
+ /** What the page hands back. Every timing is `number | null`; null always means "did not fire". */
107
+ export interface RawVitals {
108
+ hasNavigationEntry: boolean;
109
+ navigationStartEpochMs: number | null;
110
+ pageLoadMs: number | null;
111
+ ttfbMs: number | null;
112
+ fcpMs: number | null;
113
+ lcpMs: number | null;
114
+ domContentLoadedMs: number | null;
115
+ totalResources: number;
116
+ totalResourceSizeKB: number;
117
+ lcpSupported: boolean;
118
+ fcpSupported: boolean;
119
+ }
120
+ /**
121
+ * Runs inside the page. Serialised by Playwright, so it closes over nothing but its argument — the
122
+ * type aliases above are erased at compile time and are safe to reference; the constants are not,
123
+ * which is why the settle window is passed in.
124
+ */
125
+ export declare function collectRawVitals(settleMs: number): Promise<RawVitals>;
126
+ /**
127
+ * Turns the page's answer into a recording, capturing WHY a vital is null while the browser's
128
+ * capabilities are still in hand. By the time a `Then` step fails, "lcpMs was not measured" on its
129
+ * own sends the reader hunting for a bug in a page that is merely running in a browser that does
130
+ * not implement LCP (WebKit). Support is read from `PerformanceObserver.supportedEntryTypes`, never
131
+ * assumed from the browser name.
132
+ */
133
+ export declare function toRecording(raw: RawVitals, url: string, browserName?: string): VitalsRecording;
134
+ interface VitalsFixtures {
135
+ page: Page;
136
+ config: BudgetConfig;
137
+ scenario: ScenarioMeta;
138
+ $bddContext: {
139
+ stepIndex: number;
140
+ };
141
+ $testInfo: TestInfo;
142
+ }
143
+ /**
144
+ * PROVES that the current navigation produced real page vitals, and pins them to this step so a
145
+ * later assertion has something that can actually fail. Records LCP / FCP / TTFB / load plus the
146
+ * resource count and weight, as the `PerformanceMetrics` contract, under `sdods/perf/<step>` — the
147
+ * name `attachmentNames.perf` has always defined and the DB ingest has always read into
148
+ * `steps.perf_json`, and which nothing had ever written.
149
+ *
150
+ * TRAP: recording before anything navigated. `performance.getEntriesByType('navigation')` is empty
151
+ * on a page that has not loaded a document, so every vital comes back null. That is step misuse
152
+ * rather than a slow application, so it fails here as `RUN_FAILED` naming the fix, instead of as
153
+ * four confusing assertion failures later.
154
+ */
155
+ export declare const recordPageVitals: ({ page, config, scenario, $bddContext, $testInfo, }: VitalsFixtures) => Promise<void>;
156
+ interface AssertFixtures {
157
+ scenario: ScenarioMeta;
158
+ config: BudgetConfig;
159
+ apiContext: ApiContext;
160
+ env: EnvConfig;
161
+ }
162
+ /**
163
+ * PROVES a recorded vital sits inside the ceiling THIS environment declares. The budget is read
164
+ * from `perf.budgets` and is never a step argument: a threshold written into a feature file ships
165
+ * unchanged to every environment, which is exactly what a per-environment budget exists to prevent.
166
+ *
167
+ * Ordered so the cheapest fix surfaces first: forgot the recording → typo'd the vital name →
168
+ * budget missing from config → vital never fired → the comparison itself.
169
+ */
170
+ export declare const assertVitalWithinBudget: ({ scenario, config, apiContext, env }: AssertFixtures, metricName: string) => Promise<void>;
171
+ /**
172
+ * PROVES a recorded vital sits under a limit this scenario owns. The visible escape hatch from the
173
+ * configured budget, for a page whose limit the environment-wide budget cannot express. Strictly
174
+ * `<`, matching `the response time should be under {int} ms`.
175
+ */
176
+ export declare const assertVitalUnderThreshold: ({ scenario, apiContext, env }: AssertFixtures, metricName: string, maxMs: number) => Promise<void>;
177
+ /**
178
+ * PROVES a second navigation is not slower than the first — the caching / warm-path claim.
179
+ * Compares the last two recordings of one vital, so a scenario records, acts, and records again.
180
+ *
181
+ * Carries an explicit tolerance because both sides are live measurements: the zero-tolerance form
182
+ * (`plus 0 ms`) is available, but it is the author's decision in the feature file rather than a
183
+ * hidden fudge factor here. Fails outright with fewer than two recordings, because comparing a
184
+ * recording with itself always passes.
185
+ */
186
+ export declare const assertVitalNoWorseThanPrevious: ({ scenario, apiContext, env }: AssertFixtures, metricName: string, toleranceMs: number) => Promise<void>;
187
+ /**
188
+ * PROVES the budgets a perf suite depends on actually exist for the environment being run.
189
+ *
190
+ * This step guards the other steps. A missing or partial `perf.budgets` block is invisible
191
+ * everywhere else in a run: the release gate reads `perfBudgets: true`, the suite goes green, and
192
+ * nobody learns the ceiling was never set. Put this in the first scenario of a perf module and the
193
+ * gap fails loudly, once, naming the key.
194
+ */
195
+ export declare const assertBudgetsConfigured: ({ config, apiContext, env }: AssertFixtures, names: string) => Promise<void>;
196
+ interface SampleFixtures {
197
+ api: ApiClient;
198
+ apiContext: ApiContext;
199
+ env: EnvConfig;
200
+ scenario: ScenarioMeta;
201
+ $bddContext: {
202
+ stepIndex: number;
203
+ };
204
+ $testInfo: TestInfo;
205
+ }
206
+ /**
207
+ * PROVES an endpoint's latency DISTRIBUTION rather than one lucky request. `the response time
208
+ * should be under {int} ms` (api.steps.ts) times a single call — the measurement most likely to be
209
+ * a cold start or a cache hit. A p95 needs a sample, and SDODS had no way to take one.
210
+ */
211
+ export declare const sampleLatency: ({ api, apiContext, env, scenario, $bddContext, $testInfo }: SampleFixtures, method: HttpMethod, path: string, times: number) => Promise<void>;
212
+ /** PROVES the same for a write path, where the latency that matters belongs to a real payload. */
213
+ export declare const sampleLatencyWithBody: ({ api, apiContext, env, scenario, $bddContext, $testInfo }: SampleFixtures, method: HttpMethod, path: string, times: number, body: string) => Promise<void>;
214
+ /**
215
+ * PROVES the sampled p95 sits inside the environment's `apiP95Ms` ceiling. The budget key is fixed
216
+ * and read from config — nothing to mistype in the feature file, nothing to drift.
217
+ *
218
+ * Says nothing about whether the responses were correct: a p95 over twenty fast 500s passes this,
219
+ * and should. Pair it with `every latency sample should have returned status 200`, which is the
220
+ * step that proves the server was doing the work being timed.
221
+ */
222
+ export declare const assertP95WithinBudget: ({ scenario, config, }: {
223
+ scenario: ScenarioMeta;
224
+ config: BudgetConfig;
225
+ }) => Promise<void>;
226
+ /** PROVES the sampled p95 sits under a limit this scenario owns. Strictly `<`, as with the vitals. */
227
+ export declare const assertP95UnderThreshold: ({ scenario }: {
228
+ scenario: ScenarioMeta;
229
+ }, maxMs: number) => Promise<void>;
230
+ /**
231
+ * PROVES that whatever changed between two samples did not make the endpoint slower — a warmed
232
+ * cache, a new index, a refusal being cheaper to serve than a response. Compares the last two
233
+ * sample sets in this scenario; fails outright with only one, because comparing a sample with
234
+ * itself always passes.
235
+ */
236
+ export declare const assertP95NoWorseThanPrevious: ({ scenario }: {
237
+ scenario: ScenarioMeta;
238
+ }, toleranceMs: number) => Promise<void>;
239
+ /**
240
+ * PROVES the timed responses were the ones the scenario meant to time. Without it a latency budget
241
+ * is happily met by an endpoint that 500s in 3 ms, or that 401s because the sample lost the
242
+ * scenario's auth. Lists the offending statuses, because "not all 200" sends nobody anywhere.
243
+ */
244
+ export declare const assertEverySampleStatus: ({ scenario }: {
245
+ scenario: ScenarioMeta;
246
+ }, status: number) => Promise<void>;
247
+ export {};
248
+ //# sourceMappingURL=perf.steps.d.ts.map