ssrwire 0.3.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 +24 -1
- package/CONTRIBUTING.md +2 -1
- package/PUBLISHING.md +15 -15
- package/README.md +109 -6
- 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 +74 -9
- 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 +16 -1
- package/dist/config.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 +1 -0
- package/dist/redact.js.map +1 -1
- package/dist/types.d.ts +83 -1
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +4 -2
- package/package.json +1 -1
- package/src/audit-report.ts +269 -0
- package/src/audit.ts +2 -0
- package/src/cli.ts +103 -11
- package/src/compare.ts +949 -0
- package/src/comparison-reporters.ts +276 -0
- package/src/config.ts +15 -1
- package/src/index.ts +27 -0
- package/src/redact.ts +1 -0
- package/src/types.ts +97 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditComparison,
|
|
3
|
+
ComparisonChange,
|
|
4
|
+
ComparisonKind,
|
|
5
|
+
ComparisonReportFormat,
|
|
6
|
+
ComparisonTimelineEvent,
|
|
7
|
+
ComparisonTimelineSnapshot,
|
|
8
|
+
TargetComparison,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ComparisonReporterOptions {
|
|
12
|
+
readonly color?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ANSI = {
|
|
16
|
+
red: "\u001b[31m",
|
|
17
|
+
green: "\u001b[32m",
|
|
18
|
+
yellow: "\u001b[33m",
|
|
19
|
+
blue: "\u001b[36m",
|
|
20
|
+
bold: "\u001b[1m",
|
|
21
|
+
dim: "\u001b[2m",
|
|
22
|
+
reset: "\u001b[0m",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const EVENT_SHORT_LABELS: Readonly<Record<string, string>> = {
|
|
26
|
+
headers: "H",
|
|
27
|
+
"first-byte": "B",
|
|
28
|
+
title: "T",
|
|
29
|
+
description: "D",
|
|
30
|
+
canonical: "C",
|
|
31
|
+
"open-graph": "OG",
|
|
32
|
+
"twitter-card": "X",
|
|
33
|
+
required: "R",
|
|
34
|
+
main: "M",
|
|
35
|
+
complete: "✓",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function paint(value: string, code: string, enabled: boolean): string {
|
|
39
|
+
return enabled ? `${code}${value}${ANSI.reset}` : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function terminalSafe(value: string): string {
|
|
43
|
+
let safe = "";
|
|
44
|
+
for (const character of value) {
|
|
45
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
46
|
+
safe +=
|
|
47
|
+
codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
48
|
+
? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
|
|
49
|
+
: character;
|
|
50
|
+
}
|
|
51
|
+
return safe;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stableValue(value: unknown): unknown {
|
|
55
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
56
|
+
if (value !== null && typeof value === "object") {
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(value)
|
|
59
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
60
|
+
.map(([key, entry]) => [key, stableValue(entry)]),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stableJson(value: unknown): string {
|
|
67
|
+
return `${JSON.stringify(stableValue(value), null, 2)}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function kindLabel(kind: ComparisonKind): string {
|
|
71
|
+
if (kind === "regression") return "REGRESS";
|
|
72
|
+
if (kind === "fixed") return "FIXED";
|
|
73
|
+
return "CHANGED";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function kindColor(kind: ComparisonKind): string {
|
|
77
|
+
if (kind === "regression") return ANSI.red;
|
|
78
|
+
if (kind === "fixed") return ANSI.green;
|
|
79
|
+
return ANSI.blue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatChange(change: ComparisonChange, color: boolean): string {
|
|
83
|
+
const label = paint(kindLabel(change.kind).padEnd(7), kindColor(change.kind), color);
|
|
84
|
+
const qualifiers = [change.agent, change.field].filter(
|
|
85
|
+
(value): value is string => value !== undefined,
|
|
86
|
+
);
|
|
87
|
+
const context = qualifiers.length === 0 ? "" : ` [${qualifiers.map(terminalSafe).join("/")}]`;
|
|
88
|
+
const values =
|
|
89
|
+
change.baseline === undefined && change.candidate === undefined
|
|
90
|
+
? ""
|
|
91
|
+
: ` (${terminalSafe(String(change.baseline ?? "—"))} → ${terminalSafe(String(change.candidate ?? "—"))})`;
|
|
92
|
+
return ` ${label} ${terminalSafe(change.code)}${context}: ${terminalSafe(change.message)}${values}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderComparisonTerminal(
|
|
96
|
+
comparison: AuditComparison,
|
|
97
|
+
options: ComparisonReporterOptions = {},
|
|
98
|
+
): string {
|
|
99
|
+
const color = options.color ?? Boolean(process.stdout.isTTY);
|
|
100
|
+
const lines = [
|
|
101
|
+
paint(`SSRWire ${comparison.version} comparison`, ANSI.bold, color),
|
|
102
|
+
`${paint("Baseline", ANSI.dim, color)}: ${terminalSafe(comparison.baseline.label)} · SSRWire ${terminalSafe(comparison.baseline.version)} · ${terminalSafe(comparison.baseline.generatedAt)}`,
|
|
103
|
+
`${paint("Candidate", ANSI.dim, color)}: ${terminalSafe(comparison.candidate.label)} · SSRWire ${terminalSafe(comparison.candidate.version)} · ${terminalSafe(comparison.candidate.generatedAt)}`,
|
|
104
|
+
paint(
|
|
105
|
+
`Timing regression requires >${comparison.thresholds.timingRegressionMs} ms and >${comparison.thresholds.timingRegressionPercent}% median increase`,
|
|
106
|
+
ANSI.dim,
|
|
107
|
+
color,
|
|
108
|
+
),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
for (const result of comparison.results) {
|
|
112
|
+
lines.push("", paint(terminalSafe(result.key), ANSI.bold, color));
|
|
113
|
+
if (result.baselineUrl !== undefined) {
|
|
114
|
+
lines.push(` Baseline: ${terminalSafe(result.baselineUrl)}`);
|
|
115
|
+
}
|
|
116
|
+
if (result.candidateUrl !== undefined) {
|
|
117
|
+
lines.push(` Candidate: ${terminalSafe(result.candidateUrl)}`);
|
|
118
|
+
}
|
|
119
|
+
if (result.changes.length === 0) {
|
|
120
|
+
lines.push(paint(" No differences", ANSI.dim, color));
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(...result.changes.map((change) => formatChange(change, color)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { summary } = comparison;
|
|
127
|
+
const summaryText =
|
|
128
|
+
`Summary: ${summary.matchedTargets} matched, ${summary.addedTargets} added, ` +
|
|
129
|
+
`${summary.removedTargets} removed, ${summary.unchangedTargets} unchanged; ` +
|
|
130
|
+
`${summary.regressions} regression(s), ${summary.fixed} fixed, ${summary.changed} changed`;
|
|
131
|
+
const summaryColor = summary.regressions > 0 ? ANSI.red : ANSI.green;
|
|
132
|
+
lines.push("", paint(summaryText, summaryColor, color));
|
|
133
|
+
return `${lines.join("\n")}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderComparisonJson(comparison: AuditComparison): string {
|
|
137
|
+
return stableJson(comparison);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeHtml(value: string): string {
|
|
141
|
+
return value
|
|
142
|
+
.replaceAll("&", "&")
|
|
143
|
+
.replaceAll("<", "<")
|
|
144
|
+
.replaceAll(">", ">")
|
|
145
|
+
.replaceAll('"', """)
|
|
146
|
+
.replaceAll("'", "'");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatMs(value: number): string {
|
|
150
|
+
return `${Math.round(value)} ms`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function maxTimelineMs(result: TargetComparison): number {
|
|
154
|
+
return Math.max(
|
|
155
|
+
1,
|
|
156
|
+
...result.timelines.flatMap((lane) => [
|
|
157
|
+
...(lane.baseline?.events.map((event) => event.medianMs) ?? []),
|
|
158
|
+
...(lane.candidate?.events.map((event) => event.medianMs) ?? []),
|
|
159
|
+
]),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function eventTitle(event: ComparisonTimelineEvent): string {
|
|
164
|
+
const details = [event.label, formatMs(event.medianMs)];
|
|
165
|
+
if (event.location !== undefined) details.push(event.location);
|
|
166
|
+
if (event.observedByByte !== undefined) {
|
|
167
|
+
details.push(`by byte ${Math.round(event.observedByByte)}`);
|
|
168
|
+
}
|
|
169
|
+
return details.join(" · ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderTimelineSnapshot(
|
|
173
|
+
side: string,
|
|
174
|
+
snapshot: ComparisonTimelineSnapshot | undefined,
|
|
175
|
+
maxMs: number,
|
|
176
|
+
): string {
|
|
177
|
+
if (snapshot === undefined) {
|
|
178
|
+
return `<div class="timeline-row"><div class="side">${escapeHtml(side)}</div><div class="missing">not present</div></div>`;
|
|
179
|
+
}
|
|
180
|
+
const events = snapshot.events
|
|
181
|
+
.map((event, index) => {
|
|
182
|
+
const position = Math.max(0, Math.min(100, (event.medianMs / maxMs) * 100));
|
|
183
|
+
const short = EVENT_SHORT_LABELS[event.key] ?? event.key.slice(0, 2).toUpperCase();
|
|
184
|
+
const title = escapeHtml(eventTitle(event));
|
|
185
|
+
return `<span class="event" style="--position:${position.toFixed(3)}%;--level:${index % 2}" title="${title}" aria-label="${title}" tabindex="0"><span>${escapeHtml(short)}</span></span>`;
|
|
186
|
+
})
|
|
187
|
+
.join("");
|
|
188
|
+
return `<div class="timeline-row"><div class="side">${escapeHtml(side)} <small>n=${snapshot.samples}</small></div><div class="track">${events}</div></div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderTimelines(result: TargetComparison): string {
|
|
192
|
+
if (result.timelines.length === 0) return "";
|
|
193
|
+
const maxMs = maxTimelineMs(result);
|
|
194
|
+
const lanes = result.timelines
|
|
195
|
+
.map(
|
|
196
|
+
(lane) => `<section class="agent-lane">
|
|
197
|
+
<h4>${escapeHtml(lane.label)} <code>${escapeHtml(lane.agent)}</code></h4>
|
|
198
|
+
${renderTimelineSnapshot("Baseline", lane.baseline, maxMs)}
|
|
199
|
+
${renderTimelineSnapshot("Candidate", lane.candidate, maxMs)}
|
|
200
|
+
</section>`,
|
|
201
|
+
)
|
|
202
|
+
.join("");
|
|
203
|
+
return `<details class="waterfall" open><summary>Wire waterfall <span>0–${escapeHtml(formatMs(maxMs))}</span></summary>${lanes}</details>`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderChange(change: ComparisonChange): string {
|
|
207
|
+
const context = [change.agent, change.field]
|
|
208
|
+
.filter((value): value is string => value !== undefined)
|
|
209
|
+
.map((value) => `<code>${escapeHtml(value)}</code>`)
|
|
210
|
+
.join(" ");
|
|
211
|
+
const values =
|
|
212
|
+
change.baseline === undefined && change.candidate === undefined
|
|
213
|
+
? ""
|
|
214
|
+
: `<dl><div><dt>Baseline</dt><dd>${escapeHtml(String(change.baseline ?? "—"))}</dd></div><div><dt>Candidate</dt><dd>${escapeHtml(String(change.candidate ?? "—"))}</dd></div></dl>`;
|
|
215
|
+
return `<article class="change ${change.kind}">
|
|
216
|
+
<div class="change-heading"><span>${escapeHtml(kindLabel(change.kind))}</span><strong>${escapeHtml(change.code)}</strong>${context}</div>
|
|
217
|
+
<p>${escapeHtml(change.message)}</p>${values}
|
|
218
|
+
</article>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderTarget(result: TargetComparison): string {
|
|
222
|
+
const urls = [
|
|
223
|
+
result.baselineUrl === undefined
|
|
224
|
+
? ""
|
|
225
|
+
: `<div><span>Baseline</span>${escapeHtml(result.baselineUrl)}</div>`,
|
|
226
|
+
result.candidateUrl === undefined
|
|
227
|
+
? ""
|
|
228
|
+
: `<div><span>Candidate</span>${escapeHtml(result.candidateUrl)}</div>`,
|
|
229
|
+
].join("");
|
|
230
|
+
const changes =
|
|
231
|
+
result.changes.length === 0
|
|
232
|
+
? '<p class="clean">No differences detected.</p>'
|
|
233
|
+
: `<div class="changes">${result.changes.map(renderChange).join("")}</div>`;
|
|
234
|
+
return `<section class="target">
|
|
235
|
+
<header><div><span class="target-status">${escapeHtml(result.status)}</span><h2>${escapeHtml(result.key)}</h2></div><div class="urls">${urls}</div></header>
|
|
236
|
+
${renderTimelines(result)}${changes}
|
|
237
|
+
</section>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderComparisonHtml(comparison: AuditComparison): string {
|
|
241
|
+
const { summary } = comparison;
|
|
242
|
+
const outcome = summary.regressions > 0 ? "Regressions detected" : "No regressions";
|
|
243
|
+
const targets = comparison.results.map(renderTarget).join("\n");
|
|
244
|
+
return `<!doctype html>
|
|
245
|
+
<html lang="en">
|
|
246
|
+
<head>
|
|
247
|
+
<meta charset="utf-8">
|
|
248
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
249
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
250
|
+
<title>SSRWire comparison · ${escapeHtml(outcome)}</title>
|
|
251
|
+
<style>
|
|
252
|
+
:root{color-scheme:light dark;--bg:#f5f2eb;--panel:#fffdf8;--ink:#191c1b;--muted:#656c68;--line:#d8d5cc;--red:#b42318;--red-bg:#fff0ed;--green:#18794e;--green-bg:#ebf8f1;--blue:#1769aa;--blue-bg:#edf6ff;--track:#e3e7e4}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:48px 0 80px}.hero{display:grid;grid-template-columns:1fr auto;gap:32px;align-items:end;margin-bottom:28px}.eyebrow,.target-status{color:var(--muted);font-size:12px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}h1{font-size:clamp(34px,6vw,64px);line-height:1;margin:.18em 0}.hero p{color:var(--muted);margin:0}.outcome{border:1px solid var(--line);border-radius:18px;background:var(--panel);padding:18px 22px;min-width:260px}.outcome strong{display:block;font-size:22px}.outcome.bad strong{color:var(--red)}.counts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}.counts div{border-radius:10px;padding:8px;background:var(--bg)}.counts b{display:block;font-size:20px}.counts span{color:var(--muted);font-size:12px}.sources{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:28px}.source,.target{background:var(--panel);border:1px solid var(--line);border-radius:18px}.source{padding:16px 18px}.source span,.urls span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.source strong{display:block;font-size:17px}.source small{color:var(--muted)}.threshold{color:var(--muted);font-size:13px;margin:-14px 0 28px}.target{overflow:hidden;margin:18px 0}.target>header{display:grid;grid-template-columns:minmax(180px,.55fr) 1fr;gap:24px;padding:22px 24px;border-bottom:1px solid var(--line)}.target h2{font-size:23px;margin:3px 0 0;overflow-wrap:anywhere}.urls{display:grid;gap:8px;overflow-wrap:anywhere}.waterfall{padding:18px 24px;border-bottom:1px solid var(--line)}.waterfall summary{cursor:pointer;font-weight:700}.waterfall summary span{color:var(--muted);font-weight:400;margin-left:8px}.agent-lane{margin:18px 0}.agent-lane h4{margin:0 0 8px}.agent-lane code,.change-heading code{color:var(--muted);font-size:12px}.timeline-row{display:grid;grid-template-columns:126px 1fr;align-items:center;margin:5px 0}.side{color:var(--muted);font-size:13px}.side small{opacity:.75}.track{position:relative;height:48px;border-radius:8px;background:linear-gradient(90deg,var(--track),transparent 1px) 0 0/10% 100%,color-mix(in srgb,var(--track) 55%,transparent)}.event{position:absolute;left:var(--position);top:calc(5px + var(--level)*20px);transform:translateX(-50%);cursor:help}.event:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.event:before{content:"";position:absolute;left:50%;top:-5px;height:42px;border-left:1px solid color-mix(in srgb,var(--ink) 22%,transparent);z-index:0}.event span{position:relative;display:block;min-width:20px;padding:1px 4px;border:1px solid var(--line);border-radius:5px;background:var(--panel);font-size:10px;font-weight:800;text-align:center;z-index:1}.missing{color:var(--muted);font-size:13px}.changes{display:grid;gap:10px;padding:18px 24px 24px}.change{border-left:4px solid var(--blue);border-radius:8px;background:var(--blue-bg);padding:12px 14px}.change.regression{border-color:var(--red);background:var(--red-bg)}.change.fixed{border-color:var(--green);background:var(--green-bg)}.change-heading{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.change-heading>span{font-size:11px;font-weight:850;letter-spacing:.08em}.change p{margin:5px 0 0}.change dl{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0 0}.change dl div{min-width:0;padding:7px 9px;border-radius:6px;background:color-mix(in srgb,var(--panel) 70%,transparent)}dt{color:var(--muted);font-size:11px;text-transform:uppercase}dd{margin:1px 0 0;overflow-wrap:anywhere}.clean{color:var(--green);font-weight:700;padding:18px 24px;margin:0}.legend{color:var(--muted);font-size:12px;margin-top:30px}.legend code{color:var(--ink)}@media(max-width:720px){main{width:min(100% - 20px,1180px);padding-top:24px}.hero,.sources,.target>header{grid-template-columns:1fr}.outcome{min-width:0}.timeline-row{grid-template-columns:1fr}.side{margin-bottom:3px}.change dl{grid-template-columns:1fr}}
|
|
253
|
+
@media(prefers-color-scheme:dark){:root{--bg:#151817;--panel:#1d211f;--ink:#f0f3f1;--muted:#aab2ad;--line:#363d39;--red:#ff8a80;--red-bg:#321d1a;--green:#73d6a6;--green-bg:#173125;--blue:#75bfff;--blue-bg:#172a3a;--track:#323a36}}
|
|
254
|
+
</style>
|
|
255
|
+
</head>
|
|
256
|
+
<body><main>
|
|
257
|
+
<section class="hero"><div><div class="eyebrow">SSRWire ${escapeHtml(comparison.version)} · deployment diff</div><h1>${escapeHtml(outcome)}</h1><p>Generated ${escapeHtml(comparison.generatedAt)}</p></div><aside class="outcome ${summary.regressions > 0 ? "bad" : "good"}"><strong>${summary.regressions} regression${summary.regressions === 1 ? "" : "s"}</strong><div class="counts"><div><b>${summary.fixed}</b><span>fixed</span></div><div><b>${summary.changed}</b><span>changed</span></div><div><b>${summary.unchangedTargets}</b><span>unchanged</span></div></div></aside></section>
|
|
258
|
+
<section class="sources"><div class="source"><span>Baseline</span><strong>${escapeHtml(comparison.baseline.label)}</strong><small>SSRWire ${escapeHtml(comparison.baseline.version)} · ${escapeHtml(comparison.baseline.generatedAt)} · repeat ${comparison.baseline.repeat}</small></div><div class="source"><span>Candidate</span><strong>${escapeHtml(comparison.candidate.label)}</strong><small>SSRWire ${escapeHtml(comparison.candidate.version)} · ${escapeHtml(comparison.candidate.generatedAt)} · repeat ${comparison.candidate.repeat}</small></div></section>
|
|
259
|
+
<p class="threshold">Timing regressions require both >${comparison.thresholds.timingRegressionMs} ms and >${comparison.thresholds.timingRegressionPercent}% median increase.</p>
|
|
260
|
+
${targets || '<section class="target"><p class="clean">Both reports contain no targets.</p></section>'}
|
|
261
|
+
<p class="legend"><code>H</code> headers · <code>B</code> first byte · <code>T</code> title · <code>D</code> description · <code>C</code> canonical · <code>OG</code> Open Graph ready · <code>X</code> Twitter Card ready · <code>R</code> required signals ready · <code>M</code> main text · <code>✓</code> complete. Hover markers for timing, location, and byte evidence.</p>
|
|
262
|
+
</main></body></html>\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function renderComparisonReport(
|
|
266
|
+
comparison: AuditComparison,
|
|
267
|
+
format: ComparisonReportFormat,
|
|
268
|
+
options: ComparisonReporterOptions = {},
|
|
269
|
+
): string {
|
|
270
|
+
if (format === "terminal") return renderComparisonTerminal(comparison, options);
|
|
271
|
+
if (format === "json") return renderComparisonJson(comparison);
|
|
272
|
+
if (format === "html") return renderComparisonHtml(comparison);
|
|
273
|
+
|
|
274
|
+
const exhaustive: never = format;
|
|
275
|
+
return exhaustive;
|
|
276
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -30,6 +30,10 @@ const requireSchema = z
|
|
|
30
30
|
|
|
31
31
|
const targetObjectSchema = z
|
|
32
32
|
.object({
|
|
33
|
+
id: z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i)
|
|
36
|
+
.optional(),
|
|
33
37
|
url: z.string().min(1),
|
|
34
38
|
expectedStatus: z
|
|
35
39
|
.union([
|
|
@@ -138,6 +142,7 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
|
|
|
138
142
|
};
|
|
139
143
|
|
|
140
144
|
return {
|
|
145
|
+
...(item.id === undefined ? {} : { id: item.id }),
|
|
141
146
|
url: validateHttpUrl(item.url, "target URL"),
|
|
142
147
|
expectations,
|
|
143
148
|
};
|
|
@@ -259,13 +264,22 @@ async function readConfig(path: string | undefined): Promise<FileConfig> {
|
|
|
259
264
|
|
|
260
265
|
function uniqueTargets(targets: readonly AuditTarget[]): readonly AuditTarget[] {
|
|
261
266
|
const seen = new Set<string>();
|
|
262
|
-
|
|
267
|
+
const unique = targets.filter((target) => {
|
|
263
268
|
if (seen.has(target.url)) {
|
|
264
269
|
return false;
|
|
265
270
|
}
|
|
266
271
|
seen.add(target.url);
|
|
267
272
|
return true;
|
|
268
273
|
});
|
|
274
|
+
const ids = new Set<string>();
|
|
275
|
+
for (const target of unique) {
|
|
276
|
+
if (target.id === undefined) continue;
|
|
277
|
+
if (ids.has(target.id)) {
|
|
278
|
+
throw new ConfigError(`Target id ${target.id} is duplicated.`);
|
|
279
|
+
}
|
|
280
|
+
ids.add(target.id);
|
|
281
|
+
}
|
|
282
|
+
return unique;
|
|
269
283
|
}
|
|
270
284
|
|
|
271
285
|
export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWireConfig> {
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export { BUILTIN_AGENTS, resolveAgent, resolveAgents } from "./agents.js";
|
|
2
2
|
export { analyzeTarget, summarizeAudit } from "./analyze.js";
|
|
3
3
|
export { runAudit } from "./audit.js";
|
|
4
|
+
export {
|
|
5
|
+
AUDIT_SCHEMA_VERSION,
|
|
6
|
+
AuditReportError,
|
|
7
|
+
parseAuditReport,
|
|
8
|
+
parseAuditReportText,
|
|
9
|
+
} from "./audit-report.js";
|
|
10
|
+
export { ComparisonError, compareAudits } from "./compare.js";
|
|
11
|
+
export {
|
|
12
|
+
renderComparisonHtml,
|
|
13
|
+
renderComparisonJson,
|
|
14
|
+
renderComparisonReport,
|
|
15
|
+
renderComparisonTerminal,
|
|
16
|
+
} from "./comparison-reporters.js";
|
|
4
17
|
export { loadConfig, parseHeaderOption } from "./config.js";
|
|
5
18
|
export { probeUrl } from "./http-probe.js";
|
|
6
19
|
export { redactProbe } from "./redact.js";
|
|
@@ -9,9 +22,21 @@ export { createStreamInspector } from "./stream-parser.js";
|
|
|
9
22
|
export type {
|
|
10
23
|
AgentProfile,
|
|
11
24
|
AgentStability,
|
|
25
|
+
AuditComparison,
|
|
26
|
+
AuditReportDescriptor,
|
|
12
27
|
AuditResult,
|
|
13
28
|
AuditSummary,
|
|
14
29
|
AuditTarget,
|
|
30
|
+
CompareAuditOptions,
|
|
31
|
+
ComparisonChange,
|
|
32
|
+
ComparisonKind,
|
|
33
|
+
ComparisonReportFormat,
|
|
34
|
+
ComparisonScope,
|
|
35
|
+
ComparisonSummary,
|
|
36
|
+
ComparisonThresholds,
|
|
37
|
+
ComparisonTimelineEvent,
|
|
38
|
+
ComparisonTimelineLane,
|
|
39
|
+
ComparisonTimelineSnapshot,
|
|
15
40
|
DocumentSignals,
|
|
16
41
|
ElementLocation,
|
|
17
42
|
ElementSignal,
|
|
@@ -33,6 +58,8 @@ export type {
|
|
|
33
58
|
StabilityTimings,
|
|
34
59
|
StabilityVariants,
|
|
35
60
|
TargetAuditResult,
|
|
61
|
+
TargetComparison,
|
|
62
|
+
TargetComparisonStatus,
|
|
36
63
|
TargetExpectations,
|
|
37
64
|
TimingMark,
|
|
38
65
|
TimingStats,
|
package/src/redact.ts
CHANGED
|
@@ -156,6 +156,7 @@ export function redactAudit(audit: AuditResult, secrets: readonly string[]): Aud
|
|
|
156
156
|
results: audit.results.map((result) => ({
|
|
157
157
|
target: {
|
|
158
158
|
...result.target,
|
|
159
|
+
...(result.target.id === undefined ? {} : { id: redactText(result.target.id, plan) }),
|
|
159
160
|
url: redactText(result.target.url, plan),
|
|
160
161
|
expectations: {
|
|
161
162
|
...result.target.expectations,
|
package/src/types.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface DocumentSignals {
|
|
|
48
48
|
readonly descriptions: readonly ElementSignal[];
|
|
49
49
|
readonly canonicals: readonly ElementSignal[];
|
|
50
50
|
readonly robots: readonly RobotsSignal[];
|
|
51
|
-
/** Present on probes
|
|
51
|
+
/** Present on current probes; optional for low-level callers and older in-memory results. */
|
|
52
52
|
readonly socialMetadata?: readonly SocialMetadataSignal[];
|
|
53
53
|
readonly h1s: readonly ElementSignal[];
|
|
54
54
|
readonly firstMainText?: ElementSignal;
|
|
@@ -135,6 +135,8 @@ export interface TargetExpectations {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
export interface AuditTarget {
|
|
138
|
+
/** Stable identity used to match the same target across reports with different origins. */
|
|
139
|
+
readonly id?: string;
|
|
138
140
|
readonly url: string;
|
|
139
141
|
readonly expectations: TargetExpectations;
|
|
140
142
|
}
|
|
@@ -211,6 +213,8 @@ export interface AuditSummary {
|
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
export interface AuditResult {
|
|
216
|
+
/** Version of the persisted audit-report contract, independent of the package version. */
|
|
217
|
+
readonly schemaVersion: 1;
|
|
214
218
|
readonly version: string;
|
|
215
219
|
readonly generatedAt: string;
|
|
216
220
|
readonly durationMs: number;
|
|
@@ -220,3 +224,95 @@ export interface AuditResult {
|
|
|
220
224
|
}
|
|
221
225
|
|
|
222
226
|
export type ReportFormat = "terminal" | "json" | "sarif";
|
|
227
|
+
|
|
228
|
+
export type ComparisonKind = "regression" | "fixed" | "changed";
|
|
229
|
+
|
|
230
|
+
export type ComparisonScope = "target" | "agent" | "finding" | "response" | "metadata" | "timing";
|
|
231
|
+
|
|
232
|
+
export interface ComparisonChange {
|
|
233
|
+
readonly kind: ComparisonKind;
|
|
234
|
+
readonly scope: ComparisonScope;
|
|
235
|
+
readonly code: string;
|
|
236
|
+
readonly message: string;
|
|
237
|
+
readonly agent?: string;
|
|
238
|
+
readonly field?: string;
|
|
239
|
+
readonly baseline?: string | number | boolean;
|
|
240
|
+
readonly candidate?: string | number | boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface ComparisonTimelineEvent {
|
|
244
|
+
readonly key: string;
|
|
245
|
+
readonly label: string;
|
|
246
|
+
readonly medianMs: number;
|
|
247
|
+
readonly location?: ElementLocation | "mixed";
|
|
248
|
+
readonly observedByByte?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ComparisonTimelineSnapshot {
|
|
252
|
+
readonly samples: number;
|
|
253
|
+
readonly events: readonly ComparisonTimelineEvent[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface ComparisonTimelineLane {
|
|
257
|
+
readonly agent: string;
|
|
258
|
+
readonly label: string;
|
|
259
|
+
readonly baseline?: ComparisonTimelineSnapshot;
|
|
260
|
+
readonly candidate?: ComparisonTimelineSnapshot;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type TargetComparisonStatus = "matched" | "added" | "removed";
|
|
264
|
+
|
|
265
|
+
export interface TargetComparison {
|
|
266
|
+
readonly key: string;
|
|
267
|
+
readonly id?: string;
|
|
268
|
+
readonly status: TargetComparisonStatus;
|
|
269
|
+
readonly baselineUrl?: string;
|
|
270
|
+
readonly candidateUrl?: string;
|
|
271
|
+
readonly changes: readonly ComparisonChange[];
|
|
272
|
+
readonly timelines: readonly ComparisonTimelineLane[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface AuditReportDescriptor {
|
|
276
|
+
readonly label: string;
|
|
277
|
+
readonly version: string;
|
|
278
|
+
readonly schemaVersion: 1;
|
|
279
|
+
readonly generatedAt: string;
|
|
280
|
+
readonly repeat: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface ComparisonThresholds {
|
|
284
|
+
readonly timingRegressionMs: number;
|
|
285
|
+
readonly timingRegressionPercent: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface ComparisonSummary {
|
|
289
|
+
readonly targets: number;
|
|
290
|
+
readonly matchedTargets: number;
|
|
291
|
+
readonly addedTargets: number;
|
|
292
|
+
readonly removedTargets: number;
|
|
293
|
+
readonly unchangedTargets: number;
|
|
294
|
+
readonly regressions: number;
|
|
295
|
+
readonly fixed: number;
|
|
296
|
+
readonly changed: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface AuditComparison {
|
|
300
|
+
readonly schemaVersion: 1;
|
|
301
|
+
readonly kind: "comparison";
|
|
302
|
+
readonly version: string;
|
|
303
|
+
readonly generatedAt: string;
|
|
304
|
+
readonly baseline: AuditReportDescriptor;
|
|
305
|
+
readonly candidate: AuditReportDescriptor;
|
|
306
|
+
readonly thresholds: ComparisonThresholds;
|
|
307
|
+
readonly results: readonly TargetComparison[];
|
|
308
|
+
readonly summary: ComparisonSummary;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface CompareAuditOptions {
|
|
312
|
+
readonly baselineLabel?: string;
|
|
313
|
+
readonly candidateLabel?: string;
|
|
314
|
+
readonly timingRegressionMs?: number;
|
|
315
|
+
readonly timingRegressionPercent?: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type ComparisonReportFormat = "terminal" | "json" | "html";
|