@monoes/monobrowse 1.0.12 → 1.0.13

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 (175) hide show
  1. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  2. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  4. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  5. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  6. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/report-a11y.test.js +147 -0
  8. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  9. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  10. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/report-analyze.test.js +157 -0
  12. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  13. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  14. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/report-budget.test.js +80 -0
  16. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  17. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-diff.test.js +231 -0
  20. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  21. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-evidence.test.js +214 -0
  24. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  25. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-flake.test.js +182 -0
  28. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  29. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-history.test.js +229 -0
  32. package/dist/src/__tests__/report-history.test.js.map +1 -0
  33. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-output.test.js +169 -0
  36. package/dist/src/__tests__/report-output.test.js.map +1 -0
  37. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-png.test.js +84 -0
  40. package/dist/src/__tests__/report-png.test.js.map +1 -0
  41. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  44. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  45. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-render.test.js +143 -0
  48. package/dist/src/__tests__/report-render.test.js.map +1 -0
  49. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-run.test.js +234 -0
  52. package/dist/src/__tests__/report-run.test.js.map +1 -0
  53. package/dist/src/browser/index.d.ts +1 -0
  54. package/dist/src/browser/index.d.ts.map +1 -1
  55. package/dist/src/browser/index.js +1 -0
  56. package/dist/src/browser/index.js.map +1 -1
  57. package/dist/src/browser/profiler.d.ts.map +1 -1
  58. package/dist/src/browser/profiler.js +19 -22
  59. package/dist/src/browser/profiler.js.map +1 -1
  60. package/dist/src/cli/commands.d.ts.map +1 -1
  61. package/dist/src/cli/commands.js +254 -0
  62. package/dist/src/cli/commands.js.map +1 -1
  63. package/dist/src/report/a11y.d.ts +52 -0
  64. package/dist/src/report/a11y.d.ts.map +1 -0
  65. package/dist/src/report/a11y.js +231 -0
  66. package/dist/src/report/a11y.js.map +1 -0
  67. package/dist/src/report/analyze.d.ts +20 -0
  68. package/dist/src/report/analyze.d.ts.map +1 -0
  69. package/dist/src/report/analyze.js +136 -0
  70. package/dist/src/report/analyze.js.map +1 -0
  71. package/dist/src/report/budget.d.ts +27 -0
  72. package/dist/src/report/budget.d.ts.map +1 -0
  73. package/dist/src/report/budget.js +143 -0
  74. package/dist/src/report/budget.js.map +1 -0
  75. package/dist/src/report/collect-a11y.d.ts +25 -0
  76. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  77. package/dist/src/report/collect-a11y.js +154 -0
  78. package/dist/src/report/collect-a11y.js.map +1 -0
  79. package/dist/src/report/collect.d.ts +45 -0
  80. package/dist/src/report/collect.d.ts.map +1 -0
  81. package/dist/src/report/collect.js +275 -0
  82. package/dist/src/report/collect.js.map +1 -0
  83. package/dist/src/report/evidence.d.ts +80 -0
  84. package/dist/src/report/evidence.d.ts.map +1 -0
  85. package/dist/src/report/evidence.js +279 -0
  86. package/dist/src/report/evidence.js.map +1 -0
  87. package/dist/src/report/flake.d.ts +88 -0
  88. package/dist/src/report/flake.d.ts.map +1 -0
  89. package/dist/src/report/flake.js +232 -0
  90. package/dist/src/report/flake.js.map +1 -0
  91. package/dist/src/report/history.d.ts +85 -0
  92. package/dist/src/report/history.d.ts.map +1 -0
  93. package/dist/src/report/history.js +176 -0
  94. package/dist/src/report/history.js.map +1 -0
  95. package/dist/src/report/index.d.ts +102 -0
  96. package/dist/src/report/index.d.ts.map +1 -0
  97. package/dist/src/report/index.js +216 -0
  98. package/dist/src/report/index.js.map +1 -0
  99. package/dist/src/report/pixel-diff.d.ts +30 -0
  100. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  101. package/dist/src/report/pixel-diff.js +175 -0
  102. package/dist/src/report/pixel-diff.js.map +1 -0
  103. package/dist/src/report/png.d.ts +28 -0
  104. package/dist/src/report/png.d.ts.map +1 -0
  105. package/dist/src/report/png.js +259 -0
  106. package/dist/src/report/png.js.map +1 -0
  107. package/dist/src/report/render-evidence.d.ts +16 -0
  108. package/dist/src/report/render-evidence.d.ts.map +1 -0
  109. package/dist/src/report/render-evidence.js +174 -0
  110. package/dist/src/report/render-evidence.js.map +1 -0
  111. package/dist/src/report/render-trend.d.ts +16 -0
  112. package/dist/src/report/render-trend.d.ts.map +1 -0
  113. package/dist/src/report/render-trend.js +194 -0
  114. package/dist/src/report/render-trend.js.map +1 -0
  115. package/dist/src/report/render.d.ts +21 -0
  116. package/dist/src/report/render.d.ts.map +1 -0
  117. package/dist/src/report/render.js +286 -0
  118. package/dist/src/report/render.js.map +1 -0
  119. package/dist/src/report/run-diff.d.ts +18 -0
  120. package/dist/src/report/run-diff.d.ts.map +1 -0
  121. package/dist/src/report/run-diff.js +51 -0
  122. package/dist/src/report/run-diff.js.map +1 -0
  123. package/dist/src/report/structure.d.ts +41 -0
  124. package/dist/src/report/structure.d.ts.map +1 -0
  125. package/dist/src/report/structure.js +210 -0
  126. package/dist/src/report/structure.js.map +1 -0
  127. package/dist/src/report/trend.d.ts +26 -0
  128. package/dist/src/report/trend.d.ts.map +1 -0
  129. package/dist/src/report/trend.js +164 -0
  130. package/dist/src/report/trend.js.map +1 -0
  131. package/dist/src/report/types.d.ts +320 -0
  132. package/dist/src/report/types.d.ts.map +1 -0
  133. package/dist/src/report/types.js +11 -0
  134. package/dist/src/report/types.js.map +1 -0
  135. package/dist/src/report/util.d.ts +17 -0
  136. package/dist/src/report/util.d.ts.map +1 -0
  137. package/dist/src/report/util.js +45 -0
  138. package/dist/src/report/util.js.map +1 -0
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +1 -1
  141. package/src/__tests__/profiler-heap.test.ts +191 -0
  142. package/src/__tests__/report-a11y.test.ts +180 -0
  143. package/src/__tests__/report-analyze.test.ts +202 -0
  144. package/src/__tests__/report-budget.test.ts +92 -0
  145. package/src/__tests__/report-diff.test.ts +272 -0
  146. package/src/__tests__/report-evidence.test.ts +276 -0
  147. package/src/__tests__/report-flake.test.ts +214 -0
  148. package/src/__tests__/report-history.test.ts +293 -0
  149. package/src/__tests__/report-output.test.ts +210 -0
  150. package/src/__tests__/report-png.test.ts +95 -0
  151. package/src/__tests__/report-render-extras.test.ts +315 -0
  152. package/src/__tests__/report-render.test.ts +175 -0
  153. package/src/__tests__/report-run.test.ts +264 -0
  154. package/src/browser/index.ts +1 -0
  155. package/src/browser/profiler.ts +28 -21
  156. package/src/cli/commands.ts +272 -0
  157. package/src/report/a11y.ts +242 -0
  158. package/src/report/analyze.ts +181 -0
  159. package/src/report/budget.ts +155 -0
  160. package/src/report/collect-a11y.ts +200 -0
  161. package/src/report/collect.ts +408 -0
  162. package/src/report/evidence.ts +379 -0
  163. package/src/report/flake.ts +303 -0
  164. package/src/report/history.ts +219 -0
  165. package/src/report/index.ts +307 -0
  166. package/src/report/pixel-diff.ts +197 -0
  167. package/src/report/png.ts +290 -0
  168. package/src/report/render-evidence.ts +194 -0
  169. package/src/report/render-trend.ts +225 -0
  170. package/src/report/render.ts +314 -0
  171. package/src/report/run-diff.ts +63 -0
  172. package/src/report/structure.ts +212 -0
  173. package/src/report/trend.ts +189 -0
  174. package/src/report/types.ts +356 -0
  175. package/src/report/util.ts +48 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Structural signature of a page, and the diff between two of them (RIG-10).
