ssrwire 0.2.0 → 0.4.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/CHANGELOG.md +47 -1
- package/CONTRIBUTING.md +3 -1
- package/PUBLISHING.md +15 -15
- package/README.md +179 -29
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +162 -6
- package/dist/analyze.js.map +1 -1
- package/dist/audit-report.d.ts +8 -0
- package/dist/audit-report.d.ts.map +1 -0
- package/dist/audit-report.js +243 -0
- package/dist/audit-report.js.map +1 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +2 -0
- package/dist/audit.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +77 -10
- package/dist/cli.js.map +1 -1
- package/dist/compare.d.ts +6 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +720 -0
- package/dist/compare.js.map +1 -0
- package/dist/comparison-reporters.d.ts +9 -0
- package/dist/comparison-reporters.d.ts.map +1 -0
- package/dist/comparison-reporters.js +223 -0
- package/dist/comparison-reporters.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/http-probe.d.ts.map +1 -1
- package/dist/http-probe.js +4 -0
- package/dist/http-probe.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +6 -0
- package/dist/redact.js.map +1 -1
- package/dist/reporters.d.ts.map +1 -1
- package/dist/reporters.js +27 -0
- package/dist/reporters.js.map +1 -1
- package/dist/social.d.ts +14 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +88 -0
- package/dist/social.js.map +1 -0
- package/dist/stability.d.ts.map +1 -1
- package/dist/stability.js +84 -4
- package/dist/stability.js.map +1 -1
- package/dist/stream-parser.d.ts.map +1 -1
- package/dist/stream-parser.js +21 -1
- package/dist/stream-parser.js.map +1 -1
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +8 -2
- package/package.json +5 -2
- package/src/analyze.ts +208 -7
- package/src/audit-report.ts +269 -0
- package/src/audit.ts +2 -0
- package/src/cli.ts +106 -12
- package/src/compare.ts +949 -0
- package/src/comparison-reporters.ts +276 -0
- package/src/config.ts +19 -1
- package/src/http-probe.ts +4 -0
- package/src/index.ts +29 -0
- package/src/redact.ts +6 -0
- package/src/reporters.ts +46 -0
- package/src/social.ts +116 -0
- package/src/stability.ts +123 -4
- package/src/stream-parser.ts +31 -1
- package/src/types.ts +117 -0
package/src/compare.ts
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { AUDIT_SCHEMA_VERSION } from "./audit-report.js";
|
|
2
|
+
import {
|
|
3
|
+
effectiveTwitterCardSignal,
|
|
4
|
+
firstSocialSignal,
|
|
5
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES,
|
|
6
|
+
TWITTER_CARD_REQUIRED_FIELDS,
|
|
7
|
+
type TwitterCardField,
|
|
8
|
+
} from "./social.js";
|
|
9
|
+
import { calculateTimingStats, criticalSignalsArrivalMs } from "./stability.js";
|
|
10
|
+
import type {
|
|
11
|
+
AuditComparison,
|
|
12
|
+
AuditResult,
|
|
13
|
+
AuditTarget,
|
|
14
|
+
CompareAuditOptions,
|
|
15
|
+
ComparisonChange,
|
|
16
|
+
ComparisonKind,
|
|
17
|
+
ComparisonTimelineEvent,
|
|
18
|
+
ComparisonTimelineLane,
|
|
19
|
+
ComparisonTimelineSnapshot,
|
|
20
|
+
ElementLocation,
|
|
21
|
+
ElementSignal,
|
|
22
|
+
Finding,
|
|
23
|
+
ProbeResult,
|
|
24
|
+
SocialMetadataProperty,
|
|
25
|
+
TargetAuditResult,
|
|
26
|
+
TargetComparison,
|
|
27
|
+
TargetExpectations,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
import { VERSION } from "./version.js";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIMING_REGRESSION_MS = 250;
|
|
32
|
+
const DEFAULT_TIMING_REGRESSION_PERCENT = 25;
|
|
33
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
34
|
+
const SEVERITY_RANK = { info: 0, warning: 1, error: 2 } as const;
|
|
35
|
+
|
|
36
|
+
interface MetadataField {
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly signals: (probe: ProbeResult) => readonly ElementSignal[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TimelineObservation {
|
|
43
|
+
readonly atMs: number;
|
|
44
|
+
readonly observedByByte?: number;
|
|
45
|
+
readonly location?: ElementLocation | "mixed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TimelineDefinition {
|
|
49
|
+
readonly key: string;
|
|
50
|
+
readonly label: string;
|
|
51
|
+
readonly observe: (target: AuditTarget, probe: ProbeResult) => TimelineObservation | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SOCIAL_PROPERTIES: readonly SocialMetadataProperty[] = [
|
|
55
|
+
"og:title",
|
|
56
|
+
"og:type",
|
|
57
|
+
"og:url",
|
|
58
|
+
"og:image",
|
|
59
|
+
"og:description",
|
|
60
|
+
"twitter:card",
|
|
61
|
+
"twitter:title",
|
|
62
|
+
"twitter:description",
|
|
63
|
+
"twitter:image",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function socialSignals(
|
|
67
|
+
probe: ProbeResult,
|
|
68
|
+
property: SocialMetadataProperty,
|
|
69
|
+
): readonly ElementSignal[] {
|
|
70
|
+
return (probe.signals.socialMetadata ?? []).filter((signal) => signal.property === property);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const METADATA_FIELDS: readonly MetadataField[] = [
|
|
74
|
+
{
|
|
75
|
+
key: "title",
|
|
76
|
+
label: "title",
|
|
77
|
+
signals: (probe) => probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []),
|
|
78
|
+
},
|
|
79
|
+
{ key: "description", label: "description", signals: (probe) => probe.signals.descriptions },
|
|
80
|
+
{ key: "canonical", label: "canonical", signals: (probe) => probe.signals.canonicals },
|
|
81
|
+
{ key: "robots", label: "robots", signals: (probe) => probe.signals.robots },
|
|
82
|
+
{ key: "h1", label: "H1", signals: (probe) => probe.signals.h1s },
|
|
83
|
+
{
|
|
84
|
+
key: "main-text",
|
|
85
|
+
label: "main text",
|
|
86
|
+
signals: (probe) =>
|
|
87
|
+
probe.signals.firstMainText === undefined ? [] : [probe.signals.firstMainText],
|
|
88
|
+
},
|
|
89
|
+
...SOCIAL_PROPERTIES.map(
|
|
90
|
+
(property): MetadataField => ({
|
|
91
|
+
key: property,
|
|
92
|
+
label: property,
|
|
93
|
+
signals: (probe) => socialSignals(probe, property),
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
export class ComparisonError extends Error {
|
|
99
|
+
public constructor(message: string) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "ComparisonError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeText(value: string): string {
|
|
106
|
+
return value.trim().replace(/\s+/g, " ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rounded(value: number): number {
|
|
110
|
+
return Math.round(value * 100) / 100;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function median(values: readonly number[]): number | undefined {
|
|
114
|
+
return calculateTimingStats(values)?.medianMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function targetKey(target: AuditTarget): string {
|
|
118
|
+
return target.id === undefined ? `url:${target.url}` : `id:${target.id}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function displayKey(target: AuditTarget): string {
|
|
122
|
+
return target.id ?? target.url;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function targetMap(report: AuditResult, label: string): Map<string, TargetAuditResult> {
|
|
126
|
+
const results = new Map<string, TargetAuditResult>();
|
|
127
|
+
for (const result of report.results) {
|
|
128
|
+
const key = targetKey(result.target);
|
|
129
|
+
if (results.has(key)) {
|
|
130
|
+
throw new ComparisonError(`${label} contains a duplicate target identity.`);
|
|
131
|
+
}
|
|
132
|
+
results.set(key, result);
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function threshold(value: number | undefined, fallback: number, label: string): number {
|
|
138
|
+
const resolved = value ?? fallback;
|
|
139
|
+
if (!Number.isFinite(resolved) || resolved < 0) {
|
|
140
|
+
throw new ComparisonError(`${label} must be a finite non-negative number.`);
|
|
141
|
+
}
|
|
142
|
+
return resolved;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function kindOrder(kind: ComparisonKind): number {
|
|
146
|
+
if (kind === "regression") return 0;
|
|
147
|
+
if (kind === "fixed") return 1;
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sortChanges(changes: readonly ComparisonChange[]): readonly ComparisonChange[] {
|
|
152
|
+
return [...changes].sort(
|
|
153
|
+
(left, right) =>
|
|
154
|
+
kindOrder(left.kind) - kindOrder(right.kind) ||
|
|
155
|
+
(left.agent ?? "").localeCompare(right.agent ?? "") ||
|
|
156
|
+
(left.field ?? "").localeCompare(right.field ?? "") ||
|
|
157
|
+
left.code.localeCompare(right.code),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findingKey(finding: Finding): string {
|
|
162
|
+
return `${finding.code}\u0000${finding.agent ?? ""}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findingMap(findings: readonly Finding[]): Map<string, Finding> {
|
|
166
|
+
const mapped = new Map<string, Finding>();
|
|
167
|
+
for (const finding of findings) {
|
|
168
|
+
const key = findingKey(finding);
|
|
169
|
+
const existing = mapped.get(key);
|
|
170
|
+
if (
|
|
171
|
+
existing === undefined ||
|
|
172
|
+
SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]
|
|
173
|
+
) {
|
|
174
|
+
mapped.set(key, finding);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return mapped;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function stableEvidence(finding: Finding): string {
|
|
181
|
+
return JSON.stringify(
|
|
182
|
+
Object.fromEntries(
|
|
183
|
+
Object.entries(finding.evidence ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function compareFindings(
|
|
189
|
+
baseline: readonly Finding[],
|
|
190
|
+
candidate: readonly Finding[],
|
|
191
|
+
ignoredCodes: ReadonlySet<string> = new Set(),
|
|
192
|
+
): readonly ComparisonChange[] {
|
|
193
|
+
const baselineMap = findingMap(baseline.filter((finding) => !ignoredCodes.has(finding.code)));
|
|
194
|
+
const candidateMap = findingMap(candidate.filter((finding) => !ignoredCodes.has(finding.code)));
|
|
195
|
+
const keys = new Set([...baselineMap.keys(), ...candidateMap.keys()]);
|
|
196
|
+
const changes: ComparisonChange[] = [];
|
|
197
|
+
|
|
198
|
+
for (const key of keys) {
|
|
199
|
+
const before = baselineMap.get(key);
|
|
200
|
+
const after = candidateMap.get(key);
|
|
201
|
+
if (before === undefined && after !== undefined) {
|
|
202
|
+
changes.push({
|
|
203
|
+
kind: after.severity === "info" ? "changed" : "regression",
|
|
204
|
+
scope: "finding",
|
|
205
|
+
code: after.code,
|
|
206
|
+
message: `New ${after.severity} finding: ${after.message}`,
|
|
207
|
+
...(after.agent === undefined ? {} : { agent: after.agent }),
|
|
208
|
+
candidate: after.severity,
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (before !== undefined && after === undefined) {
|
|
213
|
+
changes.push({
|
|
214
|
+
kind: "fixed",
|
|
215
|
+
scope: "finding",
|
|
216
|
+
code: before.code,
|
|
217
|
+
message: `Resolved ${before.severity} finding: ${before.message}`,
|
|
218
|
+
...(before.agent === undefined ? {} : { agent: before.agent }),
|
|
219
|
+
baseline: before.severity,
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (before === undefined || after === undefined) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (before.severity === after.severity) {
|
|
228
|
+
if (stableEvidence(before) !== stableEvidence(after)) {
|
|
229
|
+
changes.push({
|
|
230
|
+
kind: "changed",
|
|
231
|
+
scope: "finding",
|
|
232
|
+
code: "finding-evidence-changed",
|
|
233
|
+
message: `${after.code} evidence changed while severity remained ${after.severity}.`,
|
|
234
|
+
...(after.agent === undefined ? {} : { agent: after.agent }),
|
|
235
|
+
field: after.code,
|
|
236
|
+
baseline: stableEvidence(before),
|
|
237
|
+
candidate: stableEvidence(after),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const worsened = SEVERITY_RANK[after.severity] > SEVERITY_RANK[before.severity];
|
|
244
|
+
changes.push({
|
|
245
|
+
kind: worsened ? "regression" : "fixed",
|
|
246
|
+
scope: "finding",
|
|
247
|
+
code: after.code,
|
|
248
|
+
message: `${after.code} severity ${worsened ? "increased" : "decreased"} from ${before.severity} to ${after.severity}.`,
|
|
249
|
+
...(after.agent === undefined ? {} : { agent: after.agent }),
|
|
250
|
+
baseline: before.severity,
|
|
251
|
+
candidate: after.severity,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return changes;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function agentGroups(result: TargetAuditResult): Map<string, readonly ProbeResult[]> {
|
|
259
|
+
const groups = new Map<string, ProbeResult[]>();
|
|
260
|
+
for (const probe of result.probes) {
|
|
261
|
+
const probes = groups.get(probe.agent.key) ?? [];
|
|
262
|
+
probes.push(probe);
|
|
263
|
+
groups.set(probe.agent.key, probes);
|
|
264
|
+
}
|
|
265
|
+
return groups;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function uniqueStrings(values: readonly string[]): readonly string[] {
|
|
269
|
+
return [...new Set(values)].sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function valuesEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
273
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function displayValues(values: readonly string[]): string {
|
|
277
|
+
if (values.length === 0) return "<missing>";
|
|
278
|
+
const visible = values
|
|
279
|
+
.slice(0, 3)
|
|
280
|
+
.map((value) => (value.length <= 160 ? value : `${value.slice(0, 159)}…`));
|
|
281
|
+
return `${visible.join(" | ")}${values.length > visible.length ? ` | +${values.length - visible.length} more` : ""}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeUrlForOrigin(value: string, originUrl: string): string {
|
|
285
|
+
try {
|
|
286
|
+
const url = new URL(value, originUrl);
|
|
287
|
+
const origin = new URL(originUrl).origin;
|
|
288
|
+
return url.origin === origin ? `${url.pathname}${url.search}` : url.href;
|
|
289
|
+
} catch {
|
|
290
|
+
return normalizeText(value);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function compareResponses(
|
|
295
|
+
baselineTarget: AuditTarget,
|
|
296
|
+
candidateTarget: AuditTarget,
|
|
297
|
+
agent: string,
|
|
298
|
+
baseline: readonly ProbeResult[],
|
|
299
|
+
candidate: readonly ProbeResult[],
|
|
300
|
+
): readonly ComparisonChange[] {
|
|
301
|
+
const changes: ComparisonChange[] = [];
|
|
302
|
+
const beforeCompletions = uniqueStrings(baseline.map((probe) => probe.completion));
|
|
303
|
+
const afterCompletions = uniqueStrings(candidate.map((probe) => probe.completion));
|
|
304
|
+
if (!valuesEqual(beforeCompletions, afterCompletions)) {
|
|
305
|
+
const beforeComplete = beforeCompletions.length === 1 && beforeCompletions[0] === "complete";
|
|
306
|
+
const afterComplete = afterCompletions.length === 1 && afterCompletions[0] === "complete";
|
|
307
|
+
const kind: ComparisonKind = beforeComplete
|
|
308
|
+
? "regression"
|
|
309
|
+
: afterComplete
|
|
310
|
+
? "fixed"
|
|
311
|
+
: "changed";
|
|
312
|
+
changes.push({
|
|
313
|
+
kind,
|
|
314
|
+
scope: "response",
|
|
315
|
+
code: "probe-completion-changed",
|
|
316
|
+
message: `Probe completion changed from ${beforeCompletions.join(", ")} to ${afterCompletions.join(", ")}.`,
|
|
317
|
+
agent,
|
|
318
|
+
field: "completion",
|
|
319
|
+
baseline: beforeCompletions.join(", "),
|
|
320
|
+
candidate: afterCompletions.join(", "),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const statuses = (probes: readonly ProbeResult[]): readonly string[] =>
|
|
325
|
+
uniqueStrings(probes.map((probe) => String(probe.status ?? "missing")));
|
|
326
|
+
const beforeStatuses = statuses(baseline);
|
|
327
|
+
const afterStatuses = statuses(candidate);
|
|
328
|
+
if (!valuesEqual(beforeStatuses, afterStatuses)) {
|
|
329
|
+
changes.push({
|
|
330
|
+
kind: "changed",
|
|
331
|
+
scope: "response",
|
|
332
|
+
code: "http-status-changed",
|
|
333
|
+
message: `HTTP status changed from ${beforeStatuses.join(", ")} to ${afterStatuses.join(", ")}.`,
|
|
334
|
+
agent,
|
|
335
|
+
field: "status",
|
|
336
|
+
baseline: beforeStatuses.join(", "),
|
|
337
|
+
candidate: afterStatuses.join(", "),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const finalUrls = (probes: readonly ProbeResult[], target: AuditTarget): readonly string[] =>
|
|
342
|
+
uniqueStrings(probes.map((probe) => normalizeUrlForOrigin(probe.finalUrl, target.url)));
|
|
343
|
+
const beforeFinalUrls = finalUrls(baseline, baselineTarget);
|
|
344
|
+
const afterFinalUrls = finalUrls(candidate, candidateTarget);
|
|
345
|
+
if (!valuesEqual(beforeFinalUrls, afterFinalUrls)) {
|
|
346
|
+
changes.push({
|
|
347
|
+
kind: "changed",
|
|
348
|
+
scope: "response",
|
|
349
|
+
code: "final-url-changed",
|
|
350
|
+
message: "Final response route changed.",
|
|
351
|
+
agent,
|
|
352
|
+
field: "final-url",
|
|
353
|
+
baseline: displayValues(beforeFinalUrls),
|
|
354
|
+
candidate: displayValues(afterFinalUrls),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const redirectChains = (probes: readonly ProbeResult[], target: AuditTarget): readonly string[] =>
|
|
359
|
+
uniqueStrings(
|
|
360
|
+
probes.map((probe) =>
|
|
361
|
+
probe.redirects.length === 0
|
|
362
|
+
? "<none>"
|
|
363
|
+
: probe.redirects
|
|
364
|
+
.map(
|
|
365
|
+
(redirect) =>
|
|
366
|
+
`${redirect.status} ${normalizeUrlForOrigin(redirect.location, target.url)}`,
|
|
367
|
+
)
|
|
368
|
+
.join(" → "),
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
const beforeRedirects = redirectChains(baseline, baselineTarget);
|
|
372
|
+
const afterRedirects = redirectChains(candidate, candidateTarget);
|
|
373
|
+
if (!valuesEqual(beforeRedirects, afterRedirects)) {
|
|
374
|
+
changes.push({
|
|
375
|
+
kind: "changed",
|
|
376
|
+
scope: "response",
|
|
377
|
+
code: "redirect-chain-changed",
|
|
378
|
+
message: "Redirect chain changed.",
|
|
379
|
+
agent,
|
|
380
|
+
field: "redirects",
|
|
381
|
+
baseline: displayValues(beforeRedirects),
|
|
382
|
+
candidate: displayValues(afterRedirects),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return changes;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function metadataValues(field: MetadataField, probes: readonly ProbeResult[]): readonly string[] {
|
|
390
|
+
return uniqueStrings(
|
|
391
|
+
probes
|
|
392
|
+
.filter((probe) => probe.completion === "complete")
|
|
393
|
+
.flatMap((probe) => field.signals(probe))
|
|
394
|
+
.map((signal) => normalizeText(signal.value))
|
|
395
|
+
.filter((value) => value.length > 0),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function metadataLocations(
|
|
400
|
+
field: MetadataField,
|
|
401
|
+
probes: readonly ProbeResult[],
|
|
402
|
+
): readonly string[] {
|
|
403
|
+
return uniqueStrings(
|
|
404
|
+
probes
|
|
405
|
+
.filter((probe) => probe.completion === "complete")
|
|
406
|
+
.flatMap((probe) => field.signals(probe))
|
|
407
|
+
.filter((signal) => normalizeText(signal.value).length > 0)
|
|
408
|
+
.map((signal) => signal.location),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function compareMetadata(
|
|
413
|
+
agent: string,
|
|
414
|
+
baseline: readonly ProbeResult[],
|
|
415
|
+
candidate: readonly ProbeResult[],
|
|
416
|
+
): readonly ComparisonChange[] {
|
|
417
|
+
if (
|
|
418
|
+
!baseline.some((probe) => probe.completion === "complete") ||
|
|
419
|
+
!candidate.some((probe) => probe.completion === "complete")
|
|
420
|
+
) {
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const changes: ComparisonChange[] = [];
|
|
425
|
+
for (const field of METADATA_FIELDS) {
|
|
426
|
+
const beforeValues = metadataValues(field, baseline);
|
|
427
|
+
const afterValues = metadataValues(field, candidate);
|
|
428
|
+
if (!valuesEqual(beforeValues, afterValues)) {
|
|
429
|
+
changes.push({
|
|
430
|
+
kind: "changed",
|
|
431
|
+
scope: "metadata",
|
|
432
|
+
code: "metadata-value-changed",
|
|
433
|
+
message: `${field.label} value changed.`,
|
|
434
|
+
agent,
|
|
435
|
+
field: field.key,
|
|
436
|
+
baseline: displayValues(beforeValues),
|
|
437
|
+
candidate: displayValues(afterValues),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const beforeLocations = metadataLocations(field, baseline);
|
|
442
|
+
const afterLocations = metadataLocations(field, candidate);
|
|
443
|
+
if (!valuesEqual(beforeLocations, afterLocations)) {
|
|
444
|
+
changes.push({
|
|
445
|
+
kind: "changed",
|
|
446
|
+
scope: "metadata",
|
|
447
|
+
code: "metadata-location-changed",
|
|
448
|
+
message: `${field.label} document location changed.`,
|
|
449
|
+
agent,
|
|
450
|
+
field: field.key,
|
|
451
|
+
baseline: displayValues(beforeLocations),
|
|
452
|
+
candidate: displayValues(afterLocations),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return changes;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function timingValues(
|
|
460
|
+
metric: "headers" | "first-byte" | "critical-signals" | "complete",
|
|
461
|
+
target: AuditTarget,
|
|
462
|
+
probes: readonly ProbeResult[],
|
|
463
|
+
): readonly number[] {
|
|
464
|
+
if (metric === "headers") {
|
|
465
|
+
return probes.flatMap((probe) =>
|
|
466
|
+
probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status)
|
|
467
|
+
? [probe.timings.headersMs]
|
|
468
|
+
: [],
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (metric === "first-byte") {
|
|
472
|
+
return probes.flatMap((probe) =>
|
|
473
|
+
probe.timings.firstByteMs === undefined ? [] : [probe.timings.firstByteMs],
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (metric === "complete") {
|
|
477
|
+
return probes.flatMap((probe) =>
|
|
478
|
+
probe.completion === "complete" && probe.timings.completeMs !== undefined
|
|
479
|
+
? [probe.timings.completeMs]
|
|
480
|
+
: [],
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return probes.flatMap((probe) => {
|
|
484
|
+
if (probe.completion !== "complete") return [];
|
|
485
|
+
const value = criticalSignalsArrivalMs(target, probe);
|
|
486
|
+
return value === undefined ? [] : [value];
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function crossesTimingThreshold(
|
|
491
|
+
improvementMs: number,
|
|
492
|
+
referenceMs: number,
|
|
493
|
+
minimumMs: number,
|
|
494
|
+
minimumPercent: number,
|
|
495
|
+
): boolean {
|
|
496
|
+
const percent =
|
|
497
|
+
referenceMs === 0
|
|
498
|
+
? improvementMs > 0
|
|
499
|
+
? Number.POSITIVE_INFINITY
|
|
500
|
+
: 0
|
|
501
|
+
: (improvementMs / referenceMs) * 100;
|
|
502
|
+
return improvementMs > minimumMs && percent > minimumPercent;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function compareTimings(
|
|
506
|
+
baselineTarget: AuditTarget,
|
|
507
|
+
candidateTarget: AuditTarget,
|
|
508
|
+
agent: string,
|
|
509
|
+
baseline: readonly ProbeResult[],
|
|
510
|
+
candidate: readonly ProbeResult[],
|
|
511
|
+
minimumMs: number,
|
|
512
|
+
minimumPercent: number,
|
|
513
|
+
): readonly ComparisonChange[] {
|
|
514
|
+
const changes: ComparisonChange[] = [];
|
|
515
|
+
const labels = {
|
|
516
|
+
headers: "response headers",
|
|
517
|
+
"first-byte": "first byte",
|
|
518
|
+
"critical-signals": "required signals",
|
|
519
|
+
complete: "completion",
|
|
520
|
+
} as const;
|
|
521
|
+
|
|
522
|
+
const sameCriticalPolicy =
|
|
523
|
+
baselineTarget.expectations.requireTitle === candidateTarget.expectations.requireTitle &&
|
|
524
|
+
baselineTarget.expectations.requireDescription ===
|
|
525
|
+
candidateTarget.expectations.requireDescription &&
|
|
526
|
+
baselineTarget.expectations.requireCanonical ===
|
|
527
|
+
candidateTarget.expectations.requireCanonical &&
|
|
528
|
+
baselineTarget.expectations.requireH1 === candidateTarget.expectations.requireH1 &&
|
|
529
|
+
baselineTarget.expectations.requireMainText === candidateTarget.expectations.requireMainText &&
|
|
530
|
+
(baselineTarget.expectations.requireOpenGraph ?? false) ===
|
|
531
|
+
(candidateTarget.expectations.requireOpenGraph ?? false) &&
|
|
532
|
+
(baselineTarget.expectations.requireTwitterCard ?? false) ===
|
|
533
|
+
(candidateTarget.expectations.requireTwitterCard ?? false);
|
|
534
|
+
|
|
535
|
+
for (const metric of ["headers", "first-byte", "critical-signals", "complete"] as const) {
|
|
536
|
+
if (metric === "critical-signals" && !sameCriticalPolicy) continue;
|
|
537
|
+
const before = median(timingValues(metric, baselineTarget, baseline));
|
|
538
|
+
const after = median(timingValues(metric, candidateTarget, candidate));
|
|
539
|
+
if (before === undefined || after === undefined || before === after) continue;
|
|
540
|
+
|
|
541
|
+
const delta = after - before;
|
|
542
|
+
const slower = delta > 0;
|
|
543
|
+
if (!crossesTimingThreshold(Math.abs(delta), before, minimumMs, minimumPercent)) continue;
|
|
544
|
+
|
|
545
|
+
const percent = before === 0 ? Number.POSITIVE_INFINITY : (Math.abs(delta) / before) * 100;
|
|
546
|
+
changes.push({
|
|
547
|
+
kind: slower ? "regression" : "fixed",
|
|
548
|
+
scope: "timing",
|
|
549
|
+
code: slower ? "timing-regression" : "timing-improvement",
|
|
550
|
+
message: `${labels[metric]} median became ${rounded(Math.abs(delta))} ms (${Number.isFinite(percent) ? `${rounded(percent)}%` : "∞"}) ${slower ? "slower" : "faster"}.`,
|
|
551
|
+
agent,
|
|
552
|
+
field: metric,
|
|
553
|
+
baseline: rounded(before),
|
|
554
|
+
candidate: rounded(after),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return changes;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function changedExpectationFields(
|
|
561
|
+
baseline: TargetExpectations,
|
|
562
|
+
candidate: TargetExpectations,
|
|
563
|
+
): readonly string[] {
|
|
564
|
+
const fields: readonly (keyof TargetExpectations)[] = [
|
|
565
|
+
"statuses",
|
|
566
|
+
"finalUrl",
|
|
567
|
+
"requireTitle",
|
|
568
|
+
"requireDescription",
|
|
569
|
+
"requireCanonical",
|
|
570
|
+
"requireH1",
|
|
571
|
+
"requireMainText",
|
|
572
|
+
"requireOpenGraph",
|
|
573
|
+
"requireTwitterCard",
|
|
574
|
+
"maxFirstByteMs",
|
|
575
|
+
"maxCriticalMs",
|
|
576
|
+
];
|
|
577
|
+
return fields.filter(
|
|
578
|
+
(field) => JSON.stringify(baseline[field] ?? null) !== JSON.stringify(candidate[field] ?? null),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function combinedLocation(
|
|
583
|
+
signals: readonly ElementSignal[],
|
|
584
|
+
): ElementLocation | "mixed" | undefined {
|
|
585
|
+
const locations = uniqueStrings(signals.map((signal) => signal.location));
|
|
586
|
+
if (locations.length === 0) return undefined;
|
|
587
|
+
if (locations.length > 1) return "mixed";
|
|
588
|
+
const location = locations[0];
|
|
589
|
+
return location === "head" || location === "body" || location === "document"
|
|
590
|
+
? location
|
|
591
|
+
: undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function signalObservation(signals: readonly ElementSignal[]): TimelineObservation | undefined {
|
|
595
|
+
const usable = signals.filter((signal) => normalizeText(signal.value).length > 0);
|
|
596
|
+
const arrival =
|
|
597
|
+
usable.length === 0 ? undefined : Math.max(...usable.map((signal) => signal.atMs));
|
|
598
|
+
if (arrival === undefined) return undefined;
|
|
599
|
+
const location = combinedLocation(usable);
|
|
600
|
+
return {
|
|
601
|
+
atMs: arrival,
|
|
602
|
+
observedByByte: Math.max(...usable.map((signal) => signal.observedByByte)),
|
|
603
|
+
...(location === undefined ? {} : { location }),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function firstNonEmptySignal(signals: readonly ElementSignal[]): readonly ElementSignal[] {
|
|
608
|
+
const signal = signals.find((item) => normalizeText(item.value).length > 0);
|
|
609
|
+
return signal === undefined ? [] : [signal];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function twitterReadySignals(probe: ProbeResult): readonly ElementSignal[] {
|
|
613
|
+
return TWITTER_CARD_REQUIRED_FIELDS.flatMap((field: TwitterCardField) => {
|
|
614
|
+
const signal = effectiveTwitterCardSignal(probe.signals, field);
|
|
615
|
+
return signal === undefined ? [] : [signal];
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const TIMELINE_DEFINITIONS: readonly TimelineDefinition[] = [
|
|
620
|
+
{
|
|
621
|
+
key: "headers",
|
|
622
|
+
label: "Headers",
|
|
623
|
+
observe: (_target, probe) => ({ atMs: probe.timings.headersMs }),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
key: "first-byte",
|
|
627
|
+
label: "First byte",
|
|
628
|
+
observe: (_target, probe) =>
|
|
629
|
+
probe.timings.firstByteMs === undefined ? undefined : { atMs: probe.timings.firstByteMs },
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
key: "title",
|
|
633
|
+
label: "Title",
|
|
634
|
+
observe: (_target, probe) =>
|
|
635
|
+
signalObservation(probe.signals.title === undefined ? [] : [probe.signals.title]),
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
key: "description",
|
|
639
|
+
label: "Description",
|
|
640
|
+
observe: (_target, probe) => signalObservation(firstNonEmptySignal(probe.signals.descriptions)),
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
key: "canonical",
|
|
644
|
+
label: "Canonical",
|
|
645
|
+
observe: (_target, probe) => signalObservation(firstNonEmptySignal(probe.signals.canonicals)),
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
key: "open-graph",
|
|
649
|
+
label: "Open Graph ready",
|
|
650
|
+
observe: (_target, probe) => {
|
|
651
|
+
const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.flatMap((property) => {
|
|
652
|
+
const signal = firstSocialSignal(probe.signals, property);
|
|
653
|
+
return signal === undefined ? [] : [signal];
|
|
654
|
+
});
|
|
655
|
+
return signals.length === OPEN_GRAPH_REQUIRED_PROPERTIES.length
|
|
656
|
+
? signalObservation(signals)
|
|
657
|
+
: undefined;
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
key: "twitter-card",
|
|
662
|
+
label: "Twitter Card ready",
|
|
663
|
+
observe: (_target, probe) => {
|
|
664
|
+
const signals = twitterReadySignals(probe);
|
|
665
|
+
return signals.length === TWITTER_CARD_REQUIRED_FIELDS.length
|
|
666
|
+
? signalObservation(signals)
|
|
667
|
+
: undefined;
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
key: "required",
|
|
672
|
+
label: "Required ready",
|
|
673
|
+
observe: (target, probe) => {
|
|
674
|
+
const atMs = criticalSignalsArrivalMs(target, probe);
|
|
675
|
+
return atMs === undefined ? undefined : { atMs };
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
key: "main",
|
|
680
|
+
label: "Main text",
|
|
681
|
+
observe: (_target, probe) =>
|
|
682
|
+
signalObservation(
|
|
683
|
+
probe.signals.firstMainText === undefined ? [] : [probe.signals.firstMainText],
|
|
684
|
+
),
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
key: "complete",
|
|
688
|
+
label: "Complete",
|
|
689
|
+
observe: (_target, probe) =>
|
|
690
|
+
probe.completion !== "complete" || probe.timings.completeMs === undefined
|
|
691
|
+
? undefined
|
|
692
|
+
: { atMs: probe.timings.completeMs },
|
|
693
|
+
},
|
|
694
|
+
];
|
|
695
|
+
|
|
696
|
+
function timelineSnapshot(
|
|
697
|
+
target: AuditTarget,
|
|
698
|
+
probes: readonly ProbeResult[],
|
|
699
|
+
): ComparisonTimelineSnapshot | undefined {
|
|
700
|
+
if (probes.length === 0) return undefined;
|
|
701
|
+
const events: ComparisonTimelineEvent[] = [];
|
|
702
|
+
for (const definition of TIMELINE_DEFINITIONS) {
|
|
703
|
+
const observations = probes.flatMap((probe) => {
|
|
704
|
+
const observation = definition.observe(target, probe);
|
|
705
|
+
return observation === undefined ? [] : [observation];
|
|
706
|
+
});
|
|
707
|
+
const medianMs = median(observations.map((observation) => observation.atMs));
|
|
708
|
+
if (medianMs === undefined) continue;
|
|
709
|
+
const bytes = median(
|
|
710
|
+
observations.flatMap((observation) =>
|
|
711
|
+
observation.observedByByte === undefined ? [] : [observation.observedByByte],
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
const locations = uniqueStrings(
|
|
715
|
+
observations.flatMap((observation) =>
|
|
716
|
+
observation.location === undefined ? [] : [observation.location],
|
|
717
|
+
),
|
|
718
|
+
);
|
|
719
|
+
const location =
|
|
720
|
+
locations.length === 1 ? locations[0] : locations.length > 1 ? "mixed" : undefined;
|
|
721
|
+
events.push({
|
|
722
|
+
key: definition.key,
|
|
723
|
+
label: definition.label,
|
|
724
|
+
medianMs: rounded(medianMs),
|
|
725
|
+
...(location === "head" ||
|
|
726
|
+
location === "body" ||
|
|
727
|
+
location === "document" ||
|
|
728
|
+
location === "mixed"
|
|
729
|
+
? { location }
|
|
730
|
+
: {}),
|
|
731
|
+
...(bytes === undefined ? {} : { observedByByte: rounded(bytes) }),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return { samples: probes.length, events };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function comparisonTimelines(
|
|
738
|
+
baseline: TargetAuditResult | undefined,
|
|
739
|
+
candidate: TargetAuditResult | undefined,
|
|
740
|
+
): readonly ComparisonTimelineLane[] {
|
|
741
|
+
const before: Map<string, readonly ProbeResult[]> =
|
|
742
|
+
baseline === undefined ? new Map() : agentGroups(baseline);
|
|
743
|
+
const after: Map<string, readonly ProbeResult[]> =
|
|
744
|
+
candidate === undefined ? new Map() : agentGroups(candidate);
|
|
745
|
+
const agentKeys = [...new Set([...before.keys(), ...after.keys()])];
|
|
746
|
+
return agentKeys.map((agent) => {
|
|
747
|
+
const baselineProbes = before.get(agent) ?? [];
|
|
748
|
+
const candidateProbes = after.get(agent) ?? [];
|
|
749
|
+
const profile = candidateProbes[0]?.agent ?? baselineProbes[0]?.agent;
|
|
750
|
+
const baselineSnapshot =
|
|
751
|
+
baseline === undefined ? undefined : timelineSnapshot(baseline.target, baselineProbes);
|
|
752
|
+
const candidateSnapshot =
|
|
753
|
+
candidate === undefined ? undefined : timelineSnapshot(candidate.target, candidateProbes);
|
|
754
|
+
return {
|
|
755
|
+
agent,
|
|
756
|
+
label: profile?.label ?? agent,
|
|
757
|
+
...(baselineSnapshot === undefined ? {} : { baseline: baselineSnapshot }),
|
|
758
|
+
...(candidateSnapshot === undefined ? {} : { candidate: candidateSnapshot }),
|
|
759
|
+
};
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function compareMatchedTarget(
|
|
764
|
+
baseline: TargetAuditResult,
|
|
765
|
+
candidate: TargetAuditResult,
|
|
766
|
+
timingRegressionMs: number,
|
|
767
|
+
timingRegressionPercent: number,
|
|
768
|
+
): readonly ComparisonChange[] {
|
|
769
|
+
const changes: ComparisonChange[] = [
|
|
770
|
+
...compareFindings(baseline.findings, candidate.findings, new Set(["incomplete-probe"])),
|
|
771
|
+
];
|
|
772
|
+
const policyFields = changedExpectationFields(
|
|
773
|
+
baseline.target.expectations,
|
|
774
|
+
candidate.target.expectations,
|
|
775
|
+
);
|
|
776
|
+
if (policyFields.length > 0) {
|
|
777
|
+
changes.push({
|
|
778
|
+
kind: "changed",
|
|
779
|
+
scope: "target",
|
|
780
|
+
code: "target-policy-changed",
|
|
781
|
+
message: `Target policy changed: ${policyFields.join(", ")}.`,
|
|
782
|
+
field: "expectations",
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const beforeAgents = agentGroups(baseline);
|
|
787
|
+
const afterAgents = agentGroups(candidate);
|
|
788
|
+
const agents = new Set([...beforeAgents.keys(), ...afterAgents.keys()]);
|
|
789
|
+
for (const agent of agents) {
|
|
790
|
+
const before = beforeAgents.get(agent);
|
|
791
|
+
const after = afterAgents.get(agent);
|
|
792
|
+
if (before === undefined && after !== undefined) {
|
|
793
|
+
changes.push({
|
|
794
|
+
kind: "changed",
|
|
795
|
+
scope: "agent",
|
|
796
|
+
code: "agent-added",
|
|
797
|
+
message: `Agent ${agent} was added to the candidate report.`,
|
|
798
|
+
agent,
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (before !== undefined && after === undefined) {
|
|
803
|
+
changes.push({
|
|
804
|
+
kind: "changed",
|
|
805
|
+
scope: "agent",
|
|
806
|
+
code: "agent-removed",
|
|
807
|
+
message: `Agent ${agent} is absent from the candidate report.`,
|
|
808
|
+
agent,
|
|
809
|
+
});
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (before === undefined || after === undefined) continue;
|
|
813
|
+
|
|
814
|
+
changes.push(
|
|
815
|
+
...compareResponses(baseline.target, candidate.target, agent, before, after),
|
|
816
|
+
...compareMetadata(agent, before, after),
|
|
817
|
+
...compareTimings(
|
|
818
|
+
baseline.target,
|
|
819
|
+
candidate.target,
|
|
820
|
+
agent,
|
|
821
|
+
before,
|
|
822
|
+
after,
|
|
823
|
+
timingRegressionMs,
|
|
824
|
+
timingRegressionPercent,
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return sortChanges(changes);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function compareAudits(
|
|
832
|
+
baseline: AuditResult,
|
|
833
|
+
candidate: AuditResult,
|
|
834
|
+
options: CompareAuditOptions = {},
|
|
835
|
+
): AuditComparison {
|
|
836
|
+
if (
|
|
837
|
+
baseline.schemaVersion !== AUDIT_SCHEMA_VERSION ||
|
|
838
|
+
candidate.schemaVersion !== AUDIT_SCHEMA_VERSION
|
|
839
|
+
) {
|
|
840
|
+
throw new ComparisonError(`Both audit reports must use schemaVersion ${AUDIT_SCHEMA_VERSION}.`);
|
|
841
|
+
}
|
|
842
|
+
const timingRegressionMs = threshold(
|
|
843
|
+
options.timingRegressionMs,
|
|
844
|
+
DEFAULT_TIMING_REGRESSION_MS,
|
|
845
|
+
"timingRegressionMs",
|
|
846
|
+
);
|
|
847
|
+
const timingRegressionPercent = threshold(
|
|
848
|
+
options.timingRegressionPercent,
|
|
849
|
+
DEFAULT_TIMING_REGRESSION_PERCENT,
|
|
850
|
+
"timingRegressionPercent",
|
|
851
|
+
);
|
|
852
|
+
const baselineMap = targetMap(baseline, "Baseline report");
|
|
853
|
+
const candidateMap = targetMap(candidate, "Candidate report");
|
|
854
|
+
const keys = [...baselineMap.keys(), ...candidateMap.keys()].filter(
|
|
855
|
+
(key, index, all) => all.indexOf(key) === index,
|
|
856
|
+
);
|
|
857
|
+
const results: TargetComparison[] = [];
|
|
858
|
+
|
|
859
|
+
for (const key of keys) {
|
|
860
|
+
const before = baselineMap.get(key);
|
|
861
|
+
const after = candidateMap.get(key);
|
|
862
|
+
const target = after?.target ?? before?.target;
|
|
863
|
+
if (target === undefined) continue;
|
|
864
|
+
if (before === undefined && after !== undefined) {
|
|
865
|
+
results.push({
|
|
866
|
+
key: displayKey(target),
|
|
867
|
+
...(target.id === undefined ? {} : { id: target.id }),
|
|
868
|
+
status: "added",
|
|
869
|
+
candidateUrl: target.url,
|
|
870
|
+
changes: sortChanges([
|
|
871
|
+
{
|
|
872
|
+
kind: "changed",
|
|
873
|
+
scope: "target",
|
|
874
|
+
code: "target-added",
|
|
875
|
+
message: "Target exists only in the candidate report.",
|
|
876
|
+
},
|
|
877
|
+
...compareFindings([], after.findings),
|
|
878
|
+
]),
|
|
879
|
+
timelines: comparisonTimelines(undefined, after),
|
|
880
|
+
});
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (before !== undefined && after === undefined) {
|
|
884
|
+
results.push({
|
|
885
|
+
key: displayKey(target),
|
|
886
|
+
...(target.id === undefined ? {} : { id: target.id }),
|
|
887
|
+
status: "removed",
|
|
888
|
+
baselineUrl: target.url,
|
|
889
|
+
changes: [
|
|
890
|
+
{
|
|
891
|
+
kind: "changed",
|
|
892
|
+
scope: "target",
|
|
893
|
+
code: "target-removed",
|
|
894
|
+
message: "Target exists only in the baseline report.",
|
|
895
|
+
},
|
|
896
|
+
],
|
|
897
|
+
timelines: comparisonTimelines(before, undefined),
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (before === undefined || after === undefined) continue;
|
|
902
|
+
|
|
903
|
+
results.push({
|
|
904
|
+
key: displayKey(after.target),
|
|
905
|
+
...(after.target.id === undefined ? {} : { id: after.target.id }),
|
|
906
|
+
status: "matched",
|
|
907
|
+
baselineUrl: before.target.url,
|
|
908
|
+
candidateUrl: after.target.url,
|
|
909
|
+
changes: compareMatchedTarget(before, after, timingRegressionMs, timingRegressionPercent),
|
|
910
|
+
timelines: comparisonTimelines(before, after),
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const allChanges = results.flatMap((result) => result.changes);
|
|
915
|
+
return {
|
|
916
|
+
schemaVersion: AUDIT_SCHEMA_VERSION,
|
|
917
|
+
kind: "comparison",
|
|
918
|
+
version: VERSION,
|
|
919
|
+
generatedAt: new Date().toISOString(),
|
|
920
|
+
baseline: {
|
|
921
|
+
label: options.baselineLabel ?? "Baseline",
|
|
922
|
+
version: baseline.version,
|
|
923
|
+
schemaVersion: baseline.schemaVersion,
|
|
924
|
+
generatedAt: baseline.generatedAt,
|
|
925
|
+
repeat: baseline.repeat ?? 1,
|
|
926
|
+
},
|
|
927
|
+
candidate: {
|
|
928
|
+
label: options.candidateLabel ?? "Candidate",
|
|
929
|
+
version: candidate.version,
|
|
930
|
+
schemaVersion: candidate.schemaVersion,
|
|
931
|
+
generatedAt: candidate.generatedAt,
|
|
932
|
+
repeat: candidate.repeat ?? 1,
|
|
933
|
+
},
|
|
934
|
+
thresholds: { timingRegressionMs, timingRegressionPercent },
|
|
935
|
+
results,
|
|
936
|
+
summary: {
|
|
937
|
+
targets: results.length,
|
|
938
|
+
matchedTargets: results.filter((result) => result.status === "matched").length,
|
|
939
|
+
addedTargets: results.filter((result) => result.status === "added").length,
|
|
940
|
+
removedTargets: results.filter((result) => result.status === "removed").length,
|
|
941
|
+
unchangedTargets: results.filter(
|
|
942
|
+
(result) => result.status === "matched" && result.changes.length === 0,
|
|
943
|
+
).length,
|
|
944
|
+
regressions: allChanges.filter((change) => change.kind === "regression").length,
|
|
945
|
+
fixed: allChanges.filter((change) => change.kind === "fixed").length,
|
|
946
|
+
changed: allChanges.filter((change) => change.kind === "changed").length,
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
}
|