@jitsusama/agentic-harness.core 0.1.0 → 0.2.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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * How text blocks end: orphans and runts, from real layout.
3
+ *
4
+ * A single word stranded on the last line of a paragraph or
5
+ * heading reads as a typesetting accident, and a very short last
6
+ * line nearly does. Neither can be judged from markup or font
7
+ * metrics, because where the browser broke the lines is the
8
+ * fact, so the capture reads actual line boxes word by word
9
+ * through Range.getBoundingClientRect.
10
+ *
11
+ * This is taste, not conformance: nothing here fails a
12
+ * standard, and the verdict never goes past WARN. It sits in
13
+ * the design domain beside the inventory for the same reason
14
+ * the inventory does: it reports what a person would want to
15
+ * polish, and the judgment of whether it matters is theirs.
16
+ *
17
+ * The line-reading approach is ported from the orphans-and-runts
18
+ * check in Carolyn McNeillie's review-page skill set, including
19
+ * its exclusions: text the author already asked the browser to
20
+ * balance is the browser's business, and text inside a collapsed
21
+ * disclosure is not laid out at all.
22
+ */
23
+ import { count } from "../../ui/count.js";
24
+ import { renderVerdict } from "../audit/verdict.js";
25
+ /** Body text shorter than this is skipped in the capture. */
26
+ export const BODY_FLOOR_CHARS = 80;
27
+ /** A last line of this many words or fewer can be a runt. */
28
+ export const RUNT_MAX_WORDS = 3;
29
+ /** ...when it fills less than this share of the container. */
30
+ export const RUNT_WIDTH_SHARE = 0.25;
31
+ const HEADINGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
32
+ /**
33
+ * Judge how the captured blocks end.
34
+ *
35
+ * Only blocks that actually wrapped are judged: a one-line
36
+ * heading has no last line to strand anything on.
37
+ */
38
+ export function analyseTypography(blocks) {
39
+ const findings = [];
40
+ for (const block of blocks) {
41
+ if (block.lineCount < 2)
42
+ continue;
43
+ const heading = HEADINGS.has(block.tag);
44
+ if (block.lastLine.words === 1) {
45
+ findings.push({ kind: "orphan", heading, block });
46
+ continue;
47
+ }
48
+ if (block.lastLine.words <= RUNT_MAX_WORDS &&
49
+ block.containerWidth > 0 &&
50
+ block.lastLine.width < block.containerWidth * RUNT_WIDTH_SHARE) {
51
+ findings.push({ kind: "runt", heading, block });
52
+ }
53
+ }
54
+ // Headings first, orphans before runts, so the worst reads first.
55
+ return findings.sort((a, b) => {
56
+ if (a.heading !== b.heading)
57
+ return a.heading ? -1 : 1;
58
+ if (a.kind !== b.kind)
59
+ return a.kind === "orphan" ? -1 : 1;
60
+ return 0;
61
+ });
62
+ }
63
+ /** Say how the page's text blocks end. */
64
+ export function renderTypography(blocks, findings) {
65
+ const wrapped = blocks.filter((block) => block.lineCount >= 2).length;
66
+ const measured = `Measured ${count(blocks.length, "text block")}, ` +
67
+ `${wrapped} of them wrapped. Single-line blocks have no last ` +
68
+ `line to judge; text under ${BODY_FLOOR_CHARS} characters and ` +
69
+ `text the author asked the browser to balance were left out ` +
70
+ `at capture.`;
71
+ if (findings.length === 0) {
72
+ return renderVerdict({
73
+ standing: "pass",
74
+ headline: "Every wrapped text block ends cleanly.",
75
+ measured,
76
+ }, "");
77
+ }
78
+ const orphans = findings.filter((one) => one.kind === "orphan");
79
+ const inHeadings = findings.filter((one) => one.heading);
80
+ const lines = findings.map((one) => {
81
+ const what = one.kind === "orphan"
82
+ ? `one word alone on the last line: "${one.block.lastLine.text}"`
83
+ : `${count(one.block.lastLine.words, "word")} on a last line ` +
84
+ `filling ${Math.round((one.block.lastLine.width / one.block.containerWidth) * 100)}% of the container: "${one.block.lastLine.text}"`;
85
+ return ` ${one.block.selector} <${one.block.tag}> ${what}`;
86
+ });
87
+ return renderVerdict({
88
+ // Taste, not conformance: this never fails a page.
89
+ standing: "warn",
90
+ headline: `${count(findings.length, "text block")} of ${wrapped} ` +
91
+ `wrapped ${wrapped === 1 ? "ends" : "end"} badly: ` +
92
+ `${count(orphans.length, "orphan")}, ` +
93
+ `${count(findings.length - orphans.length, "runt")}` +
94
+ `${inHeadings.length === 0 ? "" : `, ${inHeadings.length} in headings`}.`,
95
+ measured,
96
+ }, lines.join("\n"));
97
+ }
98
+ /**
99
+ * The expression that measures how text blocks wrap.
100
+ *
101
+ * The word walk reads each word's rectangle through a Range and
102
+ * groups words into lines by their tops, so the lines are the
103
+ * browser's own. Kept close to the tuned original: the fixes it
104
+ * carries (zero-size rects from hidden text, line grouping by
105
+ * half a line-height) were earned against real pages.
106
+ */
107
+ export const TYPOGRAPHY_CAPTURE = `(() => {
108
+ const BODY_FLOOR = ${BODY_FLOOR_CHARS};
109
+ const MAX_TEXT = 80;
110
+
111
+ const selectorFor = (el) => {
112
+ const tag = el.tagName.toLowerCase();
113
+ if (el.id) return "#" + el.id;
114
+ const hook = el.getAttribute("data-testid");
115
+ if (hook) return tag + '[data-testid="' + hook + '"]';
116
+ const first = el.classList[0];
117
+ if (first) return tag + "." + first;
118
+ return tag;
119
+ };
120
+
121
+ const visualLines = (element) => {
122
+ const walker = document.createTreeWalker(
123
+ element,
124
+ NodeFilter.SHOW_TEXT,
125
+ null,
126
+ );
127
+ const nodes = [];
128
+ while (walker.nextNode()) {
129
+ if (walker.currentNode.textContent.trim()) {
130
+ nodes.push(walker.currentNode);
131
+ }
132
+ }
133
+ const range = document.createRange();
134
+ const lines = [];
135
+ let top = null;
136
+ let height = 0;
137
+ let words = 0;
138
+ let left = Infinity;
139
+ let right = -Infinity;
140
+ let text = "";
141
+ const flush = () => {
142
+ if (words > 0) {
143
+ lines.push({ words, width: right - left, text });
144
+ }
145
+ words = 0;
146
+ left = Infinity;
147
+ right = -Infinity;
148
+ text = "";
149
+ };
150
+ for (const node of nodes) {
151
+ const content = node.textContent;
152
+ const matcher = /\\S+/g;
153
+ let match = matcher.exec(content);
154
+ while (match) {
155
+ range.setStart(node, match.index);
156
+ range.setEnd(node, match.index + match[0].length);
157
+ const rect = range.getBoundingClientRect();
158
+ if (rect.width > 0 || rect.height > 0) {
159
+ const sameLine =
160
+ top !== null && Math.abs(rect.top - top) <= height / 2;
161
+ if (!sameLine) {
162
+ flush();
163
+ top = rect.top;
164
+ height = rect.height;
165
+ }
166
+ words += 1;
167
+ left = Math.min(left, rect.left);
168
+ right = Math.max(right, rect.right);
169
+ if (text.length < MAX_TEXT) {
170
+ text = (text ? text + " " : "") + match[0];
171
+ }
172
+ }
173
+ match = matcher.exec(content);
174
+ }
175
+ }
176
+ flush();
177
+ return lines;
178
+ };
179
+
180
+ const wrapStyle = (el) => {
181
+ const style = getComputedStyle(el);
182
+ return style.textWrap || style.textWrapStyle || "";
183
+ };
184
+
185
+ const candidates = document.querySelectorAll(
186
+ "p, h1, h2, h3, h4, h5, h6, figcaption",
187
+ );
188
+ const blocks = [];
189
+ for (const el of candidates) {
190
+ if (el.getClientRects().length === 0) continue;
191
+ const tag = el.tagName.toLowerCase();
192
+ const textLength = (el.innerText || "").trim().length;
193
+ if (textLength === 0) continue;
194
+ // Headings matter most and are short; body text below the
195
+ // floor has no room for a wrap worth judging.
196
+ const heading = tag[0] === "h";
197
+ if (!heading && textLength < BODY_FLOOR) continue;
198
+ // The author already asked the browser to mind the wrapping.
199
+ const wrap = wrapStyle(el);
200
+ if (wrap.includes("balance") || wrap.includes("pretty")) continue;
201
+ // Text inside a collapsed disclosure is not laid out.
202
+ const details = el.closest("details");
203
+ if (details && !details.open) continue;
204
+ const lines = visualLines(el);
205
+ const last = lines[lines.length - 1];
206
+ if (!last) continue;
207
+ blocks.push({
208
+ selector: selectorFor(el),
209
+ tag,
210
+ textLength,
211
+ containerWidth: el.clientWidth,
212
+ lineCount: lines.length,
213
+ lastLine: {
214
+ words: last.words,
215
+ width: last.width,
216
+ text: last.text.slice(0, MAX_TEXT),
217
+ },
218
+ });
219
+ }
220
+ return blocks;
221
+ })()`;
@@ -0,0 +1,37 @@
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
+ /** Both renders, reduced to comparable shape. */
13
+ export interface HydrationCapture {
14
+ readonly url: string;
15
+ /** Whether the server render could be fetched at all. */
16
+ readonly fetched: boolean;
17
+ readonly status?: number;
18
+ readonly serverTexts: readonly string[];
19
+ readonly clientTexts: readonly string[];
20
+ readonly serverTags: Readonly<Record<string, number>>;
21
+ readonly clientTags: Readonly<Record<string, number>>;
22
+ }
23
+ /** Texts shorter than this are markup lint, not content. */
24
+ export declare const MIN_TEXT_CHARS = 3;
25
+ /** How many texts to carry per side. */
26
+ export declare const MAX_TEXTS = 500;
27
+ /** How much of each text to keep. */
28
+ export declare const MAX_TEXT_CHARS = 120;
29
+ /**
30
+ * The expression that reads both renders.
31
+ *
32
+ * Async because the server render is a fetch; evaluate it with
33
+ * awaitPromise. Scripts, styles and noscript are excluded from
34
+ * both sides: they are machinery, not content, and noscript text
35
+ * is visible in exactly one of the two renders by definition.
36
+ */
37
+ export declare const HYDRATION_CAPTURE = "(async () => {\n\tconst MIN_TEXT = 3;\n\tconst MAX_TEXTS = 500;\n\tconst MAX_CHARS = 120;\n\tconst MACHINERY = new Set([\"script\", \"style\", \"noscript\", \"template\"]);\n\n\tconst textsOf = (root) => {\n\t\tconst walker = document.createTreeWalker(\n\t\t\troot,\n\t\t\tNodeFilter.SHOW_TEXT,\n\t\t\tnull,\n\t\t);\n\t\tconst texts = [];\n\t\twhile (walker.nextNode() && texts.length < MAX_TEXTS) {\n\t\t\tconst node = walker.currentNode;\n\t\t\tconst parent = node.parentElement;\n\t\t\tif (!parent) continue;\n\t\t\tif (MACHINERY.has(parent.tagName.toLowerCase())) continue;\n\t\t\tconst text = (node.textContent || \"\")\n\t\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t\t.trim()\n\t\t\t\t.slice(0, MAX_CHARS);\n\t\t\tif (text.length >= MIN_TEXT) texts.push(text);\n\t\t}\n\t\treturn texts;\n\t};\n\n\tconst tagsOf = (root) => {\n\t\tconst counts = {};\n\t\tfor (const el of root.querySelectorAll(\"*\")) {\n\t\t\tconst tag = el.tagName.toLowerCase();\n\t\t\tif (MACHINERY.has(tag) || tag === \"link\" || tag === \"meta\") continue;\n\t\t\tcounts[tag] = (counts[tag] || 0) + 1;\n\t\t}\n\t\treturn counts;\n\t};\n\n\tlet fetched = false;\n\tlet status;\n\tlet serverTexts = [];\n\tlet serverTags = {};\n\ttry {\n\t\tconst response = await fetch(location.href, {\n\t\t\theaders: { accept: \"text/html\" },\n\t\t\tcredentials: \"include\",\n\t\t\tcache: \"no-store\",\n\t\t});\n\t\tstatus = response.status;\n\t\tif (response.ok) {\n\t\t\tconst html = await response.text();\n\t\t\tconst server = new DOMParser().parseFromString(html, \"text/html\");\n\t\t\tconst root = server.body || server.documentElement;\n\t\t\tserverTexts = textsOf(root);\n\t\t\tserverTags = tagsOf(root);\n\t\t\tfetched = true;\n\t\t}\n\t} catch (error) {\n\t\t// fetched stays false, which the judge reports honestly.\n\t}\n\n\tconst live = document.body || document.documentElement;\n\treturn {\n\t\turl: location.href,\n\t\tfetched,\n\t\t...(status === undefined ? {} : { status }),\n\t\tserverTexts,\n\t\tserverTags,\n\t\tclientTexts: textsOf(live),\n\t\tclientTags: tagsOf(live),\n\t};\n})()";
@@ -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";