3
+ *
4
+ * The signature is derived from the accessibility tree we already fetch for
5
+ * the a11y rules, so this costs no extra CDP round-trip. It is deliberately
6
+ * the AX tree and not the DOM: the AX tree is what the page *means* — a
7
+ * className churned by a CSS-in-JS build is invisible to it, while a button
8
+ * losing its label, a heading changing text, or a form field disappearing
9
+ * all show up. That is the signal a "did this deploy change anything real?"
10
+ * diff wants.
11
+ *
12
+ * Everything here is pure, so the tests feed hand-written trees.
13
+ */
14
+
15
+ import type { AxNode, StructureDiff, StructureNode } from './types.js';
16
+
17
+ /** Roles that exist only to hold other nodes — spliced out, children kept. */
18
+ const TRANSPARENT_ROLES = new Set([
19
+ 'none',
20
+ 'generic',
21
+ 'presentation',
22
+ 'inlinetextbox',
23
+ 'genericcontainer',
24
+ ]);
25
+
26
+ /** Bound on signature size: a 40k-node AX tree must not become a 40k-row diff. */
27
+ export const MAX_STRUCTURE_NODES = 1500;
28
+ const MAX_DEPTH = 25;
29
+ /** Accessible names are compared verbatim, so trim the essays. */
30
+ const MAX_NAME_LENGTH = 160;
31
+
32
+ function strValue(v: unknown): string {
33
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
34
+ }
35
+
36
+ function normalizeName(raw: string): string | null {
37
+ const flat = raw.replace(/\s+/g, ' ').trim();
38
+ if (!flat) return null;
39
+ return flat.length > MAX_NAME_LENGTH ? `${flat.slice(0, MAX_NAME_LENGTH - 1)}…` : flat;
40
+ }
41
+
42
+ /**
43
+ * Walk the AX tree into a flat, path-addressed signature.
44
+ *
45
+ * Path segments are `role[n]`, n counting siblings of the same role after
46
+ * transparent wrappers are spliced away. Two runs of an unchanged page
47
+ * therefore produce identical paths; an inserted element shifts only its own
48
+ * role's numbering, which the diff's second pass then recovers from.
49
+ */
50
+ export function buildStructure(nodes: AxNode[]): StructureNode[] {
51
+ const byId = new Map<string, AxNode>();
52
+ for (const node of nodes) byId.set(String(node.nodeId), node);
53
+
54
+ const childIds = (node: AxNode): string[] => (node.childIds ?? []).map(String);
55
+
56
+ /** Visible children, with transparent wrappers replaced by their children. */
57
+ const effectiveChildren = (node: AxNode, depth: number): AxNode[] => {
58
+ const out: AxNode[] = [];
59
+ const queue = [...childIds(node)];
60
+ let guard = 0;
61
+ while (queue.length && guard++ < 4000) {
62
+ const child = byId.get(queue.shift() as string);
63
+ if (!child) continue;
64
+ const role = strValue(child.role?.value).toLowerCase();
65
+ if (child.ignored || TRANSPARENT_ROLES.has(role)) {
66
+ if (depth < MAX_DEPTH) queue.unshift(...childIds(child));
67
+ continue;
68
+ }
69
+ out.push(child);
70
+ }
71
+ return out;
72
+ };
73
+
74
+ const referenced = new Set<string>();
75
+ for (const node of nodes) for (const id of childIds(node)) referenced.add(id);
76
+ const roots = nodes.filter((n) => !referenced.has(String(n.nodeId)));
77
+ // In a cycle every node has a parent, so there is no unreferenced root and
78
+ // the walk below would visit nothing. Start somewhere rather than silently
79
+ // returning an empty signature that the diff would read as "page is gone".
80
+ if (!roots.length && nodes.length) roots.push(nodes[0]);
81
+
82
+ const out: StructureNode[] = [];
83
+ const visited = new Set<string>();
84
+ const counters = new Map<string, Map<string, number>>();
85
+
86
+ const walk = (node: AxNode, parentPath: string, depth: number): void => {
87
+ if (out.length >= MAX_STRUCTURE_NODES || depth > MAX_DEPTH) return;
88
+ const id = String(node.nodeId);
89
+ if (visited.has(id)) return; // a malformed tree must not loop us forever
90
+ visited.add(id);
91
+
92
+ const role = strValue(node.role?.value).toLowerCase() || 'unknown';
93
+ let siblingsSeen = counters.get(parentPath);
94
+ if (!siblingsSeen) {
95
+ siblingsSeen = new Map<string, number>();
96
+ counters.set(parentPath, siblingsSeen);
97
+ }
98
+ const n = (siblingsSeen.get(role) ?? 0) + 1;
99
+ siblingsSeen.set(role, n);
100
+
101
+ const path = `${parentPath}/${role}[${n}]`;
102
+ out.push({ path, role, name: normalizeName(strValue(node.name?.value)), depth });
103
+
104
+ for (const child of effectiveChildren(node, depth)) walk(child, path, depth + 1);
105
+ };
106
+
107
+ for (const root of roots) {
108
+ const role = strValue(root.role?.value).toLowerCase();
109
+ if (root.ignored || TRANSPARENT_ROLES.has(role)) {
110
+ for (const child of effectiveChildren(root, 0)) walk(child, '', 0);
111
+ } else {
112
+ walk(root, '', 0);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Diff
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Identity for the second matching pass. Names are whitespace-collapsed to a
124
+ * single line by normalizeName, so a newline is a separator no value carries.
125
+ */
126
+ function identityKey(node: StructureNode): string {
127
+ return `${node.role}\n${node.name ?? ''}`;
128
+ }
129
+
130
+ /**
131
+ * Diff two signatures.
132
+ *
133
+ * Two passes, because path alone is too brittle to use on its own: inserting
134
+ * one list item renumbers every later sibling, which a naive path diff would
135
+ * report as "everything below here was replaced".
136
+ *
137
+ * 1. Exact path match — same slot. Same name means unchanged, a different
138
+ * name means renamed (the useful case: a label that silently changed).
139
+ * 2. Whatever is left is matched on role+name, which recovers elements that
140
+ * merely moved. Only what survives both passes is genuinely gained/lost.
141
+ */
142
+ export function diffStructure(previous: StructureNode[], current: StructureNode[]): StructureDiff {
143
+ const prevByPath = new Map(previous.map((n) => [n.path, n]));
144
+ const currByPath = new Map(current.map((n) => [n.path, n]));
145
+
146
+ const renamed: StructureDiff['renamed'] = [];
147
+ const unmatchedPrev: StructureNode[] = [];
148
+ const unmatchedCurr: StructureNode[] = [];
149
+ let unchanged = 0;
150
+
151
+ for (const node of current) {
152
+ const before = prevByPath.get(node.path);
153
+ if (!before) {
154
+ unmatchedCurr.push(node);
155
+ continue;
156
+ }
157
+ if (before.role === node.role && before.name === node.name) {
158
+ unchanged++;
159
+ } else if (before.role === node.role) {
160
+ renamed.push({ path: node.path, role: node.role, from: before.name, to: node.name });
161
+ } else {
162
+ // Same slot, different role — that is a replacement, not a rename.
163
+ unmatchedCurr.push(node);
164
+ unmatchedPrev.push(before);
165
+ }
166
+ }
167
+ for (const node of previous) {
168
+ if (!currByPath.has(node.path)) unmatchedPrev.push(node);
169
+ }
170
+
171
+ // Pass two: role+name identity, which is what "the same element, elsewhere"
172
+ // actually means to a reader.
173
+ const pool = new Map<string, StructureNode[]>();
174
+ for (const node of unmatchedPrev) {
175
+ const key = identityKey(node);
176
+ const bucket = pool.get(key);
177
+ if (bucket) bucket.push(node);
178
+ else pool.set(key, [node]);
179
+ }
180
+
181
+ const moved: StructureDiff['moved'] = [];
182
+ const gained: StructureNode[] = [];
183
+ for (const node of unmatchedCurr) {
184
+ const bucket = pool.get(identityKey(node));
185
+ const match = bucket?.shift();
186
+ if (match) {
187
+ moved.push({ role: node.role, name: node.name, from: match.path, to: node.path });
188
+ } else {
189
+ gained.push(node);
190
+ }
191
+ }
192
+ const lost = [...pool.values()].flat();
193
+
194
+ return {
195
+ gained,
196
+ lost,
197
+ renamed,
198
+ moved,
199
+ unchanged,
200
+ changed: gained.length + lost.length + renamed.length,
201
+ };
202
+ }
203
+
204
+ /** One line for a CI log: "3 gained, 1 lost, 2 renamed". */
205
+ export function summarizeStructureDiff(diff: StructureDiff): string {
206
+ const parts: string[] = [];
207
+ if (diff.gained.length) parts.push(`${diff.gained.length} gained`);
208
+ if (diff.lost.length) parts.push(`${diff.lost.length} lost`);
209
+ if (diff.renamed.length) parts.push(`${diff.renamed.length} renamed`);
210
+ if (diff.moved.length) parts.push(`${diff.moved.length} moved`);
211
+ return parts.length ? parts.join(', ') : 'structurally identical';
212
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Trends across a URL's run history (RIG-13).
3
+ *
4
+ * The point is catching slow decay. A single run that renders in 2.4s looks
5
+ * fine; the same page at 2.0s nine runs ago is a regression nobody filed a
6
+ * bug for, because no individual run ever crossed the budget. So every series
7
+ * reports two deltas — against the previous run (what just changed) and
8
+ * against the oldest run in the window (what has been happening) — and the
9
+ * second one is what produces a headline.
10
+ *
11
+ * Pure: the tests feed arrays of history records.
12
+ */
13
+
14
+ import type { HistoryRun } from './history.js';
15
+ import type { Report, TrendDirection, TrendPoint, TrendReport, TrendSeries } from './types.js';
16
+
17
+ type Unit = 'ms' | 'score' | 'count';
18
+
19
+ interface SeriesDef {
20
+ key: string;
21
+ label: string;
22
+ unit: Unit;
23
+ read: (run: Pick<HistoryRun, 'vitals' | 'counts'>) => number | null | undefined;
24
+ }
25
+
26
+ const SERIES: SeriesDef[] = [
27
+ { key: 'lcp', label: 'LCP', unit: 'ms', read: (r) => r.vitals.lcp },
28
+ { key: 'fcp', label: 'FCP', unit: 'ms', read: (r) => r.vitals.fcp },
29
+ { key: 'cls', label: 'CLS', unit: 'score', read: (r) => r.vitals.cls },
30
+ { key: 'inp', label: 'INP', unit: 'ms', read: (r) => r.vitals.inp },
31
+ { key: 'ttfb', label: 'TTFB', unit: 'ms', read: (r) => r.vitals.ttfb },
32
+ {
33
+ key: 'consoleErrors',
34
+ label: 'Console errors',
35
+ unit: 'count',
36
+ read: (r) => r.counts.consoleErrors,
37
+ },
38
+ { key: 'pageErrors', label: 'Page errors', unit: 'count', read: (r) => r.counts.pageErrors },
39
+ {
40
+ key: 'failedRequests',
41
+ label: 'Failed requests',
42
+ unit: 'count',
43
+ read: (r) => r.counts.failedRequests,
44
+ },
45
+ {
46
+ key: 'a11yErrors',
47
+ label: 'Accessibility errors',
48
+ unit: 'count',
49
+ read: (r) => r.counts.a11yErrors,
50
+ },
51
+ ];
52
+
53
+ /**
54
+ * Smallest change worth calling a change. Chrome's own run-to-run variance on
55
+ * a paint metric is tens of milliseconds, so without a floor every series
56
+ * would report a direction every single run and the word would stop meaning
57
+ * anything.
58
+ */
59
+ function significant(unit: Unit, delta: number, base: number | null): boolean {
60
+ const magnitude = Math.abs(delta);
61
+ if (unit === 'count') return magnitude >= 1;
62
+ if (unit === 'score') return magnitude >= 0.01;
63
+ const relative = base !== null && base > 0 ? base * 0.05 : 0;
64
+ return magnitude >= Math.max(25, relative);
65
+ }
66
+
67
+ export function formatTrendValue(unit: Unit, value: number | null): string {
68
+ if (value === null || !Number.isFinite(value)) return '—';
69
+ if (unit === 'ms') {
70
+ return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`;
71
+ }
72
+ if (unit === 'score') return value.toFixed(3);
73
+ return String(Math.round(value));
74
+ }
75
+
76
+ export function formatTrendDelta(unit: Unit, delta: number | null): string {
77
+ if (delta === null || !Number.isFinite(delta) || delta === 0) return 'no change';
78
+ const sign = delta > 0 ? '+' : '−';
79
+ return `${sign}${formatTrendValue(unit, Math.abs(delta))}`;
80
+ }
81
+
82
+ function clean(value: number | null | undefined): number | null {
83
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
84
+ }
85
+
86
+ function buildSeries(def: SeriesDef, history: HistoryRun[], current: Report): TrendSeries {
87
+ const points: TrendPoint[] = history.map((run) => ({
88
+ capturedAt: run.capturedAt,
89
+ value: clean(def.read(run)),
90
+ }));
91
+ const currentValue = clean(def.read({ vitals: current.vitals, counts: current.counts }));
92
+ points.push({ capturedAt: current.capturedAt, value: currentValue });
93
+
94
+ const priorWithValues = points.slice(0, -1).filter((p) => p.value !== null);
95
+ const previous = priorWithValues.length
96
+ ? (priorWithValues[priorWithValues.length - 1].value as number)
97
+ : null;
98
+ const oldest = priorWithValues.length ? (priorWithValues[0].value as number) : null;
99
+
100
+ const deltaFromPrevious =
101
+ currentValue !== null && previous !== null ? currentValue - previous : null;
102
+ const deltaFromOldest = currentValue !== null && oldest !== null ? currentValue - oldest : null;
103
+
104
+ let direction: TrendDirection = 'flat';
105
+ if (currentValue === null || previous === null) {
106
+ direction = 'new';
107
+ } else if (deltaFromPrevious !== null && significant(def.unit, deltaFromPrevious, previous)) {
108
+ // Every metric here is lower-is-better, so the sign maps straight across.
109
+ direction = deltaFromPrevious > 0 ? 'regressed' : 'improved';
110
+ }
111
+
112
+ const series: TrendSeries = {
113
+ key: def.key,
114
+ label: def.label,
115
+ unit: def.unit,
116
+ points,
117
+ current: currentValue,
118
+ previous,
119
+ oldest,
120
+ deltaFromPrevious,
121
+ deltaFromOldest,
122
+ direction,
123
+ };
124
+
125
+ // The creep sentence: drift across the whole window, which is invisible run
126
+ // to run. Needs at least three prior points to be worth asserting.
127
+ if (
128
+ priorWithValues.length >= 3 &&
129
+ deltaFromOldest !== null &&
130
+ oldest !== null &&
131
+ significant(def.unit, deltaFromOldest, oldest)
132
+ ) {
133
+ const verb = deltaFromOldest > 0 ? 'crept up' : 'come down';
134
+ series.creep =
135
+ `${def.label} has ${verb} ${formatTrendValue(def.unit, Math.abs(deltaFromOldest))} ` +
136
+ `over ${points.length} runs (${formatTrendValue(def.unit, oldest)} → ${formatTrendValue(def.unit, currentValue)}).`;
137
+ }
138
+ return series;
139
+ }
140
+
141
+ /**
142
+ * Rank the creep sentences so the report leads with the worst. Regressions
143
+ * first, then by how far the metric moved relative to where it started.
144
+ */
145
+ function headlinesFor(series: TrendSeries[], history: HistoryRun[], current: Report): string[] {
146
+ const regressions = series
147
+ .filter((s) => s.creep && (s.deltaFromOldest ?? 0) > 0)
148
+ .sort((a, b) => {
149
+ const ratio = (s: TrendSeries) =>
150
+ s.oldest && s.oldest > 0 ? Math.abs(s.deltaFromOldest ?? 0) / s.oldest : Infinity;
151
+ return ratio(b) - ratio(a);
152
+ })
153
+ .map((s) => s.creep as string);
154
+
155
+ const headlines = [...regressions];
156
+
157
+ // A verdict flip is the loudest thing that can happen, so it goes first.
158
+ const priorVerdicts = history.map((r) => r.verdict);
159
+ const allPassed = priorVerdicts.length > 0 && priorVerdicts.every((v) => v === 'pass');
160
+ const allFailed = priorVerdicts.length > 0 && priorVerdicts.every((v) => v === 'fail');
161
+ if (current.verdict === 'fail' && allPassed) {
162
+ headlines.unshift(
163
+ `First failure in this window — the previous ${priorVerdicts.length} run(s) all passed.`,
164
+ );
165
+ } else if (current.verdict === 'pass' && allFailed) {
166
+ headlines.unshift(`Back to passing — the previous ${priorVerdicts.length} run(s) all failed.`);
167
+ }
168
+ return headlines;
169
+ }
170
+
171
+ /**
172
+ * Compare `current` against prior runs for the same URL.
173
+ *
174
+ * `history` is oldest-first and must NOT include the current run; callers
175
+ * save the run after building the trend so a run never trends against itself.
176
+ */
177
+ export function buildTrend(current: Report, history: HistoryRun[]): TrendReport {
178
+ const series = SERIES.map((def) => buildSeries(def, history, current));
179
+ return {
180
+ runs: history.length,
181
+ windowFrom: history.length ? history[0].capturedAt : null,
182
+ series,
183
+ verdictHistory: [
184
+ ...history.map((r) => ({ capturedAt: r.capturedAt, verdict: r.verdict })),
185
+ { capturedAt: current.capturedAt, verdict: current.verdict },
186
+ ],
187
+ headlines: headlinesFor(series, history, current),
188
+ };
189
+ }