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
package/dist/infra.js
ADDED
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AuditConfig } from "./config/types.ts";
|
|
2
|
+
type CaseRecord = Record<string, any>;
|
|
3
|
+
export interface AuditDocument {
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
finishedAt: string | null;
|
|
7
|
+
provenance: Record<string, unknown>;
|
|
8
|
+
environment: Record<string, unknown>;
|
|
9
|
+
settings: Record<string, unknown>;
|
|
10
|
+
observations: string[];
|
|
11
|
+
pages: CaseRecord[];
|
|
12
|
+
navigations: CaseRecord[];
|
|
13
|
+
assets?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface RunOptions {
|
|
16
|
+
repeats?: number;
|
|
17
|
+
only?: string[];
|
|
18
|
+
pagesOnly?: boolean;
|
|
19
|
+
navigationOnly?: boolean;
|
|
20
|
+
headed?: boolean;
|
|
21
|
+
outputDirectory?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AuditResult {
|
|
24
|
+
directory: string;
|
|
25
|
+
reportPath: string;
|
|
26
|
+
archivePath: string;
|
|
27
|
+
audit: AuditDocument;
|
|
28
|
+
}
|
|
29
|
+
export declare function runAudit(config: AuditConfig, options?: RunOptions): Promise<AuditResult>;
|
|
30
|
+
export {};
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { arch, platform } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { AuditConfigError } from "./config/errors.js";
|
|
5
|
+
import { buildArtifactManifest } from "./engine/artifacts.mjs";
|
|
6
|
+
import { writeAuditArchive } from "./engine/audit-archive.mjs";
|
|
7
|
+
import { collectCase, navigationFindings, pageFindings, recordIssue, thresholds, } from "./engine/findings.mjs";
|
|
8
|
+
import { startNavigationRequestCapture } from "./engine/navigation-requests.mjs";
|
|
9
|
+
import { inspectState, measureNavigation, waitForContent } from "./engine/navigation.mjs";
|
|
10
|
+
import { startPageDiagnostics } from "./engine/page-diagnostics.mjs";
|
|
11
|
+
import { writeReport } from "./engine/report.mjs";
|
|
12
|
+
function resolveOrigin(config) {
|
|
13
|
+
const origin = process.env.AUDIT_ORIGIN ?? config.origin;
|
|
14
|
+
const parsed = new URL(origin);
|
|
15
|
+
if (!["http:", "https:"].includes(parsed.protocol) ||
|
|
16
|
+
parsed.username ||
|
|
17
|
+
parsed.password ||
|
|
18
|
+
parsed.search ||
|
|
19
|
+
parsed.hash ||
|
|
20
|
+
parsed.pathname !== "/") {
|
|
21
|
+
throw new AuditConfigError("invalid-config", "origin", "AUDIT_ORIGIN must be a credential-free HTTP(S) origin without a path, query or fragment");
|
|
22
|
+
}
|
|
23
|
+
return origin;
|
|
24
|
+
}
|
|
25
|
+
export async function runAudit(config, options = {}) {
|
|
26
|
+
const repeats = options.repeats ?? config.settings.repeats;
|
|
27
|
+
if (!Number.isInteger(repeats) || repeats < 1 || repeats > 20) {
|
|
28
|
+
throw new AuditConfigError("invalid-config", "repeats", "repeats must be an integer from 1 to 20");
|
|
29
|
+
}
|
|
30
|
+
const only = options.only ?? [];
|
|
31
|
+
const skipPages = options.navigationOnly ?? false;
|
|
32
|
+
const skipNavigations = options.pagesOnly ?? false;
|
|
33
|
+
const headless = !(options.headed ?? false);
|
|
34
|
+
const settings = config.settings;
|
|
35
|
+
const origin = resolveOrigin(config);
|
|
36
|
+
const outputDirectory = resolve(options.outputDirectory ?? "artifacts");
|
|
37
|
+
const directory = resolve(outputDirectory, `run-${new Date().toISOString().replaceAll(":", "-")}`);
|
|
38
|
+
await mkdir(directory, { recursive: true });
|
|
39
|
+
let launchError;
|
|
40
|
+
const { chromium } = await import("@playwright/test");
|
|
41
|
+
const browser = await chromium.launch({ headless }).catch((error) => {
|
|
42
|
+
launchError = error;
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
const audit = {
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
startedAt: new Date().toISOString(),
|
|
48
|
+
finishedAt: null,
|
|
49
|
+
provenance: {
|
|
50
|
+
commit: process.env.GITHUB_SHA ?? null,
|
|
51
|
+
ref: process.env.GITHUB_REF_NAME ?? null,
|
|
52
|
+
repository: process.env.GITHUB_REPOSITORY ?? null,
|
|
53
|
+
runUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
|
|
54
|
+
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
55
|
+
: null,
|
|
56
|
+
},
|
|
57
|
+
environment: {
|
|
58
|
+
origin,
|
|
59
|
+
browserVersion: browser?.version() ?? "unavailable",
|
|
60
|
+
headless,
|
|
61
|
+
viewport: settings.viewport,
|
|
62
|
+
platform: `${platform()} ${arch()}`,
|
|
63
|
+
network: "unthrottled",
|
|
64
|
+
cpu: "unthrottled",
|
|
65
|
+
anonymous: true,
|
|
66
|
+
browserCache: "enabled; fresh context per attempt",
|
|
67
|
+
cdnCache: "uncontrolled; response headers recorded",
|
|
68
|
+
colorScheme: "light",
|
|
69
|
+
timezone: "UTC",
|
|
70
|
+
locale: "en-US",
|
|
71
|
+
},
|
|
72
|
+
settings: { ...settings, repeats, thresholds },
|
|
73
|
+
observations: [],
|
|
74
|
+
pages: [],
|
|
75
|
+
navigations: [],
|
|
76
|
+
};
|
|
77
|
+
const contextOptions = {
|
|
78
|
+
viewport: settings.viewport,
|
|
79
|
+
colorScheme: "light",
|
|
80
|
+
timezoneId: "UTC",
|
|
81
|
+
locale: "en-US",
|
|
82
|
+
};
|
|
83
|
+
async function newContext() {
|
|
84
|
+
if (!browser)
|
|
85
|
+
throw launchError;
|
|
86
|
+
return browser.newContext(contextOptions);
|
|
87
|
+
}
|
|
88
|
+
async function save() {
|
|
89
|
+
audit.finishedAt = new Date().toISOString();
|
|
90
|
+
audit.observations = [
|
|
91
|
+
"Cases are reporting-only: poor metrics, missing content and individual collection errors do not stop remaining cases or fail the command.",
|
|
92
|
+
"Desktop anonymous synthetic baseline only. Font and image diagnoses require the per-page source/priority evidence; local fallback fonts are not network blockers.",
|
|
93
|
+
];
|
|
94
|
+
audit.assets = await buildArtifactManifest(directory, audit);
|
|
95
|
+
await writeFile(`${directory}/audit.json`, JSON.stringify(audit, null, 2));
|
|
96
|
+
}
|
|
97
|
+
async function measurePage(spec, holder, attempt, mode) {
|
|
98
|
+
let diagnostics;
|
|
99
|
+
const record = {
|
|
100
|
+
id: spec.id,
|
|
101
|
+
path: spec.path,
|
|
102
|
+
attempt,
|
|
103
|
+
mode,
|
|
104
|
+
status: "ok",
|
|
105
|
+
screenshot: `pages/${spec.id}-${attempt}-${mode}.png`,
|
|
106
|
+
};
|
|
107
|
+
await collectCase(record, async () => {
|
|
108
|
+
if (!holder.context)
|
|
109
|
+
holder.context = await newContext();
|
|
110
|
+
if (!holder.page || holder.page.isClosed())
|
|
111
|
+
holder.page = await holder.context.newPage();
|
|
112
|
+
const page = holder.page;
|
|
113
|
+
await collectCase(record, async () => {
|
|
114
|
+
diagnostics = await startPageDiagnostics(page);
|
|
115
|
+
});
|
|
116
|
+
if (mode === "warm-reload" && new URL(page.url()).pathname !== spec.path) {
|
|
117
|
+
recordIssue(record, "Warm reload", "Original document was not available; this is a recovery navigation, not a warm sample");
|
|
118
|
+
record.mode = "recovery-navigation";
|
|
119
|
+
}
|
|
120
|
+
const response = record.mode === "warm-reload"
|
|
121
|
+
? await page.reload({ waitUntil: "domcontentloaded", timeout: 60000 })
|
|
122
|
+
: await page.goto(new URL(spec.path, origin).href, {
|
|
123
|
+
waitUntil: "domcontentloaded",
|
|
124
|
+
timeout: 60000,
|
|
125
|
+
});
|
|
126
|
+
const finalPath = new URL(page.url()).pathname;
|
|
127
|
+
if (!response?.ok())
|
|
128
|
+
recordIssue(record, "Document", `HTTP ${response?.status() ?? "missing"}`);
|
|
129
|
+
else if (finalPath !== spec.path)
|
|
130
|
+
recordIssue(record, "Path", `Redirected to ${finalPath}; pages are matched on location.pathname, so set this page's path to ${finalPath}`);
|
|
131
|
+
else
|
|
132
|
+
await collectCase(record, () => waitForContent(page, spec));
|
|
133
|
+
await page.waitForTimeout(settings.pageObservationMs);
|
|
134
|
+
}, async () => {
|
|
135
|
+
if (diagnostics) {
|
|
136
|
+
const pending = diagnostics;
|
|
137
|
+
await collectCase(record, async () => {
|
|
138
|
+
record.diagnostics = await pending.finish();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (holder.page) {
|
|
142
|
+
await collectCase(record, async () => {
|
|
143
|
+
record.finalState = await holder.page.evaluate(inspectState, spec);
|
|
144
|
+
});
|
|
145
|
+
await collectCase(record, () => holder
|
|
146
|
+
.page.screenshot({ path: `${directory}/${record.screenshot}`, timeout: 10000 })
|
|
147
|
+
.catch((error) => {
|
|
148
|
+
record.screenshot = null;
|
|
149
|
+
throw error;
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
record.screenshot = null;
|
|
154
|
+
if (!record.finalState?.primary) {
|
|
155
|
+
recordIssue(record, "Content", "Useful page content missing at final collection");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const ttfb = pageFindings(record).ttfb;
|
|
159
|
+
audit.pages.push(record);
|
|
160
|
+
console.log(`page ${spec.path} ${record.mode}; TTFB ${ttfb.grade ?? "unmeasured"} ${ttfb.ms?.toFixed(0) ?? "?"}ms; collection ${record.status === "ok" ? "complete" : "incomplete"}`);
|
|
161
|
+
await save();
|
|
162
|
+
}
|
|
163
|
+
async function measureJourney(journey, attempt, mode, holder) {
|
|
164
|
+
const source = config.pages.find((page) => page.id === journey.from);
|
|
165
|
+
const target = config.pages.find((page) => page.id === journey.to);
|
|
166
|
+
if (!source || !target) {
|
|
167
|
+
throw new AuditConfigError("unknown-page", `journeys.${journey.id}`, `journey references pages that are not defined`);
|
|
168
|
+
}
|
|
169
|
+
let requestCapture;
|
|
170
|
+
const folder = `navigations/${journey.id}-${attempt}-${mode}`;
|
|
171
|
+
const record = {
|
|
172
|
+
id: journey.id,
|
|
173
|
+
from: source.path,
|
|
174
|
+
to: target.path,
|
|
175
|
+
attempt,
|
|
176
|
+
mode,
|
|
177
|
+
status: "ok",
|
|
178
|
+
criteria: target,
|
|
179
|
+
directory: folder,
|
|
180
|
+
screenshot: `${folder}/final.png`,
|
|
181
|
+
};
|
|
182
|
+
await collectCase(record, async () => {
|
|
183
|
+
await mkdir(`${directory}/${folder}`, { recursive: true });
|
|
184
|
+
if (!holder.context)
|
|
185
|
+
holder.context = await newContext();
|
|
186
|
+
if (!holder.page || holder.page.isClosed())
|
|
187
|
+
holder.page = await holder.context.newPage();
|
|
188
|
+
const page = holder.page;
|
|
189
|
+
requestCapture = await startNavigationRequestCapture(page, new URL(target.path, origin).href);
|
|
190
|
+
if (mode === "revisit") {
|
|
191
|
+
if (new URL(page.url()).pathname !== target.path) {
|
|
192
|
+
throw new Error("Revisit requires the completed first visit destination in the same page");
|
|
193
|
+
}
|
|
194
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: 60000 });
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
await page.goto(new URL(source.path, origin).href, {
|
|
198
|
+
waitUntil: "domcontentloaded",
|
|
199
|
+
timeout: 60000,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
await waitForContent(page, source);
|
|
203
|
+
await page.waitForTimeout(settings.sourceDwellMs);
|
|
204
|
+
record.measurement = await measureNavigation(page, target, {
|
|
205
|
+
directory: `${directory}/${folder}`,
|
|
206
|
+
observeMs: settings.navigationObservationMs,
|
|
207
|
+
requireClientReady: mode === "client-ready-first-visit",
|
|
208
|
+
requestCapture,
|
|
209
|
+
settings,
|
|
210
|
+
});
|
|
211
|
+
if (record.measurement.clickToUsefulMs === null) {
|
|
212
|
+
throw new Error("Useful destination content not observed within the measurement window");
|
|
213
|
+
}
|
|
214
|
+
if (!record.measurement.finalState?.primary) {
|
|
215
|
+
throw new Error("Useful content did not survive until final collection");
|
|
216
|
+
}
|
|
217
|
+
if (record.measurement.collectionErrors.length) {
|
|
218
|
+
throw new Error("Instrumentation incomplete; inspect measurement.collectionErrors");
|
|
219
|
+
}
|
|
220
|
+
}, async () => {
|
|
221
|
+
if (holder.page) {
|
|
222
|
+
await collectCase(record, () => holder
|
|
223
|
+
.page.screenshot({ path: `${directory}/${record.screenshot}`, timeout: 10000 })
|
|
224
|
+
.catch((error) => {
|
|
225
|
+
record.screenshot = null;
|
|
226
|
+
throw error;
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
else
|
|
230
|
+
record.screenshot = null;
|
|
231
|
+
if (!record.measurement && requestCapture) {
|
|
232
|
+
const pending = requestCapture;
|
|
233
|
+
await collectCase(record, () => pending.dispose());
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const findings = navigationFindings(record);
|
|
237
|
+
audit.navigations.push(record);
|
|
238
|
+
console.log(`navigation ${journey.id} ${mode}; feedback ${findings.clickToFeedbackMs?.grade ?? "unmeasured"} ${record.measurement?.clickToFeedbackMs?.toFixed(0) ?? "?"}ms; useful ${findings.clickToUsefulMs?.grade ?? "unmeasured"} ${record.measurement?.clickToUsefulMs?.toFixed(0) ?? "?"}ms; collection ${record.status === "ok" ? "complete" : "incomplete"}`);
|
|
239
|
+
await save();
|
|
240
|
+
return record;
|
|
241
|
+
}
|
|
242
|
+
let result;
|
|
243
|
+
try {
|
|
244
|
+
await mkdir(`${directory}/pages`, { recursive: true });
|
|
245
|
+
if (!skipPages) {
|
|
246
|
+
for (const spec of config.pages.filter((page) => only.length === 0 || only.includes(page.id))) {
|
|
247
|
+
for (let attempt = 1; attempt <= repeats; attempt++) {
|
|
248
|
+
const holder = {};
|
|
249
|
+
try {
|
|
250
|
+
await measurePage(spec, holder, attempt, "fresh-context");
|
|
251
|
+
await measurePage(spec, holder, attempt, "warm-reload");
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
if (holder.context) {
|
|
255
|
+
const last = audit.pages.at(-1);
|
|
256
|
+
if (last)
|
|
257
|
+
await collectCase(last, () => holder.context.close());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!skipNavigations) {
|
|
264
|
+
const journeys = config.journeys.filter((journey) => only.length === 0 || only.includes(journey.id) || only.includes(journey.to));
|
|
265
|
+
for (const journey of journeys) {
|
|
266
|
+
for (let attempt = 1; attempt <= repeats; attempt++) {
|
|
267
|
+
const sequence = {};
|
|
268
|
+
let firstVisit;
|
|
269
|
+
try {
|
|
270
|
+
firstVisit = await measureJourney(journey, attempt, "first-visit", sequence);
|
|
271
|
+
await measureJourney(journey, attempt, "revisit", sequence);
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
if (sequence.context) {
|
|
275
|
+
const last = audit.navigations.at(-1);
|
|
276
|
+
if (last)
|
|
277
|
+
await collectCase(last, () => sequence.context.close());
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (firstVisit?.measurement?.type === "full-document") {
|
|
281
|
+
const retry = {};
|
|
282
|
+
try {
|
|
283
|
+
await measureJourney(journey, attempt, "client-ready-first-visit", retry);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
if (retry.context) {
|
|
287
|
+
const last = audit.navigations.at(-1);
|
|
288
|
+
if (last)
|
|
289
|
+
await collectCase(last, () => retry.context.close());
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!audit.pages.length && !audit.navigations.length) {
|
|
297
|
+
throw new AuditConfigError("invalid-config", "only", "No matching cases selected");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
try {
|
|
302
|
+
await save();
|
|
303
|
+
const reportPath = await writeReport(audit, directory);
|
|
304
|
+
const archivePath = await writeAuditArchive(`${directory}/audit.json`);
|
|
305
|
+
await writeFile(`${outputDirectory}/latest.json`, JSON.stringify({ directory }, null, 2));
|
|
306
|
+
console.log(`Report: ${reportPath}`);
|
|
307
|
+
console.log(`Bundle: ${archivePath}`);
|
|
308
|
+
result = { directory, reportPath, archivePath, audit };
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
await browser
|
|
312
|
+
?.close()
|
|
313
|
+
.catch((error) => console.warn(`Browser cleanup: ${error.message.split("\n")[0]}`));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!result)
|
|
317
|
+
throw new Error("Audit did not produce a result");
|
|
318
|
+
return result;
|
|
319
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "audit0",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Engine and authoring SDK for browser performance and UX audits",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"audit0": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"schema"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./cli": {
|
|
21
|
+
"types": "./dist/cli.d.ts",
|
|
22
|
+
"default": "./dist/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"./infra": {
|
|
25
|
+
"types": "./dist/infra.d.ts",
|
|
26
|
+
"default": "./dist/infra.js"
|
|
27
|
+
},
|
|
28
|
+
"./findings": {
|
|
29
|
+
"types": "./dist/findings.d.ts",
|
|
30
|
+
"default": "./dist/findings.js"
|
|
31
|
+
},
|
|
32
|
+
"./schema.json": "./schema/audit-v1.schema.json",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"ajv": "8.17.1",
|
|
41
|
+
"yauzl": "3.2.0",
|
|
42
|
+
"yazl": "3.3.1",
|
|
43
|
+
"zod": "4.6.5"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@playwright/test": "1.63.0",
|
|
47
|
+
"@types/node": "24.0.1",
|
|
48
|
+
"typescript": "7.0.2"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@playwright/test": "^1.63.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@playwright/test": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=24"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
63
|
+
"typecheck": "tsc --noEmit",
|
|
64
|
+
"test": "node --test test/*.test.mjs test/*.test.ts"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "urn:audit0:audit:v1",
|
|
4
|
+
"title": "Browser audit report",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schemaVersion",
|
|
8
|
+
"startedAt",
|
|
9
|
+
"finishedAt",
|
|
10
|
+
"environment",
|
|
11
|
+
"settings",
|
|
12
|
+
"observations",
|
|
13
|
+
"pages",
|
|
14
|
+
"navigations",
|
|
15
|
+
"assets"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"$schema": { "type": "string" },
|
|
19
|
+
"schemaVersion": { "const": 1 },
|
|
20
|
+
"id": { "type": "string", "minLength": 1, "maxLength": 160 },
|
|
21
|
+
"startedAt": { "type": "string", "format": "date-time" },
|
|
22
|
+
"finishedAt": { "type": "string", "format": "date-time" },
|
|
23
|
+
"provenance": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": true
|
|
26
|
+
},
|
|
27
|
+
"environment": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["origin"],
|
|
30
|
+
"additionalProperties": true
|
|
31
|
+
},
|
|
32
|
+
"settings": { "type": "object", "additionalProperties": true },
|
|
33
|
+
"observations": { "type": "array" },
|
|
34
|
+
"pages": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"maxItems": 10000,
|
|
37
|
+
"items": { "type": "object", "additionalProperties": true }
|
|
38
|
+
},
|
|
39
|
+
"navigations": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"maxItems": 10000,
|
|
42
|
+
"items": { "type": "object", "additionalProperties": true }
|
|
43
|
+
},
|
|
44
|
+
"assets": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"required": ["version", "files", "missing"],
|
|
47
|
+
"properties": {
|
|
48
|
+
"version": { "const": 1 },
|
|
49
|
+
"pathSemantics": { "type": "string" },
|
|
50
|
+
"publicBasePath": { "type": "string" },
|
|
51
|
+
"files": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"maxItems": 10000,
|
|
54
|
+
"items": { "$ref": "#/definitions/asset" }
|
|
55
|
+
},
|
|
56
|
+
"missing": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"maxItems": 10000,
|
|
59
|
+
"items": { "type": "object", "additionalProperties": true }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"additionalProperties": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"additionalProperties": true,
|
|
66
|
+
"definitions": {
|
|
67
|
+
"asset": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"required": ["path", "mediaType", "bytes", "sha256", "references"],
|
|
70
|
+
"properties": {
|
|
71
|
+
"path": { "type": "string", "minLength": 1, "maxLength": 1024 },
|
|
72
|
+
"mediaType": {
|
|
73
|
+
"enum": ["image/jpeg", "image/png", "image/webp"]
|
|
74
|
+
},
|
|
75
|
+
"bytes": { "type": "integer", "minimum": 0, "maximum": 20971520 },
|
|
76
|
+
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
77
|
+
"references": { "type": "array" }
|
|
78
|
+
},
|
|
79
|
+
"additionalProperties": true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|