@jitsusama/agentic-harness.core 0.1.0 → 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 (42) hide show
  1. package/dist/bin/cli.js +7 -0
  2. package/dist/bin/web.d.ts +30 -0
  3. package/dist/bin/web.js +97 -0
  4. package/dist/observability/index.d.ts +8 -0
  5. package/dist/observability/index.js +8 -0
  6. package/dist/observability/ledger/index.d.ts +14 -0
  7. package/dist/observability/ledger/index.js +13 -0
  8. package/dist/observability/ledger/store.d.ts +50 -0
  9. package/dist/observability/ledger/store.js +573 -0
  10. package/dist/observability/ledger/types.d.ts +197 -0
  11. package/dist/observability/ledger/types.js +1 -0
  12. package/dist/observability/recorder.d.ts +9 -2
  13. package/dist/observability/recorder.js +11 -18
  14. package/dist/observability/store.d.ts +5 -3
  15. package/dist/observability/store.js +152 -55
  16. package/dist/observability/types.d.ts +32 -4
  17. package/dist/web/audit/index.d.ts +1 -0
  18. package/dist/web/audit/index.js +1 -0
  19. package/dist/web/audit/motion.d.ts +87 -0
  20. package/dist/web/audit/motion.js +239 -0
  21. package/dist/web/design/index.d.ts +1 -0
  22. package/dist/web/design/index.js +1 -0
  23. package/dist/web/design/typography.d.ts +71 -0
  24. package/dist/web/design/typography.js +221 -0
  25. package/dist/web/hydration/capture.d.ts +37 -0
  26. package/dist/web/hydration/capture.js +96 -0
  27. package/dist/web/hydration/index.d.ts +10 -0
  28. package/dist/web/hydration/index.js +10 -0
  29. package/dist/web/hydration/judge.d.ts +56 -0
  30. package/dist/web/hydration/judge.js +191 -0
  31. package/dist/web/index.d.ts +1 -0
  32. package/dist/web/index.js +1 -0
  33. package/dist/web/perf/index.d.ts +1 -1
  34. package/dist/web/perf/index.js +1 -1
  35. package/dist/web/perf/view.js +19 -1
  36. package/dist/web/perf/vitals.d.ts +29 -0
  37. package/dist/web/perf/vitals.js +70 -0
  38. package/dist/web/session.d.ts +31 -2
  39. package/dist/web/session.js +75 -2
  40. package/package.json +12 -3
  41. package/dist/memory/db.d.ts +0 -15
  42. package/dist/memory/db.js +0 -25
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Reading the server render and the hydrated page side by side.
3
+ *
4
+ * The server's HTML is fetched from inside the page, so it
5
+ * travels with the session's own cookies and headers, and it is
6
+ * parsed with DOMParser, which builds a document without running
7
+ * a single script: exactly what the browser had before hydration
8
+ * ran. Both documents are reduced to the same compact shape,
9
+ * text and tag counts, because the judgment belongs to the pure
10
+ * side and whole DOM trees do not fit through a capture.
11
+ */
12
+ /** Texts shorter than this are markup lint, not content. */
13
+ export const MIN_TEXT_CHARS = 3;
14
+ /** How many texts to carry per side. */
15
+ export const MAX_TEXTS = 500;
16
+ /** How much of each text to keep. */
17
+ export const MAX_TEXT_CHARS = 120;
18
+ /**
19
+ * The expression that reads both renders.
20
+ *
21
+ * Async because the server render is a fetch; evaluate it with
22
+ * awaitPromise. Scripts, styles and noscript are excluded from
23
+ * both sides: they are machinery, not content, and noscript text
24
+ * is visible in exactly one of the two renders by definition.
25
+ */
26
+ export const HYDRATION_CAPTURE = `(async () => {
27
+ const MIN_TEXT = ${MIN_TEXT_CHARS};
28
+ const MAX_TEXTS = ${MAX_TEXTS};
29
+ const MAX_CHARS = ${MAX_TEXT_CHARS};
30
+ const MACHINERY = new Set(["script", "style", "noscript", "template"]);
31
+
32
+ const textsOf = (root) => {
33
+ const walker = document.createTreeWalker(
34
+ root,
35
+ NodeFilter.SHOW_TEXT,
36
+ null,
37
+ );
38
+ const texts = [];
39
+ while (walker.nextNode() && texts.length < MAX_TEXTS) {
40
+ const node = walker.currentNode;
41
+ const parent = node.parentElement;
42
+ if (!parent) continue;
43
+ if (MACHINERY.has(parent.tagName.toLowerCase())) continue;
44
+ const text = (node.textContent || "")
45
+ .replace(/\\s+/g, " ")
46
+ .trim()
47
+ .slice(0, MAX_CHARS);
48
+ if (text.length >= MIN_TEXT) texts.push(text);
49
+ }
50
+ return texts;
51
+ };
52
+
53
+ const tagsOf = (root) => {
54
+ const counts = {};
55
+ for (const el of root.querySelectorAll("*")) {
56
+ const tag = el.tagName.toLowerCase();
57
+ if (MACHINERY.has(tag) || tag === "link" || tag === "meta") continue;
58
+ counts[tag] = (counts[tag] || 0) + 1;
59
+ }
60
+ return counts;
61
+ };
62
+
63
+ let fetched = false;
64
+ let status;
65
+ let serverTexts = [];
66
+ let serverTags = {};
67
+ try {
68
+ const response = await fetch(location.href, {
69
+ headers: { accept: "text/html" },
70
+ credentials: "include",
71
+ cache: "no-store",
72
+ });
73
+ status = response.status;
74
+ if (response.ok) {
75
+ const html = await response.text();
76
+ const server = new DOMParser().parseFromString(html, "text/html");
77
+ const root = server.body || server.documentElement;
78
+ serverTexts = textsOf(root);
79
+ serverTags = tagsOf(root);
80
+ fetched = true;
81
+ }
82
+ } catch (error) {
83
+ // fetched stays false, which the judge reports honestly.
84
+ }
85
+
86
+ const live = document.body || document.documentElement;
87
+ return {
88
+ url: location.href,
89
+ fetched,
90
+ ...(status === undefined ? {} : { status }),
91
+ serverTexts,
92
+ serverTags,
93
+ clientTexts: textsOf(live),
94
+ clientTags: tagsOf(live),
95
+ };
96
+ })()`;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the page the server sent is the page the person got.
3
+ *
4
+ * The capture reads both renders from inside the page; the judge
5
+ * is pure and takes serialized data, so it can judge a stored
6
+ * capture as easily as a live one. Nothing here can start a
7
+ * browser.
8
+ */
9
+ export { HYDRATION_CAPTURE, type HydrationCapture, MAX_TEXT_CHARS, MAX_TEXTS, MIN_TEXT_CHARS, } from "./capture.js";
10
+ export { type ConsoleLine, type HydrationReport, judgeHydration, renderHydration, SHELL_FLOOR_TEXTS, SHELL_MIN_CLIENT_TEXTS, TAG_DRIFT_MIN, type TagDrift, } from "./judge.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the page the server sent is the page the person got.
3
+ *
4
+ * The capture reads both renders from inside the page; the judge
5
+ * is pure and takes serialized data, so it can judge a stored
6
+ * capture as easily as a live one. Nothing here can start a
7
+ * browser.
8
+ */
9
+ export { HYDRATION_CAPTURE, MAX_TEXT_CHARS, MAX_TEXTS, MIN_TEXT_CHARS, } from "./capture.js";
10
+ export { judgeHydration, renderHydration, SHELL_FLOOR_TEXTS, SHELL_MIN_CLIENT_TEXTS, TAG_DRIFT_MIN, } from "./judge.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether the page the server sent is the page the person got.
3
+ *
4
+ * A hydration mismatch ships silently: the markup is valid, the
5
+ * console line scrolls past, and the visitor sees content flash,
6
+ * flip or vanish. The judgment here is over three kinds of
7
+ * evidence: content in the server render that is gone after
8
+ * hydration, content that only exists after hydration, and the
9
+ * framework's own hydration warnings caught in the console.
10
+ *
11
+ * Framework-agnostic on purpose. The console recognizers know
12
+ * the words React and Vue use, but the DOM comparison knows
13
+ * nothing about any framework: a server render and a hydrated
14
+ * document disagree or they do not.
15
+ *
16
+ * Adapted from the hydration-safety check in Carolyn McNeillie's
17
+ * review-page skill set, generalized from its React framing.
18
+ */
19
+ import { type Standing } from "../audit/verdict.js";
20
+ import type { HydrationCapture } from "./capture.js";
21
+ /** One console line, as the session's telemetry records it. */
22
+ export interface ConsoleLine {
23
+ readonly level: string;
24
+ readonly text: string;
25
+ }
26
+ /** A tag whose count moved between the renders. */
27
+ export interface TagDrift {
28
+ readonly tag: string;
29
+ readonly server: number;
30
+ readonly client: number;
31
+ }
32
+ /** Everything the judge concluded. */
33
+ export interface HydrationReport {
34
+ readonly standing: Standing;
35
+ /** Server content that is gone after hydration. */
36
+ readonly vanished: readonly string[];
37
+ /** Content that only exists after hydration. */
38
+ readonly appeared: readonly string[];
39
+ readonly drift: readonly TagDrift[];
40
+ /** The framework's own hydration complaints. */
41
+ readonly warnings: readonly string[];
42
+ /** True when there was no server render worth comparing. */
43
+ readonly shell: boolean;
44
+ readonly fetched: boolean;
45
+ readonly status?: number;
46
+ }
47
+ /** Fewer server texts than this is a shell, not a render. */
48
+ export declare const SHELL_FLOOR_TEXTS = 5;
49
+ /** ...when the client has at least this many. */
50
+ export declare const SHELL_MIN_CLIENT_TEXTS = 20;
51
+ /** A tag count moving less than this is churn, not drift. */
52
+ export declare const TAG_DRIFT_MIN = 5;
53
+ /** Judge a capture beside what the console said during load. */
54
+ export declare function judgeHydration(capture: HydrationCapture, consoleLines?: readonly ConsoleLine[]): HydrationReport;
55
+ /** Say whether the server render survived hydration. */
56
+ export declare function renderHydration(report: HydrationReport): string;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Whether the page the server sent is the page the person got.
3
+ *
4
+ * A hydration mismatch ships silently: the markup is valid, the
5
+ * console line scrolls past, and the visitor sees content flash,
6
+ * flip or vanish. The judgment here is over three kinds of
7
+ * evidence: content in the server render that is gone after
8
+ * hydration, content that only exists after hydration, and the
9
+ * framework's own hydration warnings caught in the console.
10
+ *
11
+ * Framework-agnostic on purpose. The console recognizers know
12
+ * the words React and Vue use, but the DOM comparison knows
13
+ * nothing about any framework: a server render and a hydrated
14
+ * document disagree or they do not.
15
+ *
16
+ * Adapted from the hydration-safety check in Carolyn McNeillie's
17
+ * review-page skill set, generalized from its React framing.
18
+ */
19
+ import { count, renderVerdict } from "../audit/verdict.js";
20
+ /**
21
+ * What a hydration complaint looks like in a console.
22
+ *
23
+ * React 17 says "Text content does not match server-rendered
24
+ * HTML", React 18 and 19 say "Hydration failed" and "an error
25
+ * occurred during hydration", Vue says "Hydration node mismatch".
26
+ * The word stem covers all but the first, which gets its own
27
+ * pattern.
28
+ */
29
+ const COMPLAINTS = [/hydrat/i, /did not match/i, /server.rendered/i];
30
+ /** Console levels worth reading complaints from. */
31
+ const SPOKEN_LEVELS = new Set(["error", "warning", "warn"]);
32
+ /** Fewer server texts than this is a shell, not a render. */
33
+ export const SHELL_FLOOR_TEXTS = 5;
34
+ /** ...when the client has at least this many. */
35
+ export const SHELL_MIN_CLIENT_TEXTS = 20;
36
+ /** A tag count moving less than this is churn, not drift. */
37
+ export const TAG_DRIFT_MIN = 5;
38
+ function multiset(texts) {
39
+ const counts = new Map();
40
+ for (const text of texts)
41
+ counts.set(text, (counts.get(text) ?? 0) + 1);
42
+ return counts;
43
+ }
44
+ function difference(from, take) {
45
+ const remaining = multiset(take);
46
+ const out = [];
47
+ for (const text of from) {
48
+ const left = remaining.get(text) ?? 0;
49
+ if (left > 0) {
50
+ remaining.set(text, left - 1);
51
+ }
52
+ else {
53
+ out.push(text);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** Judge a capture beside what the console said during load. */
59
+ export function judgeHydration(capture, consoleLines = []) {
60
+ const warnings = consoleLines
61
+ .filter((line) => SPOKEN_LEVELS.has(line.level))
62
+ .filter((line) => COMPLAINTS.some((pattern) => pattern.test(line.text)))
63
+ .map((line) => line.text);
64
+ const base = {
65
+ warnings,
66
+ fetched: capture.fetched,
67
+ ...(capture.status === undefined ? {} : { status: capture.status }),
68
+ };
69
+ if (!capture.fetched) {
70
+ return {
71
+ ...base,
72
+ standing: warnings.length > 0 ? "fail" : "warn",
73
+ vanished: [],
74
+ appeared: [],
75
+ drift: [],
76
+ shell: false,
77
+ };
78
+ }
79
+ // A client-rendered page has no server render to hold it to:
80
+ // every text would read as appeared, which is noise about an
81
+ // architecture, not findings about a bug.
82
+ const shell = capture.serverTexts.length < SHELL_FLOOR_TEXTS &&
83
+ capture.clientTexts.length >= SHELL_MIN_CLIENT_TEXTS;
84
+ if (shell) {
85
+ return {
86
+ ...base,
87
+ standing: warnings.length > 0 ? "fail" : "warn",
88
+ vanished: [],
89
+ appeared: [],
90
+ drift: [],
91
+ shell: true,
92
+ };
93
+ }
94
+ const vanished = difference(capture.serverTexts, capture.clientTexts);
95
+ const appeared = difference(capture.clientTexts, capture.serverTexts);
96
+ const tags = new Set([
97
+ ...Object.keys(capture.serverTags),
98
+ ...Object.keys(capture.clientTags),
99
+ ]);
100
+ const drift = [];
101
+ for (const tag of tags) {
102
+ const server = capture.serverTags[tag] ?? 0;
103
+ const client = capture.clientTags[tag] ?? 0;
104
+ if (Math.abs(client - server) >= TAG_DRIFT_MIN) {
105
+ drift.push({ tag, server, client });
106
+ }
107
+ }
108
+ drift.sort((a, b) => Math.abs(b.client - b.server) - Math.abs(a.client - a.server));
109
+ // The framework saying hydration failed, or server content a
110
+ // person was sent no longer being there, is a failure. Content
111
+ // that only appeared is a warning: a client-only widget is
112
+ // legitimate, and this cannot tell it from a bug.
113
+ const standing = warnings.length > 0 || vanished.length > 0
114
+ ? "fail"
115
+ : appeared.length > 0 || drift.length > 0
116
+ ? "warn"
117
+ : "pass";
118
+ return { ...base, standing, vanished, appeared, drift, shell: false };
119
+ }
120
+ /** How many texts to name per list before counting the rest. */
121
+ const MAX_LISTED = 5;
122
+ function listed(texts) {
123
+ const lines = texts.slice(0, MAX_LISTED).map((text) => ` "${text}"`);
124
+ if (texts.length > MAX_LISTED) {
125
+ lines.push(` ... and ${texts.length - MAX_LISTED} more.`);
126
+ }
127
+ return lines;
128
+ }
129
+ /** Say whether the server render survived hydration. */
130
+ export function renderHydration(report) {
131
+ if (!report.fetched) {
132
+ return renderVerdict({
133
+ standing: report.standing,
134
+ headline: report.warnings.length > 0
135
+ ? "The console complained about hydration, and the server " +
136
+ "render could not be fetched to say what diverged."
137
+ : "The server render could not be fetched, so nothing " +
138
+ "was compared.",
139
+ measured: `Fetching the page's own URL from inside it ` +
140
+ `${report.status === undefined ? "threw" : `answered ${report.status}`}. ` +
141
+ `The comparison needs the HTML the server sends before ` +
142
+ `any script runs.`,
143
+ }, report.warnings.map((line) => ` ${line}`).join("\n"));
144
+ }
145
+ if (report.shell) {
146
+ return renderVerdict({
147
+ standing: report.standing,
148
+ headline: "The page is client-rendered: the server sends a shell, " +
149
+ "so there is no server render to hold the page to.",
150
+ measured: "Hydration cannot mismatch when nothing is hydrated; " +
151
+ "whether a shell is acceptable here is a judgment call, " +
152
+ "so this is not a pass.",
153
+ }, report.warnings.map((line) => ` ${line}`).join("\n"));
154
+ }
155
+ const parts = [];
156
+ if (report.warnings.length > 0) {
157
+ parts.push(`The framework complained during hydration:`, ...report.warnings.map((line) => ` ${line}`));
158
+ }
159
+ if (report.vanished.length > 0) {
160
+ parts.push(`Server content gone after hydration ` +
161
+ `(${count(report.vanished.length, "text")}):`, ...listed(report.vanished));
162
+ }
163
+ if (report.appeared.length > 0) {
164
+ parts.push(`Content that only exists after hydration ` +
165
+ `(${count(report.appeared.length, "text")}):`, ...listed(report.appeared));
166
+ }
167
+ if (report.drift.length > 0) {
168
+ parts.push("Element counts that moved:", ...report.drift.map((one) => ` <${one.tag}> ${one.server} on the server, ` +
169
+ `${one.client} after hydration`));
170
+ }
171
+ const headline = report.standing === "pass"
172
+ ? "The hydrated page says what the server sent."
173
+ : report.standing === "fail"
174
+ ? report.warnings.length > 0
175
+ ? "The framework itself reported a hydration failure."
176
+ : `${count(report.vanished.length, "server text")} ` +
177
+ `vanished during hydration.`
178
+ : `The renders agree on shared content, but ` +
179
+ `${count(report.appeared.length, "text")} and ` +
180
+ `${count(report.drift.length, "tag count", "tag counts")} ` +
181
+ `exist only after hydration.`;
182
+ return renderVerdict({
183
+ standing: report.standing,
184
+ headline,
185
+ measured: "Compared the HTML the server sends, parsed without running " +
186
+ "a script, against the live document. A page that renders " +
187
+ "the time or a locale will differ between the two fetches; " +
188
+ "paired vanished and appeared texts of the same shape are " +
189
+ "that, not a bug.",
190
+ }, parts.join("\n"));
191
+ }
@@ -21,6 +21,7 @@
21
21
  * - `envelope/` paging, budgets and the on-disk artifact sink
22
22
  * - `environment/` emulation, storage, network shaping, status
23
23
  * - `evaluate/` running an expression and surviving the result
24
+ * - `hydration/` whether the server render survived hydration
24
25
  * - `input/` key chords, pointer paths, touch gestures
25
26
  * - `perf/` web vitals from the browser's own observers
26
27
  * - `snapshot/` the whole page flattened, frames and shadow
package/dist/web/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * - `envelope/` paging, budgets and the on-disk artifact sink
22
22
  * - `environment/` emulation, storage, network shaping, status
23
23
  * - `evaluate/` running an expression and surviving the result
24
+ * - `hydration/` whether the server render survived hydration
24
25
  * - `input/` key chords, pointer paths, touch gestures
25
26
  * - `perf/` web vitals from the browser's own observers
26
27
  * - `snapshot/` the whole page flattened, frames and shadow
@@ -11,4 +11,4 @@ export { observerBootstrap, readVitalsSource } from "./probe.js";
11
11
  export { foldProfile, type Hotspot, type Hotspots, type RawProfile, type RawProfileNode, renderHotspots, } from "./profile.js";
12
12
  export { categoriesFor, type FrameStory, foldTrace, type RawTraceEvent, type RequestSpan, renderTrace, type TaskCost, type TimerStory, TRACE_CATEGORIES, type TraceCapture, type TraceProfile, } from "./trace.js";
13
13
  export { renderVitals } from "./view.js";
14
- export { cumulativeShift, type LongTask, type Measure, measure, type Rating, rate, SESSION_CAP_MS, SESSION_GAP_MS, type Shift, THRESHOLDS, type Vitals, worstShiftSources, } from "./vitals.js";
14
+ export { cumulativeShift, type LongTask, type Measure, measure, measureSamples, type Rating, rate, SESSION_CAP_MS, SESSION_GAP_MS, type Shift, type Spread, THRESHOLDS, type Vitals, worstShiftSources, } from "./vitals.js";
@@ -11,4 +11,4 @@ export { observerBootstrap, readVitalsSource } from "./probe.js";
11
11
  export { foldProfile, renderHotspots, } from "./profile.js";
12
12
  export { categoriesFor, foldTrace, renderTrace, TRACE_CATEGORIES, } from "./trace.js";
13
13
  export { renderVitals } from "./view.js";
14
- export { cumulativeShift, measure, rate, SESSION_CAP_MS, SESSION_GAP_MS, THRESHOLDS, worstShiftSources, } from "./vitals.js";
14
+ export { cumulativeShift, measure, measureSamples, rate, SESSION_CAP_MS, SESSION_GAP_MS, THRESHOLDS, worstShiftSources, } from "./vitals.js";
@@ -47,12 +47,26 @@ export function renderVitals(vitals, measures) {
47
47
  // person to interact: nothing here observes event timing, so a
48
48
  // reader told only that four were measured has no way to know
49
49
  // responsiveness was never among them, and assumes it passed.
50
+ const sampled = Math.max(...measures.map((one) => one.spread?.samples ?? 1));
51
+ // A rating that changed between loads is the finding, not the
52
+ // median beside it: the median alone reads as settled.
53
+ const unstable = measures.filter((one) => one.spread?.straddles);
50
54
  const caveat = ` Interaction to next paint is not among them: it needs an ` +
51
55
  `interaction, and this measures a load.` +
56
+ (sampled <= 1 ? "" : ` Each value is the median of ${sampled} loads.`) +
57
+ (unstable.length === 0
58
+ ? ""
59
+ : ` Rated differently between loads: ${unstable
60
+ .map((one) => one.name)
61
+ .join(", ")}.`) +
52
62
  (missing.length === 0 ? "" : ` Not observed: ${missing.join("; ")}.`);
53
63
  const width = Math.max(...measures.map((one) => one.name.length));
54
64
  const lines = measures.map((one) => ` ${one.name.padEnd(width)} ${MARK[one.rating].padEnd(4)} ` +
55
65
  `${say(one.value, one.unit).padStart(9)}` +
66
+ `${one.spread && one.spread.samples > 1
67
+ ? ` ${say(one.spread.low, one.unit)}-${say(one.spread.high, one.unit)}` +
68
+ ` over ${one.spread.samples}`
69
+ : ""}` +
56
70
  `${one.detail ? ` ${one.detail}` : ""}`);
57
71
  const blame = worstShiftSources(vitals.shifts);
58
72
  if (blame.length > 0) {
@@ -71,7 +85,11 @@ export function renderVitals(vitals, measures) {
71
85
  // A partial capture cannot pass: an observer that never
72
86
  // installed reports nothing, which reads the same as a page
73
87
  // with nothing wrong.
74
- standing: missing.length > 0 && overall(measures) === "pass"
88
+ // A pass that only holds on the median is not a pass: a
89
+ // metric that rated poor on any load is worth a look even
90
+ // when the middle run was fine.
91
+ standing: (missing.length > 0 || unstable.length > 0) &&
92
+ overall(measures) === "pass"
75
93
  ? "warn"
76
94
  : overall(measures),
77
95
  headline: failing.length === 0
@@ -128,6 +128,15 @@ export declare function worstShiftSources(shifts: readonly Shift[], limit?: numb
128
128
  readonly node: string;
129
129
  readonly moved: number;
130
130
  }[];
131
+ /** How a metric varied across repeated loads. */
132
+ export interface Spread {
133
+ readonly low: number;
134
+ readonly high: number;
135
+ /** How many loads actually reported this metric. */
136
+ readonly samples: number;
137
+ /** Whether the rating differed between loads. */
138
+ readonly straddles: boolean;
139
+ }
131
140
  /** One metric, rated. */
132
141
  export interface Measure {
133
142
  readonly name: string;
@@ -136,6 +145,26 @@ export interface Measure {
136
145
  readonly rating: Rating;
137
146
  /** What the value points at, when the browser said. */
138
147
  readonly detail?: string;
148
+ /** Present when the value is a median over several loads. */
149
+ readonly spread?: Spread;
139
150
  }
140
151
  /** Read the vitals into rated measures. */
141
152
  export declare function measure(vitals: Vitals): readonly Measure[];
153
+ /**
154
+ * Read several captures of the same page into rated measures,
155
+ * one per metric, each the median over the loads that reported
156
+ * it.
157
+ *
158
+ * A single headless load drifts: the same page can rate good on
159
+ * one run and poor on the next without anything changing. The
160
+ * median is the defensible middle, and the spread is reported
161
+ * beside it because a rating that straddles the runs is the
162
+ * finding, not the number.
163
+ *
164
+ * The rating is taken from the sample nearest the median rather
165
+ * than re-derived, so a metric keeps exactly the thresholds
166
+ * `measure` gave it. For an even count the worse of the two
167
+ * middle samples decides, which errs toward the reading a
168
+ * person would want to hear about.
169
+ */
170
+ export declare function measureSamples(samples: readonly Vitals[]): readonly Measure[];
@@ -173,3 +173,73 @@ export function measure(vitals) {
173
173
  }
174
174
  return measures;
175
175
  }
176
+ /**
177
+ * Read several captures of the same page into rated measures,
178
+ * one per metric, each the median over the loads that reported
179
+ * it.
180
+ *
181
+ * A single headless load drifts: the same page can rate good on
182
+ * one run and poor on the next without anything changing. The
183
+ * median is the defensible middle, and the spread is reported
184
+ * beside it because a rating that straddles the runs is the
185
+ * finding, not the number.
186
+ *
187
+ * The rating is taken from the sample nearest the median rather
188
+ * than re-derived, so a metric keeps exactly the thresholds
189
+ * `measure` gave it. For an even count the worse of the two
190
+ * middle samples decides, which errs toward the reading a
191
+ * person would want to hear about.
192
+ */
193
+ export function measureSamples(samples) {
194
+ const first = samples[0];
195
+ if (first === undefined)
196
+ return [];
197
+ if (samples.length === 1)
198
+ return measure(first);
199
+ const order = [];
200
+ const byName = new Map();
201
+ for (const capture of samples) {
202
+ for (const one of measure(capture)) {
203
+ const group = byName.get(one.name);
204
+ if (group === undefined) {
205
+ byName.set(one.name, [one]);
206
+ order.push(one.name);
207
+ }
208
+ else {
209
+ group.push(one);
210
+ }
211
+ }
212
+ }
213
+ return order.map((name) => {
214
+ // Set above for every name in order; the map cannot miss.
215
+ const group = byName.get(name);
216
+ const sorted = [...group].sort((a, b) => a.value - b.value);
217
+ const mid = Math.floor(sorted.length / 2);
218
+ const upper = sorted[mid];
219
+ const lower = sorted[Math.max(0, mid - 1)];
220
+ const odd = sorted.length % 2 === 1;
221
+ const median = odd ? upper.value : (lower.value + upper.value) / 2;
222
+ const worseMiddle = SEVERITY_ORDER.indexOf(lower.rating) >=
223
+ SEVERITY_ORDER.indexOf(upper.rating)
224
+ ? lower
225
+ : upper;
226
+ const nearest = odd ? upper : worseMiddle;
227
+ const low = sorted[0].value;
228
+ const high = sorted[sorted.length - 1].value;
229
+ return {
230
+ name,
231
+ value: median,
232
+ unit: nearest.unit,
233
+ rating: nearest.rating,
234
+ ...(nearest.detail === undefined ? {} : { detail: nearest.detail }),
235
+ spread: {
236
+ low,
237
+ high,
238
+ samples: group.length,
239
+ straddles: new Set(group.map((one) => one.rating)).size > 1,
240
+ },
241
+ };
242
+ });
243
+ }
244
+ /** Mildest to worst, for choosing the reading to stand on. */
245
+ const SEVERITY_ORDER = ["good", "needs-improvement", "poor"];
@@ -18,9 +18,10 @@ import { type Animation, type BoxModel, type DelegatedListeners, type HoverRepor
18
18
  import { type Divergence, type EmulationState, type NetworkRule, type ObservedEnvironment, type SavedState, type SessionStatus, type StorageSnapshot, type TabRecord, type ThrottleConditions } from "./environment/index.js";
19
19
  import { type ChordRefusal, type Point, type PointerEventStep, type TouchStep } from "./input/index.js";
20
20
  import { type Settled, type WaitCondition, type WaitOutcome } from "./wait/index.js";
21
- import { type A11yFinding, type BehindReport, type CapturedTarget, type ConformanceBar, type ContrastLevel, type PageBox, type PairReport, type StructureNode, type VisualNode } from "./audit/index.js";
22
- import { type StyleSample } from "./design/index.js";
21
+ import { type A11yFinding, type BehindReport, type CapturedTarget, type ConformanceBar, type ContrastLevel, type MotionCapture, type PageBox, type PairReport, type StructureNode, type VisualNode } from "./audit/index.js";
22
+ import { type StyleSample, type TextBlock } from "./design/index.js";
23
23
  import { type EvalFrame, type EvalOutcome } from "./evaluate/index.js";
24
+ import { type HydrationCapture } from "./hydration/index.js";
24
25
  import { type HeapComparison, type Hotspots, type LayerReport, type TraceCapture, type TraceProfile, type Vitals } from "./perf/index.js";
25
26
  import { type IndexedNode } from "./snapshot/index.js";
26
27
  import { type PropertyTrace, type StyleGroup } from "./styles/index.js";
@@ -810,6 +811,18 @@ export declare class BrowserSession {
810
811
  * a click or a navigation without disturbing what it reports.
811
812
  */
812
813
  focusHolder(): Promise<FocusHolder | undefined>;
814
+ /**
815
+ * What the page keeps doing when asked to hold still.
816
+ *
817
+ * Emulates prefers-reduced-motion: reduce, reloads so the page
818
+ * decides its motion under the preference rather than being
819
+ * caught mid-flight, reads what is still moving, then puts the
820
+ * emulation back exactly as it was. The reload matters: a page
821
+ * picks most of its motion at load, so flipping the preference
822
+ * on a settled page measures its reaction to a change, not its
823
+ * behaviour for a visitor who arrived with the preference set.
824
+ */
825
+ motionUnderReduce(): Promise<MotionCapture>;
813
826
  structure(): Promise<readonly StructureNode[]>;
814
827
  /**
815
828
  * What the layout actually did, as the browser measured it.
@@ -828,6 +841,22 @@ export declare class BrowserSession {
828
841
  * criterion turns on.
829
842
  */
830
843
  targets(): Promise<readonly CapturedTarget[]>;
844
+ /**
845
+ * Both renders of the current page: what the server sends and
846
+ * what hydration made of it.
847
+ *
848
+ * The server render is fetched from inside the page, so it
849
+ * travels with the session's cookies, and parsed without
850
+ * running a script. Judging the capture is the hydration
851
+ * subdomain's job; pair it with logs() so the framework's own
852
+ * complaints are read beside the comparison.
853
+ */
854
+ hydration(): Promise<HydrationCapture>;
855
+ /**
856
+ * How the page's text blocks wrap, measured from real line
857
+ * boxes rather than estimated from fonts.
858
+ */
859
+ typography(): Promise<readonly TextBlock[]>;
831
860
  layout(): Promise<{
832
861
  readonly nodes: readonly VisualNode[];
833
862
  readonly viewport: PageBox;