audit0 0.1.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/dist/cli.d.ts +3 -0
- package/dist/cli.js +264 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/define.d.ts +2 -0
- package/dist/config/define.js +112 -0
- package/dist/config/errors.d.ts +6 -0
- package/dist/config/errors.js +10 -0
- package/dist/config/marker.d.ts +3 -0
- package/dist/config/marker.js +27 -0
- package/dist/config/schema.d.ts +3 -0
- package/dist/config/schema.js +66 -0
- package/dist/config/types.d.ts +107 -0
- package/dist/config/types.js +1 -0
- package/dist/engine/artifacts.d.mts +11 -0
- package/dist/engine/artifacts.mjs +92 -0
- package/dist/engine/audit-archive.d.mts +7 -0
- package/dist/engine/audit-archive.mjs +183 -0
- package/dist/engine/findings.d.mts +59 -0
- package/dist/engine/findings.mjs +111 -0
- package/dist/engine/inspect.d.mts +36 -0
- package/dist/engine/inspect.mjs +239 -0
- package/dist/engine/navigation-requests.d.mts +50 -0
- package/dist/engine/navigation-requests.mjs +272 -0
- package/dist/engine/navigation.d.mts +91 -0
- package/dist/engine/navigation.mjs +427 -0
- package/dist/engine/page-diagnostics.d.mts +18 -0
- package/dist/engine/page-diagnostics.mjs +997 -0
- package/dist/engine/report.d.mts +1 -0
- package/dist/engine/report.mjs +577 -0
- package/dist/engine/settings.d.mts +11 -0
- package/dist/engine/settings.mjs +8 -0
- package/dist/findings.d.ts +1 -0
- package/dist/findings.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/infra.d.ts +2 -0
- package/dist/infra.js +2 -0
- package/dist/run.d.ts +30 -0
- package/dist/run.js +319 -0
- package/package.json +66 -0
- package/schema/audit-v1.schema.json +82 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeReport(audit: any, outputDirectory: any): Promise<string>;
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { thresholds, gradeTiming, imageFinding, navigationMetricTypes, navigationFindings, pageFindings, } from "./findings.mjs";
|
|
4
|
+
const NAVIGATION_METRICS = [
|
|
5
|
+
["clickToFeedbackMs", "Click to first visible feedback"],
|
|
6
|
+
["clickToUrlMs", "Click to URL (context only)"],
|
|
7
|
+
["clickToShellMs", "Click to shell"],
|
|
8
|
+
["clickToUsefulMs", "Click to useful content"],
|
|
9
|
+
["clickToCompleteMs", "Click to complete (configured markers)"],
|
|
10
|
+
].map(([key, label]) => [key, label, navigationMetricTypes[key]]);
|
|
11
|
+
const UNSAFE_PATH = /[\u0000-\u001f\u007f\\:%?#]/;
|
|
12
|
+
const IMAGE_EXTENSION = /\.(?:png|jpe?g|webp|gif|avif)$/i;
|
|
13
|
+
const BLOCKING_EVIDENCE_GAP = /^(?:renderBlockingStatus unavailable|PerformanceObserver resource is unsupported|Browser (?:resources|elements|fonts|fontStyleSheets|fontCssRules) collection was truncated|CDP request collection capped|Browser snapshot unavailable|No browser observations available)/;
|
|
14
|
+
function reportTitle(origin) {
|
|
15
|
+
try {
|
|
16
|
+
return `Performance audit · ${new URL(origin).hostname}`;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "Performance audit";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function escapeHtml(text) {
|
|
23
|
+
return String(text ?? "Unavailable").replace(/[&<>"']/g, (character) => ({
|
|
24
|
+
"&": "&",
|
|
25
|
+
"<": "<",
|
|
26
|
+
">": ">",
|
|
27
|
+
'"': """,
|
|
28
|
+
"'": "'",
|
|
29
|
+
})[character]);
|
|
30
|
+
}
|
|
31
|
+
function localPath(path) {
|
|
32
|
+
if (typeof path !== "string" || !path || UNSAFE_PATH.test(path))
|
|
33
|
+
return null;
|
|
34
|
+
const segments = path.split("/");
|
|
35
|
+
if (segments.some((segment) => !segment || segment === "." || segment === ".."))
|
|
36
|
+
return null;
|
|
37
|
+
return `./${segments.map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
38
|
+
}
|
|
39
|
+
function imageLink(path, label, thumbnail = false) {
|
|
40
|
+
const href = localPath(path);
|
|
41
|
+
if (!href || !IMAGE_EXTENSION.test(path))
|
|
42
|
+
return '<span class="muted">No safe image link</span>';
|
|
43
|
+
return `<a href="${escapeHtml(href)}">${thumbnail
|
|
44
|
+
? `<img src="${escapeHtml(href)}" alt="${escapeHtml(label)}" loading="lazy" decoding="async">`
|
|
45
|
+
: escapeHtml(label)}</a>`;
|
|
46
|
+
}
|
|
47
|
+
function validTiming(timing) {
|
|
48
|
+
return typeof timing === "number" && Number.isFinite(timing) && timing >= 0;
|
|
49
|
+
}
|
|
50
|
+
function milliseconds(timing) {
|
|
51
|
+
return validTiming(timing) ? `${timing.toFixed(1)} ms` : "Unmeasured";
|
|
52
|
+
}
|
|
53
|
+
function gradeBadge(grade) {
|
|
54
|
+
const label = ["good", "ok", "bad"].includes(grade) ? grade : "unmeasured";
|
|
55
|
+
return `<span class="grade grade-${label}">${label}</span>`;
|
|
56
|
+
}
|
|
57
|
+
function timingCell(timing, metric) {
|
|
58
|
+
if (metric === null && validTiming(timing))
|
|
59
|
+
return `${milliseconds(timing)} <span class="muted">context only; not graded</span>`;
|
|
60
|
+
return `${milliseconds(timing)} ${gradeBadge(gradeTiming(timing, metric))}`;
|
|
61
|
+
}
|
|
62
|
+
function gradeCounts(grades) {
|
|
63
|
+
return `<small class="grade-counts">Samples: good: ${grades.filter((grade) => grade === "good").length}; ok: ${grades.filter((grade) => grade === "ok").length}; bad: ${grades.filter((grade) => grade === "bad").length}; unmeasured: ${grades.filter((grade) => grade === null).length}</small>`;
|
|
64
|
+
}
|
|
65
|
+
function distribution(timings, metric) {
|
|
66
|
+
const sorted = timings.filter(validTiming).sort((a, b) => a - b);
|
|
67
|
+
const count = sorted.length;
|
|
68
|
+
const counts = metric === null
|
|
69
|
+
? `<small>Context samples: measured: ${count}; unmeasured: ${timings.length - count}</small>`
|
|
70
|
+
: gradeCounts(timings.map((timing) => gradeTiming(timing, metric)));
|
|
71
|
+
if (!count)
|
|
72
|
+
return `<span class="muted">Unmeasured (n=0)</span> ${gradeBadge(null)}${counts}`;
|
|
73
|
+
const middle = Math.floor(count / 2);
|
|
74
|
+
const median = count % 2 ? sorted[middle] : sorted[middle - 1] / 2 + sorted[middle] / 2;
|
|
75
|
+
return `${timingCell(median, metric)} <span class="muted">(n=${count})</span>${count >= 10
|
|
76
|
+
? `<small>p90 ${timingCell(sorted[Math.ceil(count * 0.9) - 1], metric)}</small>`
|
|
77
|
+
: ""}${counts}`;
|
|
78
|
+
}
|
|
79
|
+
function table(headers, rows, empty = "No observations recorded.") {
|
|
80
|
+
if (!rows.length)
|
|
81
|
+
return `<p class="muted">${escapeHtml(empty)}</p>`;
|
|
82
|
+
return `<div class="table-scroll" tabindex="0" role="region" aria-label="${escapeHtml(headers.join(", "))}"><table><thead><tr>${headers.map((header) => `<th scope="col">${escapeHtml(header)}</th>`).join("")}</tr></thead><tbody>${rows.map((cells) => `<tr>${cells.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
|
|
83
|
+
}
|
|
84
|
+
function rawDetails(label, payload) {
|
|
85
|
+
return `<details><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details>`;
|
|
86
|
+
}
|
|
87
|
+
function notesList(notes) {
|
|
88
|
+
return notes?.length
|
|
89
|
+
? `<ul>${notes.map((note) => `<li>${escapeHtml(typeof note === "string" ? note : JSON.stringify(note))}</li>`).join("")}</ul>`
|
|
90
|
+
: "";
|
|
91
|
+
}
|
|
92
|
+
function statusCounts(records) {
|
|
93
|
+
const collected = records.filter((record) => record.status === "ok" && !record.error).length;
|
|
94
|
+
return `${records.length} attempts; ${collected} collected; ${records.length - collected} incomplete`;
|
|
95
|
+
}
|
|
96
|
+
function statusCell(record, kind) {
|
|
97
|
+
if (record.status === "ok" && !record.error)
|
|
98
|
+
return '<span class="muted">collected</span>';
|
|
99
|
+
const findings = kind === "page" ? { TTFB: pageFindings(record).ttfb } : navigationFindings(record);
|
|
100
|
+
const unmeasured = Object.entries(findings)
|
|
101
|
+
.filter(([, finding]) => !validTiming(finding.ms))
|
|
102
|
+
.map(([metric]) => metric);
|
|
103
|
+
return `<span class="muted">incomplete</span><p class="error">${gradeBadge("bad")} collection finding: ${escapeHtml(record.error || "No error message recorded.")}</p>
|
|
104
|
+
<p>Unmeasured metrics: ${unmeasured.length ? escapeHtml(unmeasured.join(", ")) : "none among the timing metrics"}. Missing measurements are not bad performance; valid partial timings retain their own grades.</p>`;
|
|
105
|
+
}
|
|
106
|
+
function groups(records, fields) {
|
|
107
|
+
const grouped = new Map();
|
|
108
|
+
for (const record of records) {
|
|
109
|
+
const key = JSON.stringify(fields.map((field) => record[field] ?? null));
|
|
110
|
+
if (!grouped.has(key))
|
|
111
|
+
grouped.set(key, []);
|
|
112
|
+
grouped.get(key).push(record);
|
|
113
|
+
}
|
|
114
|
+
return [...grouped.values()];
|
|
115
|
+
}
|
|
116
|
+
function pageSummary(pages) {
|
|
117
|
+
return table(["Page", "Mode", "Collection counts", "Request-response TTFB median / sample grades"], groups(pages, ["path", "mode"]).map((records) => [
|
|
118
|
+
escapeHtml(records[0].path),
|
|
119
|
+
escapeHtml(records[0].mode),
|
|
120
|
+
statusCounts(records),
|
|
121
|
+
distribution(records.map((record) => pageFindings(record).ttfb.ms), "ttfb"),
|
|
122
|
+
]));
|
|
123
|
+
}
|
|
124
|
+
function navigationSummary(navigations) {
|
|
125
|
+
return table([
|
|
126
|
+
"Route",
|
|
127
|
+
"Mode",
|
|
128
|
+
"Collection counts",
|
|
129
|
+
...NAVIGATION_METRICS.map(([, label, metric]) => `${label} median / ${metric === null ? "sample counts" : "sample grades"}`),
|
|
130
|
+
], groups(navigations, ["from", "to", "mode"]).map((records) => [
|
|
131
|
+
`${escapeHtml(records[0].from)} → ${escapeHtml(records[0].to)}`,
|
|
132
|
+
escapeHtml(records[0].mode),
|
|
133
|
+
statusCounts(records),
|
|
134
|
+
...NAVIGATION_METRICS.map(([key, , metric]) => distribution(records.map((record) => navigationFindings(record)[key].ms), metric)),
|
|
135
|
+
]));
|
|
136
|
+
}
|
|
137
|
+
function pageEvidenceTable(pages) {
|
|
138
|
+
return table([
|
|
139
|
+
"Page / attempt",
|
|
140
|
+
"Mode",
|
|
141
|
+
"Collection",
|
|
142
|
+
"TTFB / grade",
|
|
143
|
+
"TTFB source",
|
|
144
|
+
"Navigation to first byte",
|
|
145
|
+
"Response end",
|
|
146
|
+
"FCP",
|
|
147
|
+
"Preliminary LCP",
|
|
148
|
+
"Evidence",
|
|
149
|
+
], pages.map((page, index) => [
|
|
150
|
+
`${escapeHtml(page.path)}<small>${escapeHtml(page.id)} / attempt ${escapeHtml(page.attempt)}</small>`,
|
|
151
|
+
escapeHtml(page.mode),
|
|
152
|
+
statusCell(page, "page"),
|
|
153
|
+
timingCell(pageFindings(page).ttfb.ms, "ttfb"),
|
|
154
|
+
escapeHtml(page.diagnostics?.navigation?.ttfbSource),
|
|
155
|
+
milliseconds(page.diagnostics?.navigation?.navigationToFirstByteMs),
|
|
156
|
+
milliseconds(page.diagnostics?.navigation?.responseEndMs),
|
|
157
|
+
milliseconds(page.diagnostics?.paints?.fcpMs),
|
|
158
|
+
milliseconds(page.diagnostics?.paints?.lcp?.startTime),
|
|
159
|
+
`${imageLink(page.screenshot, "Screenshot")}<small><a href="#page-${index}">Diagnostics and raw sample</a></small>`,
|
|
160
|
+
]));
|
|
161
|
+
}
|
|
162
|
+
function navigationEvidenceTable(navigations, allNavigations) {
|
|
163
|
+
return table([
|
|
164
|
+
"Route / attempt",
|
|
165
|
+
"Mode / type",
|
|
166
|
+
"Collection",
|
|
167
|
+
...NAVIGATION_METRICS.map(([, label, metric]) => metric === null ? label : `${label} / grade`),
|
|
168
|
+
"Evidence",
|
|
169
|
+
], navigations.map((navigation) => [
|
|
170
|
+
`${escapeHtml(navigation.from)} → ${escapeHtml(navigation.to)}<small>${escapeHtml(navigation.id)} / attempt ${escapeHtml(navigation.attempt)}</small>`,
|
|
171
|
+
`${escapeHtml(navigation.mode)}<small>${escapeHtml(navigation.measurement?.type)}</small>`,
|
|
172
|
+
statusCell(navigation, "navigation"),
|
|
173
|
+
...NAVIGATION_METRICS.map(([key, , metric]) => timingCell(navigationFindings(navigation)[key].ms, metric)),
|
|
174
|
+
`${imageLink(navigation.screenshot, "Screenshot")}<small><a href="#navigation-${allNavigations.indexOf(navigation)}">Checkpoints, frames and raw sample</a></small>`,
|
|
175
|
+
]));
|
|
176
|
+
}
|
|
177
|
+
function imageDiagnostics(images) {
|
|
178
|
+
if (!images)
|
|
179
|
+
return `<p>${gradeBadge(null)} Image observations unmeasured.</p>`;
|
|
180
|
+
return (`<h4>Preload declarations</h4><p>Declarations are distinct from matched request evidence. High priority alone never proves a preload. An empty evidence list means not confirmed, not proof of absence.</p>
|
|
181
|
+
${Array.isArray(images.preloads) ? rawDetails("Recorded image preload declarations (not proof of use)", images.preloads) : '<p class="muted">Preload declarations unmeasured in this report.</p>'}` +
|
|
182
|
+
["early", "final"]
|
|
183
|
+
.map((snapshot) => {
|
|
184
|
+
const candidates = [
|
|
185
|
+
...(images[snapshot] ?? []).map((image) => ({ ...image, kind: "img" })),
|
|
186
|
+
...(images.backgrounds?.[snapshot] ?? []).map((image) => ({
|
|
187
|
+
...image,
|
|
188
|
+
kind: "CSS background",
|
|
189
|
+
})),
|
|
190
|
+
].filter((image) => image.visible && image.intersectsViewport);
|
|
191
|
+
const initiallyHigh = candidates.filter((image) => ["High", "VeryHigh"].includes(image.initialPriority)).length;
|
|
192
|
+
const finallyHigh = candidates.filter((image) => ["High", "VeryHigh"].includes(image.finalPriority)).length;
|
|
193
|
+
return `<h4>${snapshot === "early" ? "Early" : "Final"} above-fold candidates</h4>
|
|
194
|
+
<p>Snapshot observed at ${milliseconds(images[`${snapshot}ObservedMs`])} after time origin. ${candidates.length} recorded candidates; initial CDP High/VeryHigh: ${initiallyHigh}; final CDP High/VeryHigh: ${finallyHigh}. Counts are per snapshot, not unique requests.</p>
|
|
195
|
+
${table([
|
|
196
|
+
"Image / source",
|
|
197
|
+
"Viewport geometry",
|
|
198
|
+
"Kind",
|
|
199
|
+
"LCP match",
|
|
200
|
+
"Fetch discovery (time origin)",
|
|
201
|
+
"Discovery after TTFB (signed)",
|
|
202
|
+
"Request rank",
|
|
203
|
+
"Preload evidence",
|
|
204
|
+
"Initiator",
|
|
205
|
+
"HTML fetchPriority hint",
|
|
206
|
+
"CDP initial",
|
|
207
|
+
"CDP final at cutoff",
|
|
208
|
+
"Priority status / request",
|
|
209
|
+
"Grade / reason",
|
|
210
|
+
], candidates.map((image) => [
|
|
211
|
+
`${escapeHtml(image.alt || image.sourceUrl || image.url)}<small>${escapeHtml(image.url)}</small><small>${escapeHtml(image.networkId)}</small>`,
|
|
212
|
+
[image.width, image.height, image.x, image.y].every(Number.isFinite)
|
|
213
|
+
? escapeHtml(`${Math.round(image.width)}x${Math.round(image.height)} at (${Math.round(image.x)}, ${Math.round(image.y)})`)
|
|
214
|
+
: "Unmeasured geometry",
|
|
215
|
+
escapeHtml(image.kind),
|
|
216
|
+
image.isLcpCandidate === true
|
|
217
|
+
? "Matched candidate LCP (preliminary)"
|
|
218
|
+
: image.isLcpCandidate === false
|
|
219
|
+
? "Above-fold non-LCP candidate"
|
|
220
|
+
: "LCP match unmeasured",
|
|
221
|
+
Number.isFinite(image.fetchStartMs)
|
|
222
|
+
? `${image.fetchStartMs.toFixed(1)} ms`
|
|
223
|
+
: "Unmeasured",
|
|
224
|
+
Number.isFinite(image.fetchStartAfterTtfbMs)
|
|
225
|
+
? `${image.fetchStartAfterTtfbMs > 0 ? "+" : ""}${image.fetchStartAfterTtfbMs.toFixed(1)} ms`
|
|
226
|
+
: "Unmeasured",
|
|
227
|
+
Number.isFinite(image.requestOrder) ? escapeHtml(image.requestOrder) : "Unmeasured",
|
|
228
|
+
Array.isArray(image.preloadEvidence)
|
|
229
|
+
? image.preloadEvidence.length
|
|
230
|
+
? `Observed: ${escapeHtml(image.preloadEvidence.join(", "))}`
|
|
231
|
+
: "Not confirmed (no recorded evidence)"
|
|
232
|
+
: "Unmeasured (legacy or missing evidence)",
|
|
233
|
+
escapeHtml(image.initiatorType ?? "Unmeasured"),
|
|
234
|
+
escapeHtml(image.fetchPriority),
|
|
235
|
+
escapeHtml(image.initialPriority),
|
|
236
|
+
escapeHtml(image.finalPriority),
|
|
237
|
+
`${escapeHtml(image.finalPriorityStatus)} / ${escapeHtml(image.requestStatus)}`,
|
|
238
|
+
`${gradeBadge(imageFinding(image).grade)} ${escapeHtml(imageFinding(image).reason)}`,
|
|
239
|
+
]), "No visible, viewport-intersecting candidates recorded in this snapshot.")}`;
|
|
240
|
+
})
|
|
241
|
+
.join(""));
|
|
242
|
+
}
|
|
243
|
+
function blockingFinding(diagnostics, kind, incomplete) {
|
|
244
|
+
if (!diagnostics ||
|
|
245
|
+
(kind === "fonts" ? !diagnostics.fonts : !Array.isArray(diagnostics.renderBlockingScripts))) {
|
|
246
|
+
return {
|
|
247
|
+
grade: null,
|
|
248
|
+
reason: `${kind === "fonts" ? "Font" : "Script"} blocking observations unmeasured.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const fonts = diagnostics.fonts;
|
|
252
|
+
const observed = kind === "fonts"
|
|
253
|
+
? fonts.resources?.some((font) => font.performanceTimings?.some((timing) => timing.renderBlockingStatus === "blocking"))
|
|
254
|
+
: diagnostics.renderBlockingScripts.some((script) => script.renderBlockingStatus === "blocking");
|
|
255
|
+
if (observed)
|
|
256
|
+
return {
|
|
257
|
+
grade: "bad",
|
|
258
|
+
reason: "Browser-reported render-blocking resources observed; this does not quantify visible delay.",
|
|
259
|
+
};
|
|
260
|
+
const partial = incomplete ||
|
|
261
|
+
(diagnostics.measurementNotes ?? []).some((note) => BLOCKING_EVIDENCE_GAP.test(String(note).slice(0, 2000)));
|
|
262
|
+
const risk = kind === "fonts"
|
|
263
|
+
? !Array.isArray(fonts.faces) ||
|
|
264
|
+
!Array.isArray(fonts.resources) ||
|
|
265
|
+
fonts.faces.some((face) => face.sourceType !== "local-only" &&
|
|
266
|
+
(["auto", "block"].includes(face.display) ||
|
|
267
|
+
!["network", "mixed"].includes(face.sourceType) ||
|
|
268
|
+
!face.display)) ||
|
|
269
|
+
fonts.resources.some((font) => !font.performanceTimings?.length ||
|
|
270
|
+
font.performanceTimings.some((timing) => !["blocking", "non-blocking"].includes(timing.renderBlockingStatus)))
|
|
271
|
+
: !Array.isArray(diagnostics.resources) ||
|
|
272
|
+
!Array.isArray(diagnostics.explicitInlineRenderBlockers) ||
|
|
273
|
+
diagnostics.explicitInlineRenderBlockers.length > 0 ||
|
|
274
|
+
diagnostics.renderBlockingScripts.length > 0 ||
|
|
275
|
+
diagnostics.resources.some((resource) => !["blocking", "non-blocking"].includes(resource.renderBlockingStatus));
|
|
276
|
+
return partial || risk
|
|
277
|
+
? { grade: "ok", reason: "Risk or partial/unsupported evidence; blocking is not proven." }
|
|
278
|
+
: {
|
|
279
|
+
grade: "good",
|
|
280
|
+
reason: "No evidenced blockers in the recorded observations; not proof that blocking is impossible.",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function fontDiagnostics(diagnostics, incomplete) {
|
|
284
|
+
const fonts = diagnostics?.fonts;
|
|
285
|
+
const finding = blockingFinding(diagnostics, "fonts", incomplete);
|
|
286
|
+
const summary = `<p>${gradeBadge(finding.grade)} ${escapeHtml(finding.reason)}</p>`;
|
|
287
|
+
if (!fonts)
|
|
288
|
+
return summary;
|
|
289
|
+
const proven = (fonts.resources ?? []).filter((font) => font.performanceTimings?.some((timing) => timing.renderBlockingStatus === "blocking"));
|
|
290
|
+
return `${summary}<p>Browser-reported blocking font resources: <strong>${Array.isArray(fonts.resources) ? proven.length : "Unmeasured"}</strong>. Font set at FCP: ${escapeHtml(fonts.statusAtFcp)} (sampled at ${milliseconds(fonts.atFcpObservedMs)}); at finish: ${escapeHtml(fonts.statusAtFinish)}.</p>
|
|
291
|
+
${table(["Font face", "font-display", "Status", "Potential invisible text risk", "CSS source type"], (fonts.faces ?? []).map((face) => [
|
|
292
|
+
escapeHtml(face.family),
|
|
293
|
+
escapeHtml(face.display),
|
|
294
|
+
escapeHtml(face.status),
|
|
295
|
+
face.sourceType === "local-only"
|
|
296
|
+
? "No font download: local-only source"
|
|
297
|
+
: ["auto", "block"].includes(face.display)
|
|
298
|
+
? "Potential risk only, not proof"
|
|
299
|
+
: "Not indicated by display mode",
|
|
300
|
+
escapeHtml(face.sourceType || "unknown"),
|
|
301
|
+
]))}
|
|
302
|
+
${table(["Font resource", "Status", "Resource Timing renderBlockingStatus", "Request-response TTFB"], (fonts.resources ?? []).map((font) => [
|
|
303
|
+
escapeHtml(font.url),
|
|
304
|
+
escapeHtml(font.status),
|
|
305
|
+
escapeHtml(font.performanceTimings?.length
|
|
306
|
+
? font.performanceTimings
|
|
307
|
+
.map((timing) => timing.renderBlockingStatus ?? "Unavailable")
|
|
308
|
+
.join(", ")
|
|
309
|
+
: null),
|
|
310
|
+
timingCell(font.ttfbMs, "ttfb"),
|
|
311
|
+
]))}`;
|
|
312
|
+
}
|
|
313
|
+
function pageDetails(page, index) {
|
|
314
|
+
const diagnostics = page.diagnostics;
|
|
315
|
+
const incomplete = page.status !== "ok" || Boolean(page.error);
|
|
316
|
+
const scripts = blockingFinding(diagnostics, "scripts", incomplete);
|
|
317
|
+
return `<details class="sample" id="page-${index}"><summary>${escapeHtml(page.path)} / ${escapeHtml(page.mode)} / attempt ${escapeHtml(page.attempt)}</summary>
|
|
318
|
+
${statusCell(page, "page")}
|
|
319
|
+
${imageLink(page.screenshot, `Page screenshot: ${page.path}`, true)}
|
|
320
|
+
${diagnostics
|
|
321
|
+
? `
|
|
322
|
+
<p>Observation cutoff: ${milliseconds(diagnostics.finishedMs)} after document time origin. FCP: ${milliseconds(diagnostics.paints?.fcpMs)}. Preliminary observation-window LCP: ${milliseconds(diagnostics.paints?.lcp?.startTime)}; not finalized.</p>
|
|
323
|
+
<h3>Images</h3>${imageDiagnostics(diagnostics.images)}
|
|
324
|
+
<h3>Fonts</h3>${fontDiagnostics(diagnostics, incomplete)}
|
|
325
|
+
<h3>Scripts</h3>
|
|
326
|
+
<p>${gradeBadge(scripts.grade)} ${escapeHtml(scripts.reason)}</p>
|
|
327
|
+
<p>Proven render-blocking script resources recorded: <strong>${diagnostics.renderBlockingScripts?.filter((script) => script.renderBlockingStatus === "blocking").length ?? "Unmeasured"}</strong>. Explicit inline blocking=render declarations: <strong>${diagnostics.explicitInlineRenderBlockers?.length ?? "Unmeasured"}</strong> snapshot observations, not unique or proven blockers.</p>
|
|
328
|
+
${table([
|
|
329
|
+
"Script URL from blocking-resource observations",
|
|
330
|
+
"Browser blocking status",
|
|
331
|
+
"TTFB",
|
|
332
|
+
"HTML hints",
|
|
333
|
+
], (diagnostics.renderBlockingScripts ?? []).map((script) => [
|
|
334
|
+
escapeHtml(script.url),
|
|
335
|
+
escapeHtml(script.renderBlockingStatus),
|
|
336
|
+
timingCell(script.ttfbMs, "ttfb"),
|
|
337
|
+
escapeHtml(JSON.stringify(script.hints)),
|
|
338
|
+
]), "No proven render-blocking scripts recorded.")}
|
|
339
|
+
${rawDetails("Explicit inline declarations (separate, not proof)", diagnostics.explicitInlineRenderBlockers)}
|
|
340
|
+
<h3>Collection notes</h3>${notesList(diagnostics.measurementNotes)}
|
|
341
|
+
`
|
|
342
|
+
: `<p class="muted">Page diagnostics unavailable. Images, fonts and scripts are unmeasured. See the recorded collection finding.</p>`}
|
|
343
|
+
${rawDetails("Final content state and document timings", { finalState: page.finalState, navigation: diagnostics?.navigation })}
|
|
344
|
+
<p>Complete resource and priority timelines are in <a href="./audit.json">audit.json</a>, under pages / ${escapeHtml(page.id)} / attempt ${escapeHtml(page.attempt)} / ${escapeHtml(page.mode)}.</p>
|
|
345
|
+
</details>`;
|
|
346
|
+
}
|
|
347
|
+
function checkpoints(measurement) {
|
|
348
|
+
const requested = [
|
|
349
|
+
...new Set([
|
|
350
|
+
100,
|
|
351
|
+
500,
|
|
352
|
+
1000,
|
|
353
|
+
...(measurement.checkpoints ?? []).map((checkpoint) => checkpoint.requestedMs),
|
|
354
|
+
]),
|
|
355
|
+
]
|
|
356
|
+
.filter(validTiming)
|
|
357
|
+
.sort((a, b) => a - b);
|
|
358
|
+
return table([
|
|
359
|
+
"Requested checkpoint",
|
|
360
|
+
"Last observed state",
|
|
361
|
+
"Observed timestamp",
|
|
362
|
+
"Observation bracket [last, next]",
|
|
363
|
+
"Marker counts",
|
|
364
|
+
], requested.map((requestedMs) => {
|
|
365
|
+
const checkpoint = measurement.checkpoints?.find((checkpoint) => checkpoint.requestedMs === requestedMs);
|
|
366
|
+
return [
|
|
367
|
+
milliseconds(requestedMs),
|
|
368
|
+
escapeHtml(checkpoint?.state ?? "not-observed"),
|
|
369
|
+
milliseconds(checkpoint?.lastObservedMs),
|
|
370
|
+
`[${milliseconds(checkpoint?.lastObservedMs)}, ${milliseconds(checkpoint?.nextObservedMs)}]`,
|
|
371
|
+
escapeHtml(checkpoint ? JSON.stringify(checkpoint.counts) : null),
|
|
372
|
+
];
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
function filmstrip(navigation) {
|
|
376
|
+
const frames = [...(navigation.measurement?.frames ?? [])].sort((a, b) => (Number.isFinite(a.atMs) ? a.atMs : Infinity) - (Number.isFinite(b.atMs) ? b.atMs : Infinity));
|
|
377
|
+
if (!frames.length)
|
|
378
|
+
return '<p class="muted">No filmstrip frames recorded.</p>';
|
|
379
|
+
return `<div class="filmstrip">${frames
|
|
380
|
+
.map((frame) => {
|
|
381
|
+
const at = Number.isFinite(frame.atMs) ? `${frame.atMs.toFixed(1)} ms` : "Unavailable";
|
|
382
|
+
const path = localPath(navigation.directory) && localPath(frame.file)
|
|
383
|
+
? `${navigation.directory}/${frame.file}`
|
|
384
|
+
: null;
|
|
385
|
+
return `<figure>${imageLink(path, `Frame at ${at} relative to click`, true)}<figcaption>${escapeHtml(at)} relative to click<small>${escapeHtml(frame.file)}</small></figcaption></figure>`;
|
|
386
|
+
})
|
|
387
|
+
.join("")}</div>`;
|
|
388
|
+
}
|
|
389
|
+
function routeMilestones(navigation) {
|
|
390
|
+
const milestones = (navigation.criteria?.milestones ?? []).map((milestone) => {
|
|
391
|
+
const atMs = navigation.measurement?.milestoneTimings?.[milestone.id];
|
|
392
|
+
return [
|
|
393
|
+
`<strong>${escapeHtml(milestone.label)}</strong><small>${escapeHtml(milestone.description)}</small>`,
|
|
394
|
+
validTiming(atMs) ? milliseconds(atMs) : "Not shown",
|
|
395
|
+
validTiming(atMs) ? escapeHtml(milestone.gradeMetric ?? "not graded") : "not graded",
|
|
396
|
+
];
|
|
397
|
+
});
|
|
398
|
+
return table(["Route milestone", "First observed", "Grading profile"], milestones, "No route-specific milestones were configured for this sample.");
|
|
399
|
+
}
|
|
400
|
+
function navigationDetails(navigation, index) {
|
|
401
|
+
const measurement = navigation.measurement;
|
|
402
|
+
return `<details class="sample" id="navigation-${index}"><summary>${escapeHtml(navigation.from)} → ${escapeHtml(navigation.to)} / ${escapeHtml(navigation.mode)} / attempt ${escapeHtml(navigation.attempt)}</summary>
|
|
403
|
+
${statusCell(navigation, "navigation")}
|
|
404
|
+
${imageLink(navigation.screenshot, `Navigation screenshot: ${navigation.to}`, true)}
|
|
405
|
+
${navigation.mode === "client-ready-first-visit" ? '<p class="notice">Conditional client-ready retry: this fresh-context sample ran only because the original first visit used a full document navigation. It is excluded from aggregate timing distributions.</p>' : ""}
|
|
406
|
+
${measurement
|
|
407
|
+
? `
|
|
408
|
+
${NAVIGATION_METRICS.map(([key, label, metric]) => `<p><strong>${label}:</strong> ${timingCell(measurement[key], metric)}</p>`).join("")}
|
|
409
|
+
<h3>Route milestones</h3>${routeMilestones(navigation)}
|
|
410
|
+
<h3>First visible feedback evidence</h3>
|
|
411
|
+
${measurement.firstFeedbackReasons?.length ? notesList(measurement.firstFeedbackReasons) : '<p class="muted">First feedback reasons unmeasured or not recorded. Missing feedback timings are not inferred from URL or shell timings.</p>'}
|
|
412
|
+
<p>Pre-click hover: ${milliseconds(measurement.preClickHoverMs)} (recorded duration). The target is scrolled into view, then hovered before the click. Hover is excluded from click-relative clocks and is not feedback; network capture starts before hover.</p>
|
|
413
|
+
<h3>Checkpoint observations</h3>
|
|
414
|
+
<p>The state is the last sample at or before the requested time, not an exact-time screenshot. The bracket exposes the next sample, gaps and missing observations. Times are relative to the click.</p>
|
|
415
|
+
${checkpoints(measurement)}
|
|
416
|
+
<h3>Chronological filmstrip</h3>${filmstrip(navigation)}
|
|
417
|
+
${measurement.notes?.length ? `<h3>Measurement notes</h3>${notesList(measurement.notes)}` : ""}
|
|
418
|
+
`
|
|
419
|
+
: '<p class="muted">Navigation measurement unavailable. See the recorded status and error.</p>'}
|
|
420
|
+
${rawDetails("Destination criteria", navigation.criteria)}
|
|
421
|
+
${rawDetails("Raw navigation sample and state timeline", navigation)}
|
|
422
|
+
</details>`;
|
|
423
|
+
}
|
|
424
|
+
const STYLES = `
|
|
425
|
+
:root { color-scheme: light; font-family: system-ui, -apple-system, sans-serif; color: #24313b; background: #f5f6f8; line-height: 1.6; }
|
|
426
|
+
* { box-sizing: border-box; }
|
|
427
|
+
body { margin: 0; }
|
|
428
|
+
main { max-width: 1440px; margin: auto; padding: 40px 28px 72px; }
|
|
429
|
+
header { border-top: 4px solid #28576b; padding: 24px 0; }
|
|
430
|
+
h1, h2, h3, h4 { line-height: 1.25; color: #172d3a; }
|
|
431
|
+
h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin: 8px 0 16px; }
|
|
432
|
+
h2 { margin-top: 0; font-size: 1.45rem; }
|
|
433
|
+
h3 { margin-top: 28px; }
|
|
434
|
+
h4 { margin-bottom: 8px; }
|
|
435
|
+
p { max-width: 110ch; }
|
|
436
|
+
section { margin: 24px 0; padding: 24px; background: white; border: 1px solid #dce2e7; border-radius: 8px; }
|
|
437
|
+
a { color: #185e80; text-underline-offset: 3px; overflow-wrap: anywhere; }
|
|
438
|
+
a:hover { color: #103b50; }
|
|
439
|
+
a:focus-visible, summary:focus-visible, .table-scroll:focus-visible { outline: 3px solid #b26b0a; outline-offset: 3px; }
|
|
440
|
+
.eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: .8rem; }
|
|
441
|
+
.muted, small { color: #57636e; }
|
|
442
|
+
small { display: block; font-size: .82rem; }
|
|
443
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
444
|
+
.card { padding: 16px; background: #edf3f6; border-radius: 6px; }
|
|
445
|
+
.card strong { display: block; font-size: 1.1rem; }
|
|
446
|
+
.notice { border-left: 3px solid #ad752a; background: #fcf7ed; padding: 12px 16px; }
|
|
447
|
+
.table-scroll { overflow-x: auto; margin: 16px 0; }
|
|
448
|
+
table { border-collapse: collapse; width: 100%; font-size: .88rem; font-variant-numeric: tabular-nums; }
|
|
449
|
+
th, td { padding: 12px; text-align: left; vertical-align: top; border-bottom: 1px solid #e1e6ea; min-width: 120px; overflow-wrap: anywhere; }
|
|
450
|
+
th { background: #edf2f5; font-weight: 600; }
|
|
451
|
+
td { max-width: 420px; }
|
|
452
|
+
.success { color: #226442; }
|
|
453
|
+
.failure, .error { color: #9a3030; }
|
|
454
|
+
.grade { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: .8rem; font-weight: 700; }
|
|
455
|
+
.grade-good { color: #185536; background: #e0f2e7; }
|
|
456
|
+
.grade-ok { color: #76500d; background: #fff0c9; }
|
|
457
|
+
.grade-bad { color: #8a2424; background: #fbe5e5; }
|
|
458
|
+
.grade-unmeasured { color: #495660; background: #e9edf0; }
|
|
459
|
+
.error { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
460
|
+
details { margin: 12px 0; }
|
|
461
|
+
details.sample { border-top: 1px solid #dce2e7; padding: 16px 0; scroll-margin-top: 16px; }
|
|
462
|
+
summary { cursor: pointer; font-weight: 600; overflow-wrap: anywhere; }
|
|
463
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 600px; overflow: auto; background: #f2f4f6; padding: 16px; font-size: .8rem; }
|
|
464
|
+
img { display: block; max-width: 100%; width: 280px; height: auto; border: 1px solid #dce2e7; border-radius: 4px; margin-top: 12px; }
|
|
465
|
+
.filmstrip { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 16px; }
|
|
466
|
+
figure { flex: 0 0 240px; margin: 0; }
|
|
467
|
+
figcaption { font-size: .85rem; margin-top: 8px; overflow-wrap: anywhere; }
|
|
468
|
+
li { margin: 8px 0; }
|
|
469
|
+
@media (max-width: 640px) { main { padding: 16px 12px 40px; } section { padding: 16px 12px; } th, td { padding: 10px; } }
|
|
470
|
+
@media print { body { background: white; } main { padding: 0; } section, .card { break-inside: avoid; } .table-scroll, .filmstrip { overflow: visible; } }
|
|
471
|
+
`;
|
|
472
|
+
export async function writeReport(audit, outputDirectory) {
|
|
473
|
+
if (!audit ||
|
|
474
|
+
audit.schemaVersion !== 1 ||
|
|
475
|
+
!Array.isArray(audit.pages) ||
|
|
476
|
+
!Array.isArray(audit.navigations)) {
|
|
477
|
+
throw new TypeError("Expected a schemaVersion: 1 audit with pages and navigations arrays");
|
|
478
|
+
}
|
|
479
|
+
const { pages, navigations } = audit;
|
|
480
|
+
const timingPages = pages;
|
|
481
|
+
const timingNavigations = navigations.filter((navigation) => ["first-visit", "revisit"].includes(navigation.mode));
|
|
482
|
+
const clientReadyNavigations = navigations.filter((navigation) => navigation.mode === "client-ready-first-visit");
|
|
483
|
+
const otherNavigations = navigations.filter((navigation) => !["first-visit", "revisit", "client-ready-first-visit"].includes(navigation.mode));
|
|
484
|
+
const html = `<!doctype html>
|
|
485
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
486
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
487
|
+
<title>${escapeHtml(reportTitle(audit.environment?.origin))}</title><style>${STYLES}</style></head><body><main>
|
|
488
|
+
<header><div class="eyebrow">Local browser observations</div><h1>Performance audit</h1>
|
|
489
|
+
<p>${escapeHtml(audit.environment?.origin)}<br><span class="muted">${escapeHtml(audit.startedAt)} to ${escapeHtml(audit.finishedAt)}</span></p>
|
|
490
|
+
<p>This report describes this audit's environment and observation windows only. It does not establish production-wide performance or claim that performance has been fixed.</p>
|
|
491
|
+
</header>
|
|
492
|
+
<section aria-labelledby="summary"><h2 id="summary">Summary</h2><div class="cards">
|
|
493
|
+
<div class="card"><strong>Page TTFB median</strong>${distribution(timingPages.map((page) => pageFindings(page).ttfb.ms), "ttfb")}</div>
|
|
494
|
+
${NAVIGATION_METRICS.map(([key, label, metric]) => `<div class="card"><strong>${label} median</strong>${distribution(timingNavigations.map((navigation) => navigationFindings(navigation)[key].ms), metric)}</div>`).join("")}
|
|
495
|
+
<div class="card"><strong>Final above-fold image grades</strong>${gradeCounts(timingPages.flatMap((page) => pageFindings(page).images.map((image) => image.grade)))}<small>${timingPages.filter((page) => !Array.isArray(page.diagnostics?.images?.final)).length} page attempts with unmeasured final image snapshots. Per-snapshot candidates, not unique requests. Missing image observations are not counted as good.</small></div>
|
|
496
|
+
</div><p>These overview medians combine timing modes and routes. Use the grouped medians and individual sample grades below to locate outliers; a good median does not erase bad samples.</p>
|
|
497
|
+
<h3>Collection coverage</h3><div class="cards">
|
|
498
|
+
<div class="card"><strong>Page diagnostics</strong>${statusCounts(pages)}</div>
|
|
499
|
+
<div class="card"><strong>Navigation timing runs</strong>${statusCounts(timingNavigations)}</div>
|
|
500
|
+
<div class="card"><strong>Conditional client-ready retries</strong>${statusCounts(clientReadyNavigations)}</div>
|
|
501
|
+
${otherNavigations.length ? `<div class="card"><strong>Unrecognized modes, excluded</strong>${statusCounts(otherNavigations)}</div>` : ""}
|
|
502
|
+
</div>
|
|
503
|
+
<h3>Environment and recorded settings (not grading thresholds)</h3>
|
|
504
|
+
${table(["Setting", "Recorded value"], Object.entries({ ...audit.environment, ...audit.settings }).map(([key, setting]) => [
|
|
505
|
+
escapeHtml(key),
|
|
506
|
+
escapeHtml(typeof setting === "object" ? JSON.stringify(setting) : setting),
|
|
507
|
+
]))}
|
|
508
|
+
<h3>Current findings</h3><p>Grades above and below are recomputed from raw measurements using the current thresholds, not persisted grading prose.</p>
|
|
509
|
+
${audit.observations?.length ? `<details><summary>Legacy observations (historical text, not authoritative grading)</summary><p>These saved observations may use obsolete thresholds. Use the recomputed grades and threshold table instead.</p>${notesList(audit.observations)}</details>` : ""}
|
|
510
|
+
<p>Page response time and navigation usefulness are different measurements. Response end is relative to navigation start and exposes streamed-document completion after early headers. A fast TTFB alone does not imply a usable page.</p>
|
|
511
|
+
</section>
|
|
512
|
+
<section aria-labelledby="method"><h2 id="method">Reading the measurements</h2>
|
|
513
|
+
${table(["Metric", "good", "ok", "bad", "Boundary interpretation"], [
|
|
514
|
+
[
|
|
515
|
+
"Request-response TTFB",
|
|
516
|
+
`< ${thresholds.ttfb.goodBelowMs} ms`,
|
|
517
|
+
`${thresholds.ttfb.goodBelowMs} to < ${thresholds.ttfb.okBelowMs} ms`,
|
|
518
|
+
`≥ ${thresholds.ttfb.okBelowMs} ms`,
|
|
519
|
+
`Exactly ${thresholds.ttfb.goodBelowMs} ms is ok; exactly ${thresholds.ttfb.okBelowMs} ms is bad.`,
|
|
520
|
+
],
|
|
521
|
+
[
|
|
522
|
+
"Immediate visible feedback / shell",
|
|
523
|
+
`< ${thresholds.feedback.goodBelowMs} ms`,
|
|
524
|
+
`${thresholds.feedback.goodBelowMs} to < ${thresholds.feedback.okBelowMs} ms`,
|
|
525
|
+
`≥ ${thresholds.feedback.okBelowMs} ms`,
|
|
526
|
+
`Exactly ${thresholds.feedback.goodBelowMs} ms is ok; exactly ${thresholds.feedback.okBelowMs} ms is bad.`,
|
|
527
|
+
],
|
|
528
|
+
[
|
|
529
|
+
"Useful or complete configured content",
|
|
530
|
+
`< ${thresholds.content.goodBelowMs} ms`,
|
|
531
|
+
`${thresholds.content.goodBelowMs} to ${thresholds.content.badAboveMs} ms`,
|
|
532
|
+
`> ${thresholds.content.badAboveMs} ms`,
|
|
533
|
+
`Exactly ${thresholds.content.badAboveMs} ms is ok; values above it are bad.`,
|
|
534
|
+
],
|
|
535
|
+
[
|
|
536
|
+
"Above-fold image heuristic",
|
|
537
|
+
"Early fetch with initial CDP High/VeryHigh, not pending",
|
|
538
|
+
"Early but pending or high initial priority not observed",
|
|
539
|
+
"Late fetch or observed request/decode error",
|
|
540
|
+
`Early means signed fetch discovery minus document first byte ≤ ${thresholds.images.earlyAfterTtfbMs} ms (inclusive, including negative delays). Missing delay is unmeasured unless a request/decode error was observed. Preload evidence is separate; final priority upgrades do not change initial priority.`,
|
|
541
|
+
],
|
|
542
|
+
])}
|
|
543
|
+
<ul>
|
|
544
|
+
<li>Medians include every finite, non-negative measurement, including partial measurements from incomplete collections. Missing or invalid timings are unmeasured, never zero. Each graded metric has its own valid sample count and per-sample good/ok/bad/unmeasured counts so medians cannot hide outliers. Context-only timings have measured/unmeasured counts, not performance grades.</li>
|
|
545
|
+
<li>Grades are findings, never audit pass/fail gates. Timeouts and collection errors are bad collection findings, not fabricated bad performance. They do not stop the runner from attempting the remaining cases. Local tests still enforce harness correctness, including invalid report input.</li>
|
|
546
|
+
<li>Collection labels are collected or incomplete, independent of performance grades. Status values are collection metadata, not performance verdicts.</li>
|
|
547
|
+
<li>90th percentiles appear only with at least 10 valid samples per metric, using nearest rank. Small runs do not support tail-performance or production-wide conclusions.</li>
|
|
548
|
+
<li>Page groups separate fresh-context and warm-reload modes. A first visit and its revisit share one browser context: the revisit uses browser Back to return to the source before clicking the destination again. A conditional client-ready first visit uses a separate fresh context only when the original first visit was full-document; conditional retries never enter timing distributions.</li>
|
|
549
|
+
<li>Request-response TTFB is diagnostics.navigation.ttfbMs, using the recorded CDP source (normally receiveHeadersStart minus sendEnd). It includes RTT, cache and upstream work, not pure server compute. Browser request-to-first-byte and navigation-to-first-byte are distinct raw measurements; unavailable CDP timings are not replaced with them.</li>
|
|
550
|
+
<li>Navigation milestones are sampled DOM states relative to the click, not exact pixel presentation. Useful and complete refer to the configured viewport marker criteria, not proof all interactions are ready. Source dwell, observation length, prefetching and prior visits affect results. FCP, LCP, response end and other contextual timings have no grade threshold in this audit.</li>
|
|
551
|
+
<li>First visible feedback is the observed new destination fallback, shell/content, or movement of an explicitly configured navigation indicator. Click to URL is context only, not evidence of visible feedback and never a pass grade. Legacy artifacts without clickToFeedbackMs remain unmeasured; feedback is not inferred from URL or shell timings. Useful and complete content are graded separately from immediate feedback.</li>
|
|
552
|
+
<li>The target is scrolled into view, then hovered for the configured pre-click duration (default 150 ms). Hover is not feedback and is excluded from click-relative clocks. Network capture starts before hover, so hover-triggered prefetch can be observed; earlier prefetch may be absent.</li>
|
|
553
|
+
<li>LCP is preliminary, observation-window only, not finalized. Long tasks and all downloaded JavaScript must not be counted as render-blocking scripts: only diagnostics.renderBlockingScripts is counted. Explicit inline blockers are reported separately as declarations.</li>
|
|
554
|
+
<li>Image fetchPriority is an HTML hint; initial and final priorities are actual CDP observations, with final meaning the collection cutoff. Above-fold candidates reflect sampled visibility and geometry, not proof of importance or lack of occlusion. Prioritize selectively after inspecting evidence, not every logo or above-fold image.</li>
|
|
555
|
+
<li>Image request rank is the 1-based chronological order of observed main-frame subresource requests, not a priority rank. Signed image delay is relative to document first byte, not the request-response TTFB duration. A matched candidate LCP is a preliminary URL match, not finalized LCP or proof of element identity.</li>
|
|
556
|
+
<li>Font-display auto/block signals potential invisible text risk, not proven blocking. Swap is not a render blocker by virtue of its display mode. Browser-reported blocking font resources require performanceTimings.renderBlockingStatus = blocking; request duration and font display alone do not prove text was blocked.</li>
|
|
557
|
+
</ul>
|
|
558
|
+
</section>
|
|
559
|
+
<section aria-labelledby="pages"><h2 id="pages">Page TTFB by mode</h2>${pageSummary(timingPages)}</section>
|
|
560
|
+
<section aria-labelledby="navigation"><h2 id="navigation">Navigation timings by mode</h2>${navigationSummary(timingNavigations)}</section>
|
|
561
|
+
<section aria-labelledby="page-evidence"><h2 id="page-evidence">Page samples and evidence</h2>${pageEvidenceTable(pages)}${pages.map(pageDetails).join("")}</section>
|
|
562
|
+
<section aria-labelledby="timing-evidence"><h2 id="timing-evidence">Navigation timing samples</h2>${navigationEvidenceTable(timingNavigations, navigations)}</section>
|
|
563
|
+
<section aria-labelledby="conditional-retries"><h2 id="conditional-retries">Conditional client-ready retries</h2><p class="notice">These fresh-context retries run only after a full-document first visit and are shown for diagnosis, not combined with timing-run distributions.</p>${navigationEvidenceTable(clientReadyNavigations, navigations)}</section>
|
|
564
|
+
${otherNavigations.length ? `<section><h2>Unrecognized navigation modes</h2>${navigationEvidenceTable(otherNavigations, navigations)}</section>` : ""}
|
|
565
|
+
<section aria-labelledby="navigation-details"><h2 id="navigation-details">Navigation checkpoints and details</h2>${navigations.length ? navigations.map(navigationDetails).join("") : '<p class="muted">No navigation observations recorded.</p>'}</section>
|
|
566
|
+
<section aria-labelledby="privacy"><h2 id="privacy">Privacy and scope</h2>
|
|
567
|
+
<p>Keep these artifacts local and review before sharing. Anonymous browsing can still capture public chat, usernames and other public content in screenshots. URLs and paths may contain identifiers, and error messages or supplied raw data may contain sensitive text.</p>
|
|
568
|
+
<p>This report writer does not collect HARs, cookies, authorization headers or secrets. It renders the supplied audit, including any allowlisted cache headers already in diagnostics; HTML escaping is not secret redaction. Evidence links are restricted to local relative image paths. No scripts, external resources or web fonts are used.</p>
|
|
569
|
+
</section>
|
|
570
|
+
</main></body></html>
|
|
571
|
+
`;
|
|
572
|
+
const directory = resolve(outputDirectory);
|
|
573
|
+
await mkdir(directory, { recursive: true });
|
|
574
|
+
const reportPath = resolve(directory, "report.html");
|
|
575
|
+
await writeFile(reportPath, html, "utf8");
|
|
576
|
+
return reportPath;
|
|
577
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { gradeTiming, imageFinding, navigationFindings, navigationMetricTypes, pageFindings, thresholds, } from "./engine/findings.mjs";
|
package/dist/findings.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { gradeTiming, imageFinding, navigationFindings, navigationMetricTypes, pageFindings, thresholds, } from "./engine/findings.mjs";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { defineAudit } from "./config/define.ts";
|
|
2
|
+
export { defaultSettings } from "./config/defaults.ts";
|
|
3
|
+
export { AuditConfigError } from "./config/errors.ts";
|
|
4
|
+
export { marker } from "./config/marker.ts";
|
|
5
|
+
export { runAudit } from "./run.ts";
|
|
6
|
+
export type { AuditDocument, AuditResult, RunOptions } from "./run.ts";
|
|
7
|
+
export type { AuditConfigErrorCode } from "./config/errors.ts";
|
|
8
|
+
export type { MarkerInput, MarkerOptions, MarkerPattern, MarkerRule } from "./config/types.ts";
|
|
9
|
+
export type { AuditConfig, AuditConfigInput, AuditSettings, AuditSettingsInput, JourneyInput, JourneySpec, MilestoneInput, MilestoneSpec, PageInput, PageSpec, Viewport, } from "./config/types.ts";
|
package/dist/index.js
ADDED
package/dist/infra.d.ts
ADDED