@sdods/core 0.7.2 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -3
|
@@ -1,136 +1,11 @@
|
|
|
1
1
|
import './params.js';
|
|
2
2
|
import type { Page, TestInfo } from '@playwright/test';
|
|
3
|
-
import type { EnvConfig
|
|
3
|
+
import type { EnvConfig } from '@sdods/contracts';
|
|
4
4
|
import type { ApiClient, HttpMethod } from '../api/client.js';
|
|
5
5
|
import type { ApiContext } from '../fixtures/api-context.js';
|
|
6
6
|
import type { ScenarioMeta } from '../fixtures/scenario.js';
|
|
7
|
-
|
|
8
|
-
|
|
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;
|
|
7
|
+
import { type BudgetConfig } from '../perf/vitals.js';
|
|
8
|
+
export { API_P95_BUDGET_KEY, VITAL_KEYS, collectRawVitals, p95Of, perfStore, requireBudget, resolvedBudgets, toRecording, type BudgetConfig, type LatencySample, type LatencySampleSet, type RawVitals, type VitalKey, type VitalsRecording, } from '../perf/vitals.js';
|
|
134
9
|
interface VitalsFixtures {
|
|
135
10
|
page: Page;
|
|
136
11
|
config: BudgetConfig;
|
|
@@ -244,5 +119,4 @@ export declare const assertP95NoWorseThanPrevious: ({ scenario }: {
|
|
|
244
119
|
export declare const assertEverySampleStatus: ({ scenario }: {
|
|
245
120
|
scenario: ScenarioMeta;
|
|
246
121
|
}, status: number) => Promise<void>;
|
|
247
|
-
export {};
|
|
248
122
|
//# sourceMappingURL=perf.steps.d.ts.map
|
package/dist/steps/perf.steps.js
CHANGED
|
@@ -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];
|
package/dist/steps/ui.steps.js
CHANGED
|
@@ -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.
|
|
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>",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./analyze": "./dist/analyze/index.js",
|
|
22
22
|
"./recorder": "./dist/recorder/index.js",
|
|
23
23
|
"./har": "./dist/har/index.js",
|
|
24
|
+
"./load": "./dist/load/index.js",
|
|
24
25
|
"./auth": "./dist/auth/index.js",
|
|
25
26
|
"./insights": "./dist/insights/index.js",
|
|
26
27
|
"./reporters/dashboard": "./dist/reporters/dashboard.js",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"playwright-bdd": ">=9"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@sdods/contracts": "0.
|
|
46
|
+
"@sdods/contracts": "0.9.0",
|
|
46
47
|
"@cucumber/cucumber-expressions": "19.0.0",
|
|
47
48
|
"@cucumber/gherkin": "^42.0.1",
|
|
48
49
|
"@cucumber/messages": "^34.2.1",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"tsx": "^4.23.0",
|
|
61
62
|
"yaml": "^2.9.0",
|
|
62
63
|
"zod": "^4.5.4",
|
|
63
|
-
"@sdods/db": "0.
|
|
64
|
+
"@sdods/db": "0.9.0"
|
|
64
65
|
},
|
|
65
66
|
"homepage": "https://sdods.com",
|
|
66
67
|
"bugs": {
|