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
|
@@ -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
|
@@ -23,11 +23,17 @@ const requireSchema = z
|
|
|
23
23
|
canonical: z.boolean().optional(),
|
|
24
24
|
h1: z.boolean().optional(),
|
|
25
25
|
mainText: z.boolean().optional(),
|
|
26
|
+
openGraph: z.boolean().optional(),
|
|
27
|
+
twitterCard: z.boolean().optional(),
|
|
26
28
|
})
|
|
27
29
|
.strict();
|
|
28
30
|
|
|
29
31
|
const targetObjectSchema = z
|
|
30
32
|
.object({
|
|
33
|
+
id: z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i)
|
|
36
|
+
.optional(),
|
|
31
37
|
url: z.string().min(1),
|
|
32
38
|
expectedStatus: z
|
|
33
39
|
.union([
|
|
@@ -129,11 +135,14 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
|
|
|
129
135
|
requireCanonical: required?.canonical ?? true,
|
|
130
136
|
requireH1: required?.h1 ?? true,
|
|
131
137
|
requireMainText: required?.mainText ?? true,
|
|
138
|
+
requireOpenGraph: required?.openGraph ?? false,
|
|
139
|
+
requireTwitterCard: required?.twitterCard ?? false,
|
|
132
140
|
...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
|
|
133
141
|
...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
|
|
134
142
|
};
|
|
135
143
|
|
|
136
144
|
return {
|
|
145
|
+
...(item.id === undefined ? {} : { id: item.id }),
|
|
137
146
|
url: validateHttpUrl(item.url, "target URL"),
|
|
138
147
|
expectations,
|
|
139
148
|
};
|
|
@@ -255,13 +264,22 @@ async function readConfig(path: string | undefined): Promise<FileConfig> {
|
|
|
255
264
|
|
|
256
265
|
function uniqueTargets(targets: readonly AuditTarget[]): readonly AuditTarget[] {
|
|
257
266
|
const seen = new Set<string>();
|
|
258
|
-
|
|
267
|
+
const unique = targets.filter((target) => {
|
|
259
268
|
if (seen.has(target.url)) {
|
|
260
269
|
return false;
|
|
261
270
|
}
|
|
262
271
|
seen.add(target.url);
|
|
263
272
|
return true;
|
|
264
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;
|
|
265
283
|
}
|
|
266
284
|
|
|
267
285
|
export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWireConfig> {
|
package/src/http-probe.ts
CHANGED
|
@@ -55,6 +55,7 @@ function emptySignals(): DocumentSignals {
|
|
|
55
55
|
descriptions: [],
|
|
56
56
|
canonicals: [],
|
|
57
57
|
robots: [],
|
|
58
|
+
socialMetadata: [],
|
|
58
59
|
h1s: [],
|
|
59
60
|
jsonLd: [],
|
|
60
61
|
};
|
|
@@ -91,6 +92,9 @@ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): Docu
|
|
|
91
92
|
descriptions: signals.descriptions.map(element),
|
|
92
93
|
canonicals: signals.canonicals.map(element),
|
|
93
94
|
robots: signals.robots.map(element),
|
|
95
|
+
...(signals.socialMetadata === undefined
|
|
96
|
+
? {}
|
|
97
|
+
: { socialMetadata: signals.socialMetadata.map(element) }),
|
|
94
98
|
h1s: signals.h1s.map(element),
|
|
95
99
|
...(signals.firstMainText === undefined
|
|
96
100
|
? {}
|
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,
|
|
@@ -27,10 +52,14 @@ export type {
|
|
|
27
52
|
RobotsAudience,
|
|
28
53
|
RobotsSignal,
|
|
29
54
|
Severity,
|
|
55
|
+
SocialMetadataProperty,
|
|
56
|
+
SocialMetadataSignal,
|
|
30
57
|
SsrWireConfig,
|
|
31
58
|
StabilityTimings,
|
|
32
59
|
StabilityVariants,
|
|
33
60
|
TargetAuditResult,
|
|
61
|
+
TargetComparison,
|
|
62
|
+
TargetComparisonStatus,
|
|
34
63
|
TargetExpectations,
|
|
35
64
|
TimingMark,
|
|
36
65
|
TimingStats,
|
package/src/redact.ts
CHANGED
|
@@ -90,6 +90,11 @@ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentS
|
|
|
90
90
|
descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
|
|
91
91
|
canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
|
|
92
92
|
robots: signals.robots.map((signal) => redactElement(signal, plan)),
|
|
93
|
+
...(signals.socialMetadata === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: {
|
|
96
|
+
socialMetadata: signals.socialMetadata.map((signal) => redactElement(signal, plan)),
|
|
97
|
+
}),
|
|
93
98
|
h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
|
|
94
99
|
...(signals.firstMainText === undefined
|
|
95
100
|
? {}
|
|
@@ -151,6 +156,7 @@ export function redactAudit(audit: AuditResult, secrets: readonly string[]): Aud
|
|
|
151
156
|
results: audit.results.map((result) => ({
|
|
152
157
|
target: {
|
|
153
158
|
...result.target,
|
|
159
|
+
...(result.target.id === undefined ? {} : { id: redactText(result.target.id, plan) }),
|
|
154
160
|
url: redactText(result.target.url, plan),
|
|
155
161
|
expectations: {
|
|
156
162
|
...result.target.expectations,
|
package/src/reporters.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
effectiveTwitterCardSignal,
|
|
3
|
+
firstSocialSignal,
|
|
4
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES,
|
|
5
|
+
TWITTER_CARD_REQUIRED_FIELDS,
|
|
6
|
+
} from "./social.js";
|
|
1
7
|
import type {
|
|
2
8
|
AgentStability,
|
|
3
9
|
AuditResult,
|
|
@@ -86,6 +92,31 @@ function probeRow(probe: ProbeResult, showSample: boolean): readonly string[] {
|
|
|
86
92
|
];
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
function formatSignalSet(signals: readonly (ElementSignal | undefined)[]): string {
|
|
96
|
+
const present = signals.filter((signal): signal is ElementSignal => signal !== undefined);
|
|
97
|
+
if (present.length < signals.length) return `${present.length}/${signals.length}`;
|
|
98
|
+
const arrivalMs = Math.max(...present.map((signal) => signal.atMs));
|
|
99
|
+
const location = present.some((signal) => signal.location === "body")
|
|
100
|
+
? "body"
|
|
101
|
+
: present.some((signal) => signal.location === "document")
|
|
102
|
+
? "document"
|
|
103
|
+
: "head";
|
|
104
|
+
return `${Math.round(arrivalMs)} ms/${location}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function socialRow(probe: ProbeResult, showSample: boolean): readonly string[] {
|
|
108
|
+
return [
|
|
109
|
+
...(showSample ? [String(probe.sample ?? "—")] : []),
|
|
110
|
+
truncate(probe.agent.label, 24),
|
|
111
|
+
formatSignalSet(
|
|
112
|
+
OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property)),
|
|
113
|
+
),
|
|
114
|
+
formatSignalSet(
|
|
115
|
+
TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field)),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
function stabilityRows(stability: readonly AgentStability[]): readonly (readonly string[])[] {
|
|
90
121
|
const labels: Readonly<Record<keyof AgentStability["timings"], string>> = {
|
|
91
122
|
headers: "Headers",
|
|
@@ -171,6 +202,21 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
|
|
|
171
202
|
),
|
|
172
203
|
);
|
|
173
204
|
|
|
205
|
+
const showSocial =
|
|
206
|
+
result.target.expectations.requireOpenGraph === true ||
|
|
207
|
+
result.target.expectations.requireTwitterCard === true ||
|
|
208
|
+
result.probes.some((probe) => (probe.signals.socialMetadata?.length ?? 0) > 0);
|
|
209
|
+
if (showSocial) {
|
|
210
|
+
lines.push(
|
|
211
|
+
"",
|
|
212
|
+
"Social preview readiness",
|
|
213
|
+
renderTable(
|
|
214
|
+
[...(showSample ? ["Sample"] : []), "Agent", "Open Graph", "Twitter Card"],
|
|
215
|
+
result.probes.map((probe) => socialRow(probe, showSample)),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
if (result.stability !== undefined) {
|
|
175
221
|
const rows = stabilityRows(result.stability);
|
|
176
222
|
if (rows.length > 0) {
|
package/src/social.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { DocumentSignals, SocialMetadataProperty, SocialMetadataSignal } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const SOCIAL_METADATA_PROPERTIES = [
|
|
4
|
+
"og:title",
|
|
5
|
+
"og:type",
|
|
6
|
+
"og:url",
|
|
7
|
+
"og:image",
|
|
8
|
+
"og:description",
|
|
9
|
+
"twitter:card",
|
|
10
|
+
"twitter:title",
|
|
11
|
+
"twitter:description",
|
|
12
|
+
"twitter:image",
|
|
13
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
14
|
+
|
|
15
|
+
export const OPEN_GRAPH_PROPERTIES = [
|
|
16
|
+
"og:title",
|
|
17
|
+
"og:type",
|
|
18
|
+
"og:url",
|
|
19
|
+
"og:image",
|
|
20
|
+
"og:description",
|
|
21
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
22
|
+
|
|
23
|
+
export const OPEN_GRAPH_REQUIRED_PROPERTIES = [
|
|
24
|
+
"og:title",
|
|
25
|
+
"og:type",
|
|
26
|
+
"og:url",
|
|
27
|
+
"og:image",
|
|
28
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
29
|
+
|
|
30
|
+
export const TWITTER_PROPERTIES = [
|
|
31
|
+
"twitter:card",
|
|
32
|
+
"twitter:title",
|
|
33
|
+
"twitter:description",
|
|
34
|
+
"twitter:image",
|
|
35
|
+
] as const satisfies readonly SocialMetadataProperty[];
|
|
36
|
+
|
|
37
|
+
export const TWITTER_CARD_REQUIRED_FIELDS = ["card", "title", "description", "image"] as const;
|
|
38
|
+
export type TwitterCardField = (typeof TWITTER_CARD_REQUIRED_FIELDS)[number];
|
|
39
|
+
|
|
40
|
+
const SOCIAL_METADATA_PROPERTY_SET = new Set<string>(SOCIAL_METADATA_PROPERTIES);
|
|
41
|
+
const SOCIAL_URL_PROPERTY_SET = new Set<SocialMetadataProperty>([
|
|
42
|
+
"og:url",
|
|
43
|
+
"og:image",
|
|
44
|
+
"twitter:image",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function isSocialMetadataProperty(
|
|
48
|
+
value: string | undefined,
|
|
49
|
+
): value is SocialMetadataProperty {
|
|
50
|
+
return value !== undefined && SOCIAL_METADATA_PROPERTY_SET.has(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function socialSignals(
|
|
54
|
+
signals: DocumentSignals,
|
|
55
|
+
property: SocialMetadataProperty,
|
|
56
|
+
): readonly SocialMetadataSignal[] {
|
|
57
|
+
return (signals.socialMetadata ?? []).filter((signal) => signal.property === property);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function firstSocialSignal(
|
|
61
|
+
signals: DocumentSignals,
|
|
62
|
+
property: SocialMetadataProperty,
|
|
63
|
+
): SocialMetadataSignal | undefined {
|
|
64
|
+
return socialSignals(signals, property).find((signal) => signal.value.trim().length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function effectiveTwitterCardSignal(
|
|
68
|
+
signals: DocumentSignals,
|
|
69
|
+
field: TwitterCardField,
|
|
70
|
+
): SocialMetadataSignal | undefined {
|
|
71
|
+
if (field === "card") return firstSocialSignal(signals, "twitter:card");
|
|
72
|
+
if (field === "title") {
|
|
73
|
+
return firstSocialSignal(signals, "twitter:title") ?? firstSocialSignal(signals, "og:title");
|
|
74
|
+
}
|
|
75
|
+
if (field === "description") {
|
|
76
|
+
return (
|
|
77
|
+
firstSocialSignal(signals, "twitter:description") ??
|
|
78
|
+
firstSocialSignal(signals, "og:description")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return firstSocialSignal(signals, "twitter:image") ?? firstSocialSignal(signals, "og:image");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeSocialValue(
|
|
85
|
+
property: SocialMetadataProperty,
|
|
86
|
+
value: string,
|
|
87
|
+
baseUrl: string,
|
|
88
|
+
): string {
|
|
89
|
+
const normalized = value.trim().replace(/\s+/gu, " ");
|
|
90
|
+
if (normalized.length === 0) return "";
|
|
91
|
+
if (SOCIAL_URL_PROPERTY_SET.has(property)) {
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(normalized, baseUrl);
|
|
94
|
+
url.hash = "";
|
|
95
|
+
return url.href;
|
|
96
|
+
} catch {
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return property === "og:type" || property === "twitter:card"
|
|
101
|
+
? normalized.toLowerCase()
|
|
102
|
+
: normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isAbsoluteHttpSocialUrl(value: string): boolean {
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(value.trim());
|
|
108
|
+
return (
|
|
109
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
110
|
+
url.username.length === 0 &&
|
|
111
|
+
url.password.length === 0
|
|
112
|
+
);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|