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,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
const MEDIA_TYPES = {
|
|
5
|
+
".jpg": "image/jpeg",
|
|
6
|
+
".jpeg": "image/jpeg",
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
};
|
|
10
|
+
export function portableArtifactPath(path) {
|
|
11
|
+
if (typeof path !== "string" || !path || path.startsWith("/") || path.includes("\\"))
|
|
12
|
+
return null;
|
|
13
|
+
const segments = path.split("/");
|
|
14
|
+
if (segments.some((segment) => !segment || segment === "." || segment === ".." || /[\u0000-\u001f\u007f]/.test(segment)))
|
|
15
|
+
return null;
|
|
16
|
+
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
17
|
+
return MEDIA_TYPES[extension] ? { path, mediaType: MEDIA_TYPES[extension] } : null;
|
|
18
|
+
}
|
|
19
|
+
export function referencedArtifacts(audit) {
|
|
20
|
+
const references = new Map();
|
|
21
|
+
const add = (path, reference) => {
|
|
22
|
+
const safe = portableArtifactPath(path);
|
|
23
|
+
if (!safe)
|
|
24
|
+
return;
|
|
25
|
+
if (!references.has(safe.path))
|
|
26
|
+
references.set(safe.path, { ...safe, references: [] });
|
|
27
|
+
references.get(safe.path).references.push(reference);
|
|
28
|
+
};
|
|
29
|
+
for (const [index, page] of audit.pages.entries()) {
|
|
30
|
+
add(page.screenshot, {
|
|
31
|
+
kind: "page-screenshot",
|
|
32
|
+
index,
|
|
33
|
+
id: page.id,
|
|
34
|
+
attempt: page.attempt,
|
|
35
|
+
mode: page.mode,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
for (const [index, navigation] of audit.navigations.entries()) {
|
|
39
|
+
add(navigation.screenshot, {
|
|
40
|
+
kind: "navigation-screenshot",
|
|
41
|
+
index,
|
|
42
|
+
id: navigation.id,
|
|
43
|
+
attempt: navigation.attempt,
|
|
44
|
+
mode: navigation.mode,
|
|
45
|
+
});
|
|
46
|
+
for (const frame of navigation.measurement?.frames || []) {
|
|
47
|
+
add(`${navigation.directory}/${frame.file}`, {
|
|
48
|
+
kind: "navigation-frame",
|
|
49
|
+
index,
|
|
50
|
+
id: navigation.id,
|
|
51
|
+
attempt: navigation.attempt,
|
|
52
|
+
mode: navigation.mode,
|
|
53
|
+
atMs: frame.atMs,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...references.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
58
|
+
}
|
|
59
|
+
export async function buildArtifactManifest(directory, audit) {
|
|
60
|
+
const root = resolve(directory);
|
|
61
|
+
const physicalRoot = await realpath(root);
|
|
62
|
+
const files = [];
|
|
63
|
+
const missing = [];
|
|
64
|
+
for (const artifact of referencedArtifacts(audit)) {
|
|
65
|
+
const absolutePath = resolve(root, artifact.path);
|
|
66
|
+
if (!absolutePath.startsWith(`${root}${sep}`))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
const metadata = await lstat(absolutePath);
|
|
70
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
71
|
+
throw new Error("Artifact is not a regular file");
|
|
72
|
+
const physicalPath = await realpath(absolutePath);
|
|
73
|
+
if (!physicalPath.startsWith(`${physicalRoot}${sep}`))
|
|
74
|
+
throw new Error("Artifact leaves run directory");
|
|
75
|
+
const content = await readFile(physicalPath);
|
|
76
|
+
files.push({
|
|
77
|
+
...artifact,
|
|
78
|
+
bytes: content.length,
|
|
79
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
missing.push(artifact);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
pathSemantics: "Paths are POSIX-style and relative to the directory containing audit.json.",
|
|
89
|
+
files,
|
|
90
|
+
missing,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function validateAuditDocument(audit: any): Promise<void>;
|
|
2
|
+
export declare function validateArchiveEntryName(name: any): string;
|
|
3
|
+
export declare function readAuditBundle(archivePath: any): Promise<{
|
|
4
|
+
audit: any;
|
|
5
|
+
entries: any;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function writeAuditArchive(auditPath: any, outputPath?: string): Promise<string>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { readFile, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve, sep } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Ajv from "ajv";
|
|
8
|
+
import yauzl from "yauzl";
|
|
9
|
+
import yazl from "yazl";
|
|
10
|
+
import { portableArtifactPath } from "./artifacts.mjs";
|
|
11
|
+
const MAX_ARCHIVE_BYTES = 300 * 1024 * 1024;
|
|
12
|
+
const MAX_ENTRY_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
const MAX_AUDIT_JSON_BYTES = 128 * 1024 * 1024;
|
|
14
|
+
const MAX_TOTAL_BYTES = 512 * 1024 * 1024;
|
|
15
|
+
const MAX_ENTRIES = 10002;
|
|
16
|
+
const ZIP_EPOCH = new Date("1980-01-01T00:00:00.000Z");
|
|
17
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
18
|
+
const schemaPath = fileURLToPath(new URL("../../schema/audit-v1.schema.json", import.meta.url));
|
|
19
|
+
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
|
|
20
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
21
|
+
ajv.addFormat("date-time", { validate: (value) => ISO_DATE_TIME.test(value) });
|
|
22
|
+
const validate = ajv.compile(schema);
|
|
23
|
+
function assertOrigin(origin) {
|
|
24
|
+
const parsed = new URL(origin);
|
|
25
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
26
|
+
throw new Error("Audit environment.origin must be a credential-free HTTP(S) URL");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function validateAuditDocument(audit) {
|
|
30
|
+
if (!validate(audit)) {
|
|
31
|
+
throw new Error(`Audit schema validation failed: ${validate.errors.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ")}`);
|
|
32
|
+
}
|
|
33
|
+
assertOrigin(audit.environment.origin);
|
|
34
|
+
const paths = new Set();
|
|
35
|
+
for (const asset of audit.assets.files) {
|
|
36
|
+
const portable = portableArtifactPath(asset.path);
|
|
37
|
+
if (!portable || portable.path !== asset.path)
|
|
38
|
+
throw new Error(`Unsafe asset path: ${asset.path}`);
|
|
39
|
+
const folded = asset.path.normalize("NFC").toLowerCase();
|
|
40
|
+
if (paths.has(folded))
|
|
41
|
+
throw new Error(`Duplicate asset path: ${asset.path}`);
|
|
42
|
+
paths.add(folded);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function openZip(archivePath) {
|
|
46
|
+
return new Promise((resolveOpen, reject) => {
|
|
47
|
+
yauzl.open(archivePath, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
|
|
48
|
+
if (error)
|
|
49
|
+
reject(error);
|
|
50
|
+
else
|
|
51
|
+
resolveOpen(zip);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function entryLimit(name) {
|
|
56
|
+
return name.normalize("NFC").toLowerCase() === "audit.json"
|
|
57
|
+
? MAX_AUDIT_JSON_BYTES
|
|
58
|
+
: MAX_ENTRY_BYTES;
|
|
59
|
+
}
|
|
60
|
+
function readEntry(zip, entry) {
|
|
61
|
+
const limit = entryLimit(entry.fileName);
|
|
62
|
+
return new Promise((resolveRead, reject) => {
|
|
63
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
64
|
+
if (error)
|
|
65
|
+
return reject(error);
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
stream.on("data", (chunk) => {
|
|
69
|
+
bytes += chunk.length;
|
|
70
|
+
if (bytes > limit)
|
|
71
|
+
stream.destroy(new Error(`Archive entry exceeds ${limit} bytes`));
|
|
72
|
+
else
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
stream.once("error", reject);
|
|
76
|
+
stream.once("end", () => resolveRead(Buffer.concat(chunks)));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function validateArchiveEntryName(name) {
|
|
81
|
+
if (typeof name !== "string" ||
|
|
82
|
+
!name ||
|
|
83
|
+
name.includes("\\") ||
|
|
84
|
+
name.startsWith("/") ||
|
|
85
|
+
/^[a-z]:/i.test(name) ||
|
|
86
|
+
name.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
|
87
|
+
throw new Error(`Unsafe archive entry: ${name}`);
|
|
88
|
+
}
|
|
89
|
+
return name;
|
|
90
|
+
}
|
|
91
|
+
async function readAuditArchive(archivePath) {
|
|
92
|
+
const archiveSize = (await stat(archivePath)).size;
|
|
93
|
+
if (archiveSize > MAX_ARCHIVE_BYTES)
|
|
94
|
+
throw new Error("Audit archive is too large");
|
|
95
|
+
const zip = await openZip(archivePath);
|
|
96
|
+
const entries = new Map();
|
|
97
|
+
let totalBytes = 0;
|
|
98
|
+
return new Promise((resolveEntries, reject) => {
|
|
99
|
+
const fail = (error) => {
|
|
100
|
+
zip.close();
|
|
101
|
+
reject(error);
|
|
102
|
+
};
|
|
103
|
+
zip.once("error", fail);
|
|
104
|
+
zip.on("entry", async (entry) => {
|
|
105
|
+
try {
|
|
106
|
+
if (entry.fileName.endsWith("/"))
|
|
107
|
+
throw new Error(`Directories are not allowed in audit archives: ${entry.fileName}`);
|
|
108
|
+
validateArchiveEntryName(entry.fileName);
|
|
109
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
110
|
+
if (mode && mode !== 0o100000)
|
|
111
|
+
throw new Error(`Non-regular archive entry: ${entry.fileName}`);
|
|
112
|
+
if (entry.uncompressedSize > entryLimit(entry.fileName) ||
|
|
113
|
+
entry.uncompressedSize > Math.max(1024, entry.compressedSize * 100)) {
|
|
114
|
+
throw new Error(`Unsafe archive entry size: ${entry.fileName}`);
|
|
115
|
+
}
|
|
116
|
+
const folded = entry.fileName.normalize("NFC").toLowerCase();
|
|
117
|
+
if (entries.has(folded))
|
|
118
|
+
throw new Error(`Duplicate archive entry: ${entry.fileName}`);
|
|
119
|
+
if (entries.size >= MAX_ENTRIES)
|
|
120
|
+
throw new Error("Audit archive has too many entries");
|
|
121
|
+
const content = await readEntry(zip, entry);
|
|
122
|
+
totalBytes += content.length;
|
|
123
|
+
if (totalBytes > MAX_TOTAL_BYTES)
|
|
124
|
+
throw new Error("Audit archive expands beyond the allowed size");
|
|
125
|
+
entries.set(folded, { name: entry.fileName, content });
|
|
126
|
+
zip.readEntry();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
fail(error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
zip.once("end", () => resolveEntries(entries));
|
|
133
|
+
zip.readEntry();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export async function readAuditBundle(archivePath) {
|
|
137
|
+
const entries = await readAuditArchive(archivePath);
|
|
138
|
+
const auditEntry = entries.get("audit.json");
|
|
139
|
+
if (!auditEntry)
|
|
140
|
+
throw new Error("Audit archive must contain audit.json");
|
|
141
|
+
const audit = JSON.parse(auditEntry.content.toString("utf8"));
|
|
142
|
+
await validateAuditDocument(audit);
|
|
143
|
+
return { audit, entries };
|
|
144
|
+
}
|
|
145
|
+
export async function writeAuditArchive(auditPath, outputPath = resolve(dirname(auditPath), "audit.zip")) {
|
|
146
|
+
const sourceDirectory = dirname(auditPath);
|
|
147
|
+
const audit = JSON.parse(await readFile(auditPath, "utf8"));
|
|
148
|
+
audit.$schema = "./audit.schema.json";
|
|
149
|
+
delete audit.assets.publicBasePath;
|
|
150
|
+
await validateAuditDocument(audit);
|
|
151
|
+
const document = Buffer.from(JSON.stringify(audit, null, 2));
|
|
152
|
+
if (document.length > MAX_AUDIT_JSON_BYTES)
|
|
153
|
+
throw new Error(`audit.json exceeds ${MAX_AUDIT_JSON_BYTES} bytes`);
|
|
154
|
+
const schema = await readFile(schemaPath);
|
|
155
|
+
const zip = new yazl.ZipFile();
|
|
156
|
+
zip.addBuffer(document, "audit.json", {
|
|
157
|
+
mtime: ZIP_EPOCH,
|
|
158
|
+
mode: 0o100644,
|
|
159
|
+
});
|
|
160
|
+
zip.addBuffer(schema, "audit.schema.json", { mtime: ZIP_EPOCH, mode: 0o100644 });
|
|
161
|
+
for (const asset of audit.assets.files) {
|
|
162
|
+
const source = resolve(sourceDirectory, asset.path);
|
|
163
|
+
if (!source.startsWith(`${resolve(sourceDirectory)}${sep}`))
|
|
164
|
+
throw new Error(`Unsafe asset path: ${asset.path}`);
|
|
165
|
+
zip.addFile(source, `assets/${asset.path}`, {
|
|
166
|
+
mtime: ZIP_EPOCH,
|
|
167
|
+
mode: 0o100644,
|
|
168
|
+
compress: true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const temporaryPath = `${outputPath}.${randomUUID()}.tmp`;
|
|
172
|
+
try {
|
|
173
|
+
zip.end();
|
|
174
|
+
await pipeline(zip.outputStream, createWriteStream(temporaryPath, { flags: "wx" }));
|
|
175
|
+
await readAuditBundle(temporaryPath);
|
|
176
|
+
await rename(temporaryPath, outputPath);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
await rm(temporaryPath, { force: true });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
return outputPath;
|
|
183
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const thresholds: {
|
|
2
|
+
ttfb: {
|
|
3
|
+
goodBelowMs: number;
|
|
4
|
+
okBelowMs: number;
|
|
5
|
+
};
|
|
6
|
+
feedback: {
|
|
7
|
+
goodBelowMs: number;
|
|
8
|
+
okBelowMs: number;
|
|
9
|
+
};
|
|
10
|
+
content: {
|
|
11
|
+
goodBelowMs: number;
|
|
12
|
+
badAboveMs: number;
|
|
13
|
+
};
|
|
14
|
+
images: {
|
|
15
|
+
earlyAfterTtfbMs: number;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export declare function gradeTiming(ms: any, metric: any): "bad" | "good" | "ok" | null;
|
|
19
|
+
export declare function imageFinding(image: any): {
|
|
20
|
+
grade: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
} | {
|
|
23
|
+
grade: null;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const navigationMetricTypes: {
|
|
27
|
+
clickToFeedbackMs: string;
|
|
28
|
+
clickToUrlMs: null;
|
|
29
|
+
clickToShellMs: string;
|
|
30
|
+
clickToUsefulMs: string;
|
|
31
|
+
clickToCompleteMs: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function navigationFindings(record: any): {
|
|
34
|
+
[k: string]: {
|
|
35
|
+
grade: string | null;
|
|
36
|
+
ms: any;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export declare function pageFindings(record: any): {
|
|
40
|
+
ttfb: {
|
|
41
|
+
grade: string | null;
|
|
42
|
+
ms: any;
|
|
43
|
+
};
|
|
44
|
+
images: ({
|
|
45
|
+
grade: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
url: any;
|
|
48
|
+
networkId: any;
|
|
49
|
+
isLcpCandidate: any;
|
|
50
|
+
} | {
|
|
51
|
+
grade: null;
|
|
52
|
+
reason: string;
|
|
53
|
+
url: any;
|
|
54
|
+
networkId: any;
|
|
55
|
+
isLcpCandidate: any;
|
|
56
|
+
})[];
|
|
57
|
+
};
|
|
58
|
+
export declare function recordIssue(record: any, phase: any, error: any): void;
|
|
59
|
+
export declare function collectCase(record: any, work: any, cleanup?: () => Promise<void>): Promise<any>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export const thresholds = {
|
|
2
|
+
ttfb: { goodBelowMs: 200, okBelowMs: 600 },
|
|
3
|
+
feedback: { goodBelowMs: 50, okBelowMs: 100 },
|
|
4
|
+
content: { goodBelowMs: 1000, badAboveMs: 3000 },
|
|
5
|
+
images: { earlyAfterTtfbMs: 100 },
|
|
6
|
+
};
|
|
7
|
+
export function gradeTiming(ms, metric) {
|
|
8
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0)
|
|
9
|
+
return null;
|
|
10
|
+
const bands = thresholds[metric];
|
|
11
|
+
if (!bands)
|
|
12
|
+
return null;
|
|
13
|
+
if (metric === "content")
|
|
14
|
+
return ms < bands.goodBelowMs ? "good" : ms <= bands.badAboveMs ? "ok" : "bad";
|
|
15
|
+
return ms < bands.goodBelowMs ? "good" : ms < bands.okBelowMs ? "ok" : "bad";
|
|
16
|
+
}
|
|
17
|
+
export function imageFinding(image) {
|
|
18
|
+
const delay = image.fetchStartAfterTtfbMs;
|
|
19
|
+
const preload = (image.preloadEvidence || []).length > 0;
|
|
20
|
+
if (image.requestStatus === "failed" || (image.complete && image.naturalWidth === 0)) {
|
|
21
|
+
return { grade: "bad", reason: "Image request failed or image could not be decoded." };
|
|
22
|
+
}
|
|
23
|
+
if (typeof delay !== "number" || !Number.isFinite(delay)) {
|
|
24
|
+
return { grade: null, reason: "Fetch start relative to document first byte was not observed." };
|
|
25
|
+
}
|
|
26
|
+
if (delay > thresholds.images.earlyAfterTtfbMs) {
|
|
27
|
+
return {
|
|
28
|
+
grade: "bad",
|
|
29
|
+
reason: `Fetch began ${Math.round(delay)}ms after first byte; a preload or later priority upgrade does not erase late discovery.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (image.requestStatus === "pending") {
|
|
33
|
+
return {
|
|
34
|
+
grade: "ok",
|
|
35
|
+
reason: "Fetch began early but was still pending at the observation cutoff.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (["High", "VeryHigh"].includes(image.initialPriority)) {
|
|
39
|
+
return {
|
|
40
|
+
grade: "good",
|
|
41
|
+
reason: preload
|
|
42
|
+
? "Early fetch, high initial priority, and observed preload evidence."
|
|
43
|
+
: "Early fetch and high initial priority; preload was not confirmed.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
grade: "ok",
|
|
48
|
+
reason: preload
|
|
49
|
+
? "Preload observed and fetch began early, but high initial priority was not observed."
|
|
50
|
+
: "Fetch began early, but high initial priority/preload was not confirmed. Later upgrades are not initial priority.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export const navigationMetricTypes = {
|
|
54
|
+
clickToFeedbackMs: "feedback",
|
|
55
|
+
clickToUrlMs: null,
|
|
56
|
+
clickToShellMs: "feedback",
|
|
57
|
+
clickToUsefulMs: "content",
|
|
58
|
+
clickToCompleteMs: "content",
|
|
59
|
+
};
|
|
60
|
+
export function navigationFindings(record) {
|
|
61
|
+
const measurement = record.measurement;
|
|
62
|
+
return Object.fromEntries(Object.entries(navigationMetricTypes).map(([metric, kind]) => [
|
|
63
|
+
metric,
|
|
64
|
+
{
|
|
65
|
+
grade: gradeTiming(measurement?.[metric], kind),
|
|
66
|
+
ms: measurement?.[metric] ?? null,
|
|
67
|
+
},
|
|
68
|
+
]));
|
|
69
|
+
}
|
|
70
|
+
export function pageFindings(record) {
|
|
71
|
+
const diagnostics = record.diagnostics;
|
|
72
|
+
return {
|
|
73
|
+
ttfb: {
|
|
74
|
+
grade: gradeTiming(diagnostics?.navigation?.ttfbMs, "ttfb"),
|
|
75
|
+
ms: diagnostics?.navigation?.ttfbMs ?? null,
|
|
76
|
+
},
|
|
77
|
+
images: [
|
|
78
|
+
...(diagnostics?.images?.final || []),
|
|
79
|
+
...(diagnostics?.images?.backgrounds?.final || []),
|
|
80
|
+
]
|
|
81
|
+
.filter((image) => image.visible && image.intersectsViewport)
|
|
82
|
+
.map((image) => ({
|
|
83
|
+
url: image.sourceUrl || image.url,
|
|
84
|
+
networkId: image.networkId,
|
|
85
|
+
isLcpCandidate: image.isLcpCandidate ?? null,
|
|
86
|
+
...imageFinding(image),
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function recordIssue(record, phase, error) {
|
|
91
|
+
const message = String(error?.message || error)
|
|
92
|
+
.split("\n")[0]
|
|
93
|
+
.replace(/https?:\/\/\S+/g, "[URL omitted]");
|
|
94
|
+
record.status = "failed";
|
|
95
|
+
record.error = [record.error, `${phase}: ${message}`].filter(Boolean).join("; ");
|
|
96
|
+
}
|
|
97
|
+
export async function collectCase(record, work, cleanup = async () => { }) {
|
|
98
|
+
try {
|
|
99
|
+
await work(record);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
recordIssue(record, "Collection", error);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await cleanup(record);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
recordIssue(record, "Cleanup", error);
|
|
109
|
+
}
|
|
110
|
+
return record;
|
|
111
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves an inspect target (a path or absolute URL) against the audit origin and refuses
|
|
3
|
+
* anything outside it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveInspectUrl(target: any, origin: any): string;
|
|
6
|
+
export declare function formatInspectCapture(capture: any): string;
|
|
7
|
+
/**
|
|
8
|
+
* Opens one URL like an audit case and saves evidence for writing markers and journeys:
|
|
9
|
+
* a screenshot, visible headings and same-origin links (with rendered size), ad-hoc selector
|
|
10
|
+
* counts, the configured page's marker state, and the main HTML. With `click`, it hovers and
|
|
11
|
+
* clicks the first visible `a[href]` whose attribute exactly equals `click` — the same target a
|
|
12
|
+
* journey uses — and captures again after the click, 1s and 5s.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string} options.url
|
|
16
|
+
* @param {string | null} [options.click]
|
|
17
|
+
* @param {string[]} [options.selectors]
|
|
18
|
+
* @param {readonly import("../config/types.ts").PageSpec[]} [options.pages]
|
|
19
|
+
* @param {import("../config/types.ts").AuditSettings} [options.settings]
|
|
20
|
+
* @param {string} [options.outputDirectory]
|
|
21
|
+
* @param {boolean} [options.headed]
|
|
22
|
+
* @param {(capture: any) => void} [options.onCapture] Called as each capture is saved.
|
|
23
|
+
*/
|
|
24
|
+
export declare function inspectUrl({ url, click, selectors, pages, settings, outputDirectory, headed, onCapture, }: {
|
|
25
|
+
url: string;
|
|
26
|
+
click?: string | null;
|
|
27
|
+
selectors?: string[];
|
|
28
|
+
pages?: readonly import("../config/types.ts").PageSpec[];
|
|
29
|
+
settings?: import("../config/types.ts").AuditSettings;
|
|
30
|
+
outputDirectory?: string;
|
|
31
|
+
headed?: boolean;
|
|
32
|
+
onCapture?: (capture: any) => void;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
directory: string;
|
|
35
|
+
captures: any[];
|
|
36
|
+
}>;
|