webguardx 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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/bin/cli.cjs +1944 -0
- package/dist/bin/cli.cjs.map +1 -0
- package/dist/bin/cli.d.cts +2 -0
- package/dist/bin/cli.d.ts +2 -0
- package/dist/bin/cli.js +1919 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/index.cjs +1641 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +658 -0
- package/dist/index.d.ts +658 -0
- package/dist/index.js +1583 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -0
- package/templates/init/.env.example +6 -0
- package/templates/init/webguard.config.ts +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
// src/config/schema.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var AuthApiLoginSchema = z.object({
|
|
4
|
+
method: z.literal("api-login"),
|
|
5
|
+
loginUrl: z.string().url(),
|
|
6
|
+
payload: z.record(z.string()),
|
|
7
|
+
headers: z.record(z.string()).optional()
|
|
8
|
+
});
|
|
9
|
+
var AuthFormLoginSchema = z.object({
|
|
10
|
+
method: z.literal("form-login"),
|
|
11
|
+
loginUrl: z.string().url(),
|
|
12
|
+
fields: z.array(
|
|
13
|
+
z.object({
|
|
14
|
+
selector: z.string(),
|
|
15
|
+
value: z.string()
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
submitSelector: z.string(),
|
|
19
|
+
waitAfterLogin: z.string().optional()
|
|
20
|
+
});
|
|
21
|
+
var AuthCookieSchema = z.object({
|
|
22
|
+
method: z.literal("cookie"),
|
|
23
|
+
cookies: z.array(
|
|
24
|
+
z.object({
|
|
25
|
+
name: z.string(),
|
|
26
|
+
value: z.string(),
|
|
27
|
+
domain: z.string(),
|
|
28
|
+
path: z.string().default("/")
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
});
|
|
32
|
+
var AuthBearerSchema = z.object({
|
|
33
|
+
method: z.literal("bearer-token"),
|
|
34
|
+
token: z.string()
|
|
35
|
+
});
|
|
36
|
+
var AuthNoneSchema = z.object({
|
|
37
|
+
method: z.literal("none")
|
|
38
|
+
});
|
|
39
|
+
var AuthSchema = z.discriminatedUnion("method", [
|
|
40
|
+
AuthApiLoginSchema,
|
|
41
|
+
AuthFormLoginSchema,
|
|
42
|
+
AuthCookieSchema,
|
|
43
|
+
AuthBearerSchema,
|
|
44
|
+
AuthNoneSchema
|
|
45
|
+
]);
|
|
46
|
+
var PageSchema = z.object({
|
|
47
|
+
name: z.string(),
|
|
48
|
+
path: z.string(),
|
|
49
|
+
expectedStatus: z.number().default(200),
|
|
50
|
+
skipAudits: z.array(z.string()).optional()
|
|
51
|
+
});
|
|
52
|
+
var LighthouseThresholdsSchema = z.object({
|
|
53
|
+
performance: z.number().min(0).max(100).default(50),
|
|
54
|
+
accessibility: z.number().min(0).max(100).default(90),
|
|
55
|
+
bestPractices: z.number().min(0).max(100).default(80),
|
|
56
|
+
seo: z.number().min(0).max(100).default(80)
|
|
57
|
+
}).partial();
|
|
58
|
+
var WebguardConfigSchema = z.object({
|
|
59
|
+
baseURL: z.string().url(),
|
|
60
|
+
pages: z.array(PageSchema).min(1),
|
|
61
|
+
auth: AuthSchema.default({ method: "none" }),
|
|
62
|
+
// Open record — built-in keys have defaults, custom audit keys are allowed
|
|
63
|
+
audits: z.record(z.string(), z.boolean()).default({
|
|
64
|
+
httpStatus: true,
|
|
65
|
+
contentVisibility: true,
|
|
66
|
+
accessibility: true,
|
|
67
|
+
lighthouse: false,
|
|
68
|
+
brokenLinks: false,
|
|
69
|
+
consoleErrors: true
|
|
70
|
+
}),
|
|
71
|
+
// Custom audits defined inline in config
|
|
72
|
+
customAudits: z.array(z.any()).default([]),
|
|
73
|
+
// Plugins — objects or string paths to npm packages / local files
|
|
74
|
+
plugins: z.array(z.any()).default([]),
|
|
75
|
+
wcagTags: z.array(z.string()).default(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]),
|
|
76
|
+
lighthouseThresholds: LighthouseThresholdsSchema.default({}),
|
|
77
|
+
retry: z.object({
|
|
78
|
+
maxRetries: z.number().min(1).default(3),
|
|
79
|
+
delayMs: z.number().min(0).default(5e3)
|
|
80
|
+
}).default({}),
|
|
81
|
+
runner: z.object({
|
|
82
|
+
concurrency: z.number().min(1).default(1),
|
|
83
|
+
failFast: z.boolean().default(false)
|
|
84
|
+
}).default({}),
|
|
85
|
+
browser: z.object({
|
|
86
|
+
headless: z.boolean().default(true),
|
|
87
|
+
timeout: z.number().default(6e4),
|
|
88
|
+
viewport: z.object({
|
|
89
|
+
width: z.number().default(1280),
|
|
90
|
+
height: z.number().default(720)
|
|
91
|
+
}).default({})
|
|
92
|
+
}).default({}),
|
|
93
|
+
output: z.object({
|
|
94
|
+
dir: z.string().default("./webguard-results"),
|
|
95
|
+
formats: z.array(z.enum(["terminal", "html", "json", "junit"])).default(["terminal", "html", "json"]),
|
|
96
|
+
screenshots: z.boolean().default(true),
|
|
97
|
+
screenshotOnFailOnly: z.boolean().default(false)
|
|
98
|
+
}).default({}),
|
|
99
|
+
baseline: z.object({
|
|
100
|
+
enabled: z.boolean().default(false),
|
|
101
|
+
updateOnPass: z.boolean().default(true)
|
|
102
|
+
}).default({}),
|
|
103
|
+
notifications: z.array(z.any()).default([])
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// src/config/defaults.ts
|
|
107
|
+
function defineConfig(config) {
|
|
108
|
+
return WebguardConfigSchema.parse(config);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/config/loader.ts
|
|
112
|
+
import path from "path";
|
|
113
|
+
import fs from "fs";
|
|
114
|
+
|
|
115
|
+
// src/utils/logger.ts
|
|
116
|
+
import chalk from "chalk";
|
|
117
|
+
var log = {
|
|
118
|
+
info(msg) {
|
|
119
|
+
console.log(chalk.blue("i"), msg);
|
|
120
|
+
},
|
|
121
|
+
success(msg) {
|
|
122
|
+
console.log(chalk.green("\u2713"), msg);
|
|
123
|
+
},
|
|
124
|
+
warn(msg) {
|
|
125
|
+
console.log(chalk.yellow("\u26A0"), msg);
|
|
126
|
+
},
|
|
127
|
+
error(msg) {
|
|
128
|
+
console.log(chalk.red("\u2717"), msg);
|
|
129
|
+
},
|
|
130
|
+
dim(msg) {
|
|
131
|
+
console.log(chalk.dim(msg));
|
|
132
|
+
},
|
|
133
|
+
plain(msg) {
|
|
134
|
+
console.log(msg);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// src/config/loader.ts
|
|
139
|
+
var CONFIG_NAMES = [
|
|
140
|
+
"webguard.config.ts",
|
|
141
|
+
"webguard.config.js",
|
|
142
|
+
"webguard.config.mjs",
|
|
143
|
+
"webguard.config.json"
|
|
144
|
+
];
|
|
145
|
+
async function loadConfig(configPath) {
|
|
146
|
+
const resolvedPath = configPath ? path.resolve(configPath) : findConfigFile();
|
|
147
|
+
if (!resolvedPath) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`No webguard config found. Run "webguardx init" to create one, or specify --config <path>.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
log.info(`Loading config from ${path.relative(process.cwd(), resolvedPath)}`);
|
|
153
|
+
let rawConfig;
|
|
154
|
+
if (resolvedPath.endsWith(".json")) {
|
|
155
|
+
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
156
|
+
rawConfig = JSON.parse(content);
|
|
157
|
+
} else {
|
|
158
|
+
const { createJiti } = await import("jiti");
|
|
159
|
+
const jiti = createJiti(import.meta.url);
|
|
160
|
+
const mod = await jiti.import(resolvedPath);
|
|
161
|
+
rawConfig = mod.default ?? mod;
|
|
162
|
+
}
|
|
163
|
+
const result = WebguardConfigSchema.safeParse(rawConfig);
|
|
164
|
+
if (!result.success) {
|
|
165
|
+
const errors = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
166
|
+
throw new Error(`Invalid webguard config:
|
|
167
|
+
${errors}`);
|
|
168
|
+
}
|
|
169
|
+
return result.data;
|
|
170
|
+
}
|
|
171
|
+
function findConfigFile() {
|
|
172
|
+
const cwd = process.cwd();
|
|
173
|
+
for (const name of CONFIG_NAMES) {
|
|
174
|
+
const fullPath = path.join(cwd, name);
|
|
175
|
+
if (fs.existsSync(fullPath)) {
|
|
176
|
+
return fullPath;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/runner/index.ts
|
|
183
|
+
import { chromium as chromium2 } from "playwright";
|
|
184
|
+
|
|
185
|
+
// src/auth/strategies/api-login.ts
|
|
186
|
+
import { request } from "playwright";
|
|
187
|
+
import path3 from "path";
|
|
188
|
+
|
|
189
|
+
// src/utils/fs.ts
|
|
190
|
+
import fs2 from "fs";
|
|
191
|
+
import path2 from "path";
|
|
192
|
+
function ensureDir(dir) {
|
|
193
|
+
if (!fs2.existsSync(dir)) {
|
|
194
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function cleanDir(dir) {
|
|
198
|
+
if (fs2.existsSync(dir)) {
|
|
199
|
+
fs2.rmSync(dir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function writeJson(filePath, data) {
|
|
203
|
+
ensureDir(path2.dirname(filePath));
|
|
204
|
+
fs2.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/auth/strategies/api-login.ts
|
|
208
|
+
async function apiLogin(config, outputDir) {
|
|
209
|
+
const authDir = path3.join(outputDir, ".auth");
|
|
210
|
+
ensureDir(authDir);
|
|
211
|
+
const storagePath = path3.join(authDir, "storageState.json");
|
|
212
|
+
const origin = new URL(config.loginUrl).origin;
|
|
213
|
+
const context = await request.newContext({ baseURL: origin });
|
|
214
|
+
const response = await context.post(config.loginUrl, {
|
|
215
|
+
data: config.payload,
|
|
216
|
+
headers: {
|
|
217
|
+
"content-type": "application/json",
|
|
218
|
+
accept: "application/json",
|
|
219
|
+
...config.headers
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
if (!response.ok()) {
|
|
223
|
+
const body = await response.text();
|
|
224
|
+
await context.dispose();
|
|
225
|
+
throw new Error(`Login failed (HTTP ${response.status()}): ${body}`);
|
|
226
|
+
}
|
|
227
|
+
await context.storageState({ path: storagePath });
|
|
228
|
+
await context.dispose();
|
|
229
|
+
return { storageStatePath: storagePath };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/auth/strategies/form-login.ts
|
|
233
|
+
import { chromium } from "playwright";
|
|
234
|
+
import path4 from "path";
|
|
235
|
+
async function formLogin(config, outputDir) {
|
|
236
|
+
const authDir = path4.join(outputDir, ".auth");
|
|
237
|
+
ensureDir(authDir);
|
|
238
|
+
const storagePath = path4.join(authDir, "storageState.json");
|
|
239
|
+
const browser = await chromium.launch({ headless: true });
|
|
240
|
+
const context = await browser.newContext();
|
|
241
|
+
const page = await context.newPage();
|
|
242
|
+
await page.goto(config.loginUrl);
|
|
243
|
+
for (const field of config.fields) {
|
|
244
|
+
await page.fill(field.selector, field.value);
|
|
245
|
+
}
|
|
246
|
+
await page.click(config.submitSelector);
|
|
247
|
+
if (config.waitAfterLogin) {
|
|
248
|
+
if (config.waitAfterLogin.startsWith("http")) {
|
|
249
|
+
await page.waitForURL(config.waitAfterLogin);
|
|
250
|
+
} else {
|
|
251
|
+
await page.waitForSelector(config.waitAfterLogin);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
await page.waitForLoadState("networkidle");
|
|
255
|
+
}
|
|
256
|
+
await context.storageState({ path: storagePath });
|
|
257
|
+
await browser.close();
|
|
258
|
+
return { storageStatePath: storagePath };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/auth/strategies/cookie-inject.ts
|
|
262
|
+
import fs3 from "fs";
|
|
263
|
+
import path5 from "path";
|
|
264
|
+
async function cookieInject(config, outputDir) {
|
|
265
|
+
const authDir = path5.join(outputDir, ".auth");
|
|
266
|
+
ensureDir(authDir);
|
|
267
|
+
const storagePath = path5.join(authDir, "storageState.json");
|
|
268
|
+
const storageState = {
|
|
269
|
+
cookies: config.cookies.map((c) => ({
|
|
270
|
+
name: c.name,
|
|
271
|
+
value: c.value,
|
|
272
|
+
domain: c.domain,
|
|
273
|
+
path: c.path || "/",
|
|
274
|
+
expires: -1,
|
|
275
|
+
httpOnly: false,
|
|
276
|
+
secure: true,
|
|
277
|
+
sameSite: "Lax"
|
|
278
|
+
})),
|
|
279
|
+
origins: []
|
|
280
|
+
};
|
|
281
|
+
fs3.writeFileSync(storagePath, JSON.stringify(storageState, null, 2));
|
|
282
|
+
return { storageStatePath: storagePath };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/auth/strategies/bearer-token.ts
|
|
286
|
+
import fs4 from "fs";
|
|
287
|
+
import path6 from "path";
|
|
288
|
+
async function bearerToken(config, outputDir) {
|
|
289
|
+
const authDir = path6.join(outputDir, ".auth");
|
|
290
|
+
ensureDir(authDir);
|
|
291
|
+
const storagePath = path6.join(authDir, "storageState.json");
|
|
292
|
+
fs4.writeFileSync(
|
|
293
|
+
storagePath,
|
|
294
|
+
JSON.stringify({ cookies: [], origins: [] })
|
|
295
|
+
);
|
|
296
|
+
return {
|
|
297
|
+
storageStatePath: storagePath,
|
|
298
|
+
extraHeaders: { Authorization: `Bearer ${config.token}` }
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/auth/index.ts
|
|
303
|
+
async function authenticate(authConfig, outputDir) {
|
|
304
|
+
if (authConfig.method === "none") {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
log.info(`Authenticating via ${authConfig.method}...`);
|
|
308
|
+
let result;
|
|
309
|
+
switch (authConfig.method) {
|
|
310
|
+
case "api-login":
|
|
311
|
+
result = await apiLogin(authConfig, outputDir);
|
|
312
|
+
break;
|
|
313
|
+
case "form-login":
|
|
314
|
+
result = await formLogin(authConfig, outputDir);
|
|
315
|
+
break;
|
|
316
|
+
case "cookie":
|
|
317
|
+
result = await cookieInject(authConfig, outputDir);
|
|
318
|
+
break;
|
|
319
|
+
case "bearer-token":
|
|
320
|
+
result = await bearerToken(authConfig, outputDir);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
log.success("Authenticated successfully");
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/audits/http-status.ts
|
|
328
|
+
var HttpStatusAudit = {
|
|
329
|
+
name: "httpStatus",
|
|
330
|
+
description: "Verify the page returns the expected HTTP status code",
|
|
331
|
+
async run(ctx) {
|
|
332
|
+
const status = ctx.navigationResponse?.status() ?? 0;
|
|
333
|
+
const expected = ctx.pageEntry.expectedStatus ?? 200;
|
|
334
|
+
return {
|
|
335
|
+
audit: this.name,
|
|
336
|
+
page: ctx.pageEntry.name,
|
|
337
|
+
passed: status === expected,
|
|
338
|
+
severity: status === expected ? "pass" : "fail",
|
|
339
|
+
message: status === expected ? `HTTP ${status} OK` : `Expected HTTP ${expected}, got ${status}`,
|
|
340
|
+
details: { status, expected }
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/audits/content-visibility.ts
|
|
346
|
+
var ContentVisibilityAudit = {
|
|
347
|
+
name: "contentVisibility",
|
|
348
|
+
description: "Verify the page has visible rendered content",
|
|
349
|
+
async run(ctx) {
|
|
350
|
+
const { page } = ctx;
|
|
351
|
+
const bodyVisible = await page.locator("body").isVisible().catch(() => false);
|
|
352
|
+
if (!bodyVisible) {
|
|
353
|
+
return {
|
|
354
|
+
audit: this.name,
|
|
355
|
+
page: ctx.pageEntry.name,
|
|
356
|
+
passed: false,
|
|
357
|
+
severity: "fail",
|
|
358
|
+
message: "Page body is not visible",
|
|
359
|
+
details: { bodyVisible: false, textLength: 0, elementCount: 0 }
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const bodyText = await page.locator("body").innerText().catch(() => "");
|
|
363
|
+
const textLength = bodyText.trim().length;
|
|
364
|
+
const elementCount = await page.locator(
|
|
365
|
+
"body h1, body h2, body p, body main, body div, body section, body nav, body header"
|
|
366
|
+
).count();
|
|
367
|
+
const passed = textLength > 0 && elementCount > 0;
|
|
368
|
+
return {
|
|
369
|
+
audit: this.name,
|
|
370
|
+
page: ctx.pageEntry.name,
|
|
371
|
+
passed,
|
|
372
|
+
severity: passed ? "pass" : "fail",
|
|
373
|
+
message: passed ? `${elementCount} elements visible` : `Content check failed (text: ${textLength} chars, elements: ${elementCount})`,
|
|
374
|
+
details: { bodyVisible: true, textLength, elementCount }
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/audits/accessibility.ts
|
|
380
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
381
|
+
import path7 from "path";
|
|
382
|
+
|
|
383
|
+
// src/utils/sanitize.ts
|
|
384
|
+
function sanitize(name) {
|
|
385
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/audits/accessibility.ts
|
|
389
|
+
var AccessibilityAudit = {
|
|
390
|
+
name: "accessibility",
|
|
391
|
+
description: "WCAG accessibility audit via axe-core",
|
|
392
|
+
async run(ctx) {
|
|
393
|
+
const results = await new AxeBuilder({ page: ctx.page }).withTags(ctx.config.wcagTags).analyze();
|
|
394
|
+
const violations = results.violations;
|
|
395
|
+
if (violations.length > 0) {
|
|
396
|
+
const summary = violations.map((v) => ({
|
|
397
|
+
rule: v.id,
|
|
398
|
+
impact: v.impact,
|
|
399
|
+
description: v.description,
|
|
400
|
+
helpUrl: v.helpUrl,
|
|
401
|
+
elements: v.nodes.length
|
|
402
|
+
}));
|
|
403
|
+
const pageDir = path7.join(
|
|
404
|
+
ctx.runDir,
|
|
405
|
+
"screenshots",
|
|
406
|
+
sanitize(ctx.pageEntry.name)
|
|
407
|
+
);
|
|
408
|
+
writeJson(path7.join(pageDir, "a11y-violations.json"), summary);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
audit: this.name,
|
|
412
|
+
page: ctx.pageEntry.name,
|
|
413
|
+
passed: violations.length === 0,
|
|
414
|
+
severity: violations.length === 0 ? "pass" : "warning",
|
|
415
|
+
message: violations.length === 0 ? "0 violations" : `${violations.length} violation(s)`,
|
|
416
|
+
details: {
|
|
417
|
+
violationCount: violations.length,
|
|
418
|
+
violations: violations.map((v) => ({
|
|
419
|
+
rule: v.id,
|
|
420
|
+
impact: v.impact,
|
|
421
|
+
description: v.description,
|
|
422
|
+
elements: v.nodes.length
|
|
423
|
+
}))
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// src/audits/lighthouse.ts
|
|
430
|
+
import path8 from "path";
|
|
431
|
+
var LighthouseAudit = {
|
|
432
|
+
name: "lighthouse",
|
|
433
|
+
description: "Lighthouse performance, accessibility, best practices, and SEO audit",
|
|
434
|
+
async run(ctx) {
|
|
435
|
+
let lighthouse;
|
|
436
|
+
let chromeLauncher;
|
|
437
|
+
try {
|
|
438
|
+
lighthouse = await import("lighthouse");
|
|
439
|
+
chromeLauncher = await import("chrome-launcher");
|
|
440
|
+
} catch {
|
|
441
|
+
return {
|
|
442
|
+
audit: this.name,
|
|
443
|
+
page: ctx.pageEntry.name,
|
|
444
|
+
passed: false,
|
|
445
|
+
severity: "skip",
|
|
446
|
+
message: "Lighthouse not installed. Run: npm install lighthouse chrome-launcher"
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const chrome = await chromeLauncher.launch({
|
|
450
|
+
chromeFlags: ["--headless", "--no-sandbox"]
|
|
451
|
+
});
|
|
452
|
+
const url = `${ctx.config.baseURL}${ctx.pageEntry.path}`;
|
|
453
|
+
const thresholds = ctx.config.lighthouseThresholds;
|
|
454
|
+
try {
|
|
455
|
+
const result = await lighthouse.default(url, {
|
|
456
|
+
port: chrome.port,
|
|
457
|
+
output: "json",
|
|
458
|
+
logLevel: "error",
|
|
459
|
+
onlyCategories: [
|
|
460
|
+
"performance",
|
|
461
|
+
"accessibility",
|
|
462
|
+
"best-practices",
|
|
463
|
+
"seo"
|
|
464
|
+
]
|
|
465
|
+
});
|
|
466
|
+
await chrome.kill();
|
|
467
|
+
if (!result?.lhr) {
|
|
468
|
+
return {
|
|
469
|
+
audit: this.name,
|
|
470
|
+
page: ctx.pageEntry.name,
|
|
471
|
+
passed: false,
|
|
472
|
+
severity: "fail",
|
|
473
|
+
message: "Lighthouse failed to produce results"
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const scores = {
|
|
477
|
+
performance: Math.round(
|
|
478
|
+
(result.lhr.categories.performance?.score ?? 0) * 100
|
|
479
|
+
),
|
|
480
|
+
accessibility: Math.round(
|
|
481
|
+
(result.lhr.categories.accessibility?.score ?? 0) * 100
|
|
482
|
+
),
|
|
483
|
+
bestPractices: Math.round(
|
|
484
|
+
(result.lhr.categories["best-practices"]?.score ?? 0) * 100
|
|
485
|
+
),
|
|
486
|
+
seo: Math.round((result.lhr.categories.seo?.score ?? 0) * 100)
|
|
487
|
+
};
|
|
488
|
+
const failures = [];
|
|
489
|
+
if (thresholds.performance && scores.performance < thresholds.performance)
|
|
490
|
+
failures.push(
|
|
491
|
+
`Performance: ${scores.performance} < ${thresholds.performance}`
|
|
492
|
+
);
|
|
493
|
+
if (thresholds.accessibility && scores.accessibility < thresholds.accessibility)
|
|
494
|
+
failures.push(
|
|
495
|
+
`Accessibility: ${scores.accessibility} < ${thresholds.accessibility}`
|
|
496
|
+
);
|
|
497
|
+
if (thresholds.bestPractices && scores.bestPractices < thresholds.bestPractices)
|
|
498
|
+
failures.push(
|
|
499
|
+
`Best Practices: ${scores.bestPractices} < ${thresholds.bestPractices}`
|
|
500
|
+
);
|
|
501
|
+
if (thresholds.seo && scores.seo < thresholds.seo)
|
|
502
|
+
failures.push(`SEO: ${scores.seo} < ${thresholds.seo}`);
|
|
503
|
+
const pageDir = path8.join(
|
|
504
|
+
ctx.runDir,
|
|
505
|
+
"screenshots",
|
|
506
|
+
sanitize(ctx.pageEntry.name)
|
|
507
|
+
);
|
|
508
|
+
ensureDir(pageDir);
|
|
509
|
+
writeJson(path8.join(pageDir, "lighthouse-report.json"), result.lhr);
|
|
510
|
+
return {
|
|
511
|
+
audit: this.name,
|
|
512
|
+
page: ctx.pageEntry.name,
|
|
513
|
+
passed: failures.length === 0,
|
|
514
|
+
severity: failures.length > 0 ? "fail" : "pass",
|
|
515
|
+
message: failures.length > 0 ? `Failed: ${failures.join("; ")}` : `perf=${scores.performance} a11y=${scores.accessibility} bp=${scores.bestPractices} seo=${scores.seo}`,
|
|
516
|
+
details: { scores, thresholds, failures }
|
|
517
|
+
};
|
|
518
|
+
} catch (err) {
|
|
519
|
+
await chrome.kill().catch(() => {
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
audit: this.name,
|
|
523
|
+
page: ctx.pageEntry.name,
|
|
524
|
+
passed: false,
|
|
525
|
+
severity: "fail",
|
|
526
|
+
message: `Lighthouse error: ${err.message}`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// src/audits/broken-links.ts
|
|
533
|
+
var BrokenLinksAudit = {
|
|
534
|
+
name: "brokenLinks",
|
|
535
|
+
description: "Find broken links (4xx/5xx) on the page",
|
|
536
|
+
async run(ctx) {
|
|
537
|
+
const links = await ctx.page.$$eval(
|
|
538
|
+
"a[href]",
|
|
539
|
+
(anchors) => anchors.map((a) => a.getAttribute("href")).filter((href) => !!href)
|
|
540
|
+
);
|
|
541
|
+
const baseOrigin = new URL(ctx.config.baseURL).origin;
|
|
542
|
+
const uniqueLinks = [
|
|
543
|
+
...new Set(
|
|
544
|
+
links.map((href) => {
|
|
545
|
+
try {
|
|
546
|
+
return new URL(href, ctx.config.baseURL).href;
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}).filter(
|
|
551
|
+
(url) => !!url && (url.startsWith("http://") || url.startsWith("https://"))
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
];
|
|
555
|
+
const broken = [];
|
|
556
|
+
for (const url of uniqueLinks) {
|
|
557
|
+
try {
|
|
558
|
+
const response = await ctx.page.request.head(url, { timeout: 1e4 });
|
|
559
|
+
if (response.status() >= 400) {
|
|
560
|
+
broken.push({ url, status: response.status() });
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
broken.push({ url, status: 0 });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
audit: this.name,
|
|
568
|
+
page: ctx.pageEntry.name,
|
|
569
|
+
passed: broken.length === 0,
|
|
570
|
+
severity: broken.length > 0 ? "warning" : "pass",
|
|
571
|
+
message: broken.length === 0 ? `All ${uniqueLinks.length} links valid` : `${broken.length} broken link(s) of ${uniqueLinks.length}`,
|
|
572
|
+
details: { totalLinks: uniqueLinks.length, broken }
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/audits/console-errors.ts
|
|
578
|
+
var ConsoleErrorsAudit = {
|
|
579
|
+
name: "consoleErrors",
|
|
580
|
+
description: "Capture browser console errors and warnings",
|
|
581
|
+
async run(ctx) {
|
|
582
|
+
const messages = ctx.consoleMessages;
|
|
583
|
+
const errors = messages.filter((m) => m.type === "error");
|
|
584
|
+
const warnings = messages.filter((m) => m.type === "warning");
|
|
585
|
+
return {
|
|
586
|
+
audit: this.name,
|
|
587
|
+
page: ctx.pageEntry.name,
|
|
588
|
+
passed: errors.length === 0,
|
|
589
|
+
severity: errors.length > 0 ? "fail" : warnings.length > 0 ? "warning" : "pass",
|
|
590
|
+
message: errors.length === 0 && warnings.length === 0 ? "0 errors" : `${errors.length} error(s), ${warnings.length} warning(s)`,
|
|
591
|
+
details: {
|
|
592
|
+
errors: errors.map((e) => e.text),
|
|
593
|
+
warnings: warnings.map((w) => w.text)
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/audits/index.ts
|
|
600
|
+
var BUILTIN_AUDITS = {
|
|
601
|
+
httpStatus: HttpStatusAudit,
|
|
602
|
+
contentVisibility: ContentVisibilityAudit,
|
|
603
|
+
accessibility: AccessibilityAudit,
|
|
604
|
+
lighthouse: LighthouseAudit,
|
|
605
|
+
brokenLinks: BrokenLinksAudit,
|
|
606
|
+
consoleErrors: ConsoleErrorsAudit
|
|
607
|
+
};
|
|
608
|
+
function getEnabledAudits(auditsConfig, pluginAudits = [], customAudits = []) {
|
|
609
|
+
const allAudits = { ...BUILTIN_AUDITS };
|
|
610
|
+
for (const audit of [...pluginAudits, ...customAudits]) {
|
|
611
|
+
allAudits[audit.name] = audit;
|
|
612
|
+
}
|
|
613
|
+
const enabled = [];
|
|
614
|
+
for (const [name, audit] of Object.entries(allAudits)) {
|
|
615
|
+
const isEnabled = auditsConfig[name] ?? true;
|
|
616
|
+
if (isEnabled) {
|
|
617
|
+
enabled.push(audit);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return enabled;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/runner/page-runner.ts
|
|
624
|
+
import path9 from "path";
|
|
625
|
+
|
|
626
|
+
// src/utils/sleep.ts
|
|
627
|
+
function sleep(ms) {
|
|
628
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/runner/retry.ts
|
|
632
|
+
async function navigateWithRetry(page, url, retry, waitUntil = "domcontentloaded") {
|
|
633
|
+
let lastError = null;
|
|
634
|
+
for (let attempt = 1; attempt <= retry.maxRetries; attempt++) {
|
|
635
|
+
try {
|
|
636
|
+
const response = await page.goto(url, {
|
|
637
|
+
waitUntil,
|
|
638
|
+
timeout: 3e4
|
|
639
|
+
});
|
|
640
|
+
if (response && response.ok()) {
|
|
641
|
+
return response;
|
|
642
|
+
}
|
|
643
|
+
if (attempt < retry.maxRetries) {
|
|
644
|
+
log.warn(
|
|
645
|
+
`Attempt ${attempt}/${retry.maxRetries} for ${url} returned ${response?.status()}. Retrying in ${retry.delayMs / 1e3}s...`
|
|
646
|
+
);
|
|
647
|
+
await sleep(retry.delayMs);
|
|
648
|
+
}
|
|
649
|
+
if (attempt === retry.maxRetries) return response;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
lastError = error;
|
|
652
|
+
if (attempt < retry.maxRetries) {
|
|
653
|
+
log.warn(
|
|
654
|
+
`Attempt ${attempt}/${retry.maxRetries} for ${url} failed. Retrying in ${retry.delayMs / 1e3}s...`
|
|
655
|
+
);
|
|
656
|
+
await sleep(retry.delayMs);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
throw lastError ?? new Error(`Failed to load ${url} after ${retry.maxRetries} attempts`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/runner/page-runner.ts
|
|
664
|
+
async function runPageAudits(pageEntry, config, browserContext, audits, runDir, registry) {
|
|
665
|
+
const start = Date.now();
|
|
666
|
+
const url = `${config.baseURL}${pageEntry.path}`;
|
|
667
|
+
const page = await browserContext.newPage();
|
|
668
|
+
if (registry) {
|
|
669
|
+
await registry.runHook("beforePage", { config, pageEntry, page });
|
|
670
|
+
}
|
|
671
|
+
const consoleMessages = [];
|
|
672
|
+
page.on("console", (msg) => {
|
|
673
|
+
consoleMessages.push({ type: msg.type(), text: msg.text() });
|
|
674
|
+
});
|
|
675
|
+
let navigationResponse = null;
|
|
676
|
+
try {
|
|
677
|
+
navigationResponse = await navigateWithRetry(
|
|
678
|
+
page,
|
|
679
|
+
url,
|
|
680
|
+
config.retry,
|
|
681
|
+
"domcontentloaded"
|
|
682
|
+
);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
log.error(`Failed to navigate to ${url}: ${err.message}`);
|
|
685
|
+
}
|
|
686
|
+
const ctx = {
|
|
687
|
+
page,
|
|
688
|
+
browserContext,
|
|
689
|
+
pageEntry,
|
|
690
|
+
config,
|
|
691
|
+
runDir,
|
|
692
|
+
navigationResponse,
|
|
693
|
+
consoleMessages
|
|
694
|
+
};
|
|
695
|
+
const pageAudits = audits.filter(
|
|
696
|
+
(a) => !pageEntry.skipAudits?.includes(a.name)
|
|
697
|
+
);
|
|
698
|
+
const auditResults = [];
|
|
699
|
+
for (const audit of pageAudits) {
|
|
700
|
+
if (registry) {
|
|
701
|
+
await registry.runHook("beforeAudit", { audit, auditContext: ctx });
|
|
702
|
+
}
|
|
703
|
+
const auditStart = Date.now();
|
|
704
|
+
let result;
|
|
705
|
+
try {
|
|
706
|
+
result = await audit.run(ctx);
|
|
707
|
+
result.duration = Date.now() - auditStart;
|
|
708
|
+
} catch (err) {
|
|
709
|
+
result = {
|
|
710
|
+
audit: audit.name,
|
|
711
|
+
page: pageEntry.name,
|
|
712
|
+
passed: false,
|
|
713
|
+
severity: "fail",
|
|
714
|
+
message: `Audit error: ${err.message}`,
|
|
715
|
+
duration: Date.now() - auditStart
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
auditResults.push(result);
|
|
719
|
+
if (registry) {
|
|
720
|
+
await registry.runHook("afterAudit", { audit, result });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
let screenshotPath;
|
|
724
|
+
if (config.output.screenshots) {
|
|
725
|
+
const anyFailed = auditResults.some((r) => !r.passed);
|
|
726
|
+
if (!config.output.screenshotOnFailOnly || anyFailed) {
|
|
727
|
+
try {
|
|
728
|
+
const pageDir = path9.join(runDir, "screenshots", sanitize(pageEntry.name));
|
|
729
|
+
ensureDir(pageDir);
|
|
730
|
+
const status = anyFailed ? "fail" : "pass";
|
|
731
|
+
screenshotPath = path9.join(pageDir, `page-${status}.png`);
|
|
732
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const pageResult = {
|
|
738
|
+
page: pageEntry.name,
|
|
739
|
+
path: pageEntry.path,
|
|
740
|
+
url,
|
|
741
|
+
audits: auditResults,
|
|
742
|
+
screenshotPath,
|
|
743
|
+
duration: Date.now() - start
|
|
744
|
+
};
|
|
745
|
+
if (registry) {
|
|
746
|
+
await registry.runHook("afterPage", { config, pageEntry, pageResult });
|
|
747
|
+
}
|
|
748
|
+
await page.close();
|
|
749
|
+
return pageResult;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/runner/parallel.ts
|
|
753
|
+
async function runPagesParallel(pages, config, browserContext, audits, runDir, registry, concurrency, failFast) {
|
|
754
|
+
const results = new Array(pages.length);
|
|
755
|
+
let currentIndex = 0;
|
|
756
|
+
let aborted = false;
|
|
757
|
+
async function worker() {
|
|
758
|
+
while (!aborted) {
|
|
759
|
+
const index = currentIndex++;
|
|
760
|
+
if (index >= pages.length) break;
|
|
761
|
+
const pageEntry = pages[index];
|
|
762
|
+
log.plain(` [${index + 1}/${pages.length}] ${pageEntry.name} (${pageEntry.path})`);
|
|
763
|
+
const result = await runPageAudits(
|
|
764
|
+
pageEntry,
|
|
765
|
+
config,
|
|
766
|
+
browserContext,
|
|
767
|
+
audits,
|
|
768
|
+
runDir,
|
|
769
|
+
registry
|
|
770
|
+
);
|
|
771
|
+
results[index] = result;
|
|
772
|
+
for (const audit of result.audits) {
|
|
773
|
+
const icon = audit.passed ? " \u2713" : audit.severity === "warning" ? " \u26A0" : " \u2717";
|
|
774
|
+
const duration = audit.duration ? `${audit.duration}ms` : "";
|
|
775
|
+
log.plain(
|
|
776
|
+
`${icon} ${audit.audit.padEnd(20)} ${audit.message.padEnd(30)} ${duration}`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
log.plain("");
|
|
780
|
+
if (failFast && result.audits.some((a) => a.severity === "fail")) {
|
|
781
|
+
log.warn("Fail fast enabled \u2014 stopping after first failure");
|
|
782
|
+
aborted = true;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const workerCount = Math.min(concurrency, pages.length);
|
|
787
|
+
const workers = Array.from({ length: workerCount }, () => worker());
|
|
788
|
+
await Promise.all(workers);
|
|
789
|
+
return results.filter(Boolean);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/runner/setup.ts
|
|
793
|
+
import path10 from "path";
|
|
794
|
+
function setupRunDirectory(outputBaseDir) {
|
|
795
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
796
|
+
const runDir = path10.join(outputBaseDir, `run-${timestamp}`);
|
|
797
|
+
const screenshotsDir = path10.join(runDir, "screenshots");
|
|
798
|
+
cleanDir(outputBaseDir);
|
|
799
|
+
ensureDir(screenshotsDir);
|
|
800
|
+
return { runDir, screenshotsDir };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/reporters/json.ts
|
|
804
|
+
import path11 from "path";
|
|
805
|
+
function reportJson(result, runDir) {
|
|
806
|
+
const filePath = path11.join(runDir, "results.json");
|
|
807
|
+
writeJson(filePath, result);
|
|
808
|
+
log.dim(` JSON report: ${filePath}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/reporters/terminal.ts
|
|
812
|
+
import chalk2 from "chalk";
|
|
813
|
+
function reportTerminal(result) {
|
|
814
|
+
const { summary } = result;
|
|
815
|
+
console.log("");
|
|
816
|
+
console.log(chalk2.bold("\u2500".repeat(60)));
|
|
817
|
+
console.log(chalk2.bold(" Summary"));
|
|
818
|
+
console.log(chalk2.bold("\u2500".repeat(60)));
|
|
819
|
+
console.log("");
|
|
820
|
+
console.log(` Total audits: ${summary.totalAudits}`);
|
|
821
|
+
console.log(
|
|
822
|
+
` ${chalk2.green("\u2713 Passed:")} ${summary.passed}`
|
|
823
|
+
);
|
|
824
|
+
if (summary.failed > 0) {
|
|
825
|
+
console.log(
|
|
826
|
+
` ${chalk2.red("\u2717 Failed:")} ${summary.failed}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
if (summary.warnings > 0) {
|
|
830
|
+
console.log(
|
|
831
|
+
` ${chalk2.yellow("\u26A0 Warnings:")} ${summary.warnings}`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (summary.skipped > 0) {
|
|
835
|
+
console.log(
|
|
836
|
+
` ${chalk2.dim("\u25CB Skipped:")} ${summary.skipped}`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
console.log(
|
|
840
|
+
` Duration: ${(summary.duration / 1e3).toFixed(1)}s`
|
|
841
|
+
);
|
|
842
|
+
console.log("");
|
|
843
|
+
const failedAudits = result.pages.flatMap(
|
|
844
|
+
(p) => p.audits.filter((a) => a.severity === "fail")
|
|
845
|
+
);
|
|
846
|
+
if (failedAudits.length > 0) {
|
|
847
|
+
console.log(chalk2.red.bold(" Failed Audits:"));
|
|
848
|
+
for (const audit of failedAudits) {
|
|
849
|
+
console.log(
|
|
850
|
+
chalk2.red(` \u2717 ${audit.page} \u2192 ${audit.audit}: ${audit.message}`)
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
console.log("");
|
|
854
|
+
}
|
|
855
|
+
if (summary.failed > 0) {
|
|
856
|
+
console.log(
|
|
857
|
+
chalk2.red.bold(` Result: FAIL (${summary.failed} failure(s))`)
|
|
858
|
+
);
|
|
859
|
+
} else if (summary.warnings > 0) {
|
|
860
|
+
console.log(
|
|
861
|
+
chalk2.yellow.bold(` Result: PASS with ${summary.warnings} warning(s)`)
|
|
862
|
+
);
|
|
863
|
+
} else {
|
|
864
|
+
console.log(chalk2.green.bold(" Result: PASS"));
|
|
865
|
+
}
|
|
866
|
+
console.log("");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/reporters/html.ts
|
|
870
|
+
import fs5 from "fs";
|
|
871
|
+
import path12 from "path";
|
|
872
|
+
function escapeHtml(str) {
|
|
873
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
874
|
+
}
|
|
875
|
+
function severityIcon(severity) {
|
|
876
|
+
switch (severity) {
|
|
877
|
+
case "pass":
|
|
878
|
+
return "✓";
|
|
879
|
+
case "fail":
|
|
880
|
+
return "✗";
|
|
881
|
+
case "warning":
|
|
882
|
+
return "⚠";
|
|
883
|
+
case "skip":
|
|
884
|
+
return "○";
|
|
885
|
+
default:
|
|
886
|
+
return "?";
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
function severityClass(severity) {
|
|
890
|
+
return `severity-${severity}`;
|
|
891
|
+
}
|
|
892
|
+
function renderAuditRow(audit) {
|
|
893
|
+
return `
|
|
894
|
+
<tr class="${severityClass(audit.severity)}">
|
|
895
|
+
<td class="icon">${severityIcon(audit.severity)}</td>
|
|
896
|
+
<td>${escapeHtml(audit.audit)}</td>
|
|
897
|
+
<td>${escapeHtml(audit.message)}</td>
|
|
898
|
+
<td class="duration">${audit.duration ? `${audit.duration}ms` : "\u2014"}</td>
|
|
899
|
+
</tr>`;
|
|
900
|
+
}
|
|
901
|
+
function renderPageSection(page) {
|
|
902
|
+
const failCount = page.audits.filter((a) => a.severity === "fail").length;
|
|
903
|
+
const status = failCount > 0 ? "fail" : "pass";
|
|
904
|
+
return `
|
|
905
|
+
<div class="page-section">
|
|
906
|
+
<h2 class="page-header ${status}">
|
|
907
|
+
<span class="page-name">${escapeHtml(page.page)}</span>
|
|
908
|
+
<span class="page-path">${escapeHtml(page.path)}</span>
|
|
909
|
+
<span class="page-duration">${(page.duration / 1e3).toFixed(1)}s</span>
|
|
910
|
+
</h2>
|
|
911
|
+
<table class="audit-table">
|
|
912
|
+
<thead>
|
|
913
|
+
<tr>
|
|
914
|
+
<th class="icon-col"></th>
|
|
915
|
+
<th>Audit</th>
|
|
916
|
+
<th>Result</th>
|
|
917
|
+
<th>Duration</th>
|
|
918
|
+
</tr>
|
|
919
|
+
</thead>
|
|
920
|
+
<tbody>
|
|
921
|
+
${page.audits.map(renderAuditRow).join("")}
|
|
922
|
+
</tbody>
|
|
923
|
+
</table>
|
|
924
|
+
</div>`;
|
|
925
|
+
}
|
|
926
|
+
function buildHtml(result) {
|
|
927
|
+
const { summary } = result;
|
|
928
|
+
const overallStatus = summary.failed > 0 ? "FAIL" : summary.warnings > 0 ? "PASS (with warnings)" : "PASS";
|
|
929
|
+
const statusClass = summary.failed > 0 ? "fail" : summary.warnings > 0 ? "warning" : "pass";
|
|
930
|
+
return `<!DOCTYPE html>
|
|
931
|
+
<html lang="en">
|
|
932
|
+
<head>
|
|
933
|
+
<meta charset="UTF-8">
|
|
934
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
935
|
+
<title>Webguardx Report \u2014 ${escapeHtml(result.config.baseURL)}</title>
|
|
936
|
+
<style>
|
|
937
|
+
:root {
|
|
938
|
+
--pass: #22c55e;
|
|
939
|
+
--fail: #ef4444;
|
|
940
|
+
--warning: #f59e0b;
|
|
941
|
+
--skip: #94a3b8;
|
|
942
|
+
--bg: #0f172a;
|
|
943
|
+
--surface: #1e293b;
|
|
944
|
+
--text: #e2e8f0;
|
|
945
|
+
--text-dim: #94a3b8;
|
|
946
|
+
--border: #334155;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
950
|
+
|
|
951
|
+
body {
|
|
952
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
953
|
+
background: var(--bg);
|
|
954
|
+
color: var(--text);
|
|
955
|
+
line-height: 1.6;
|
|
956
|
+
padding: 2rem;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.container { max-width: 900px; margin: 0 auto; }
|
|
960
|
+
|
|
961
|
+
header {
|
|
962
|
+
text-align: center;
|
|
963
|
+
margin-bottom: 2rem;
|
|
964
|
+
padding-bottom: 1.5rem;
|
|
965
|
+
border-bottom: 1px solid var(--border);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
header h1 {
|
|
969
|
+
font-size: 1.75rem;
|
|
970
|
+
font-weight: 700;
|
|
971
|
+
margin-bottom: 0.5rem;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
header .meta {
|
|
975
|
+
color: var(--text-dim);
|
|
976
|
+
font-size: 0.875rem;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.summary-grid {
|
|
980
|
+
display: grid;
|
|
981
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
982
|
+
gap: 1rem;
|
|
983
|
+
margin-bottom: 2rem;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
.summary-card {
|
|
987
|
+
background: var(--surface);
|
|
988
|
+
border-radius: 8px;
|
|
989
|
+
padding: 1rem;
|
|
990
|
+
text-align: center;
|
|
991
|
+
border: 1px solid var(--border);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.summary-card .value {
|
|
995
|
+
font-size: 2rem;
|
|
996
|
+
font-weight: 700;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.summary-card .label {
|
|
1000
|
+
font-size: 0.75rem;
|
|
1001
|
+
color: var(--text-dim);
|
|
1002
|
+
text-transform: uppercase;
|
|
1003
|
+
letter-spacing: 0.05em;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.summary-card.pass .value { color: var(--pass); }
|
|
1007
|
+
.summary-card.fail .value { color: var(--fail); }
|
|
1008
|
+
.summary-card.warning .value { color: var(--warning); }
|
|
1009
|
+
.summary-card.skip .value { color: var(--skip); }
|
|
1010
|
+
|
|
1011
|
+
.overall-status {
|
|
1012
|
+
text-align: center;
|
|
1013
|
+
font-size: 1.25rem;
|
|
1014
|
+
font-weight: 700;
|
|
1015
|
+
padding: 0.75rem;
|
|
1016
|
+
border-radius: 8px;
|
|
1017
|
+
margin-bottom: 2rem;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.overall-status.pass { background: rgba(34,197,94,0.15); color: var(--pass); }
|
|
1021
|
+
.overall-status.fail { background: rgba(239,68,68,0.15); color: var(--fail); }
|
|
1022
|
+
.overall-status.warning { background: rgba(245,158,11,0.15); color: var(--warning); }
|
|
1023
|
+
|
|
1024
|
+
.page-section {
|
|
1025
|
+
background: var(--surface);
|
|
1026
|
+
border-radius: 8px;
|
|
1027
|
+
margin-bottom: 1.5rem;
|
|
1028
|
+
border: 1px solid var(--border);
|
|
1029
|
+
overflow: hidden;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.page-header {
|
|
1033
|
+
padding: 1rem 1.25rem;
|
|
1034
|
+
display: flex;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
gap: 0.75rem;
|
|
1037
|
+
border-bottom: 1px solid var(--border);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.page-header.fail { border-left: 4px solid var(--fail); }
|
|
1041
|
+
.page-header.pass { border-left: 4px solid var(--pass); }
|
|
1042
|
+
|
|
1043
|
+
.page-name { font-weight: 600; }
|
|
1044
|
+
.page-path { color: var(--text-dim); font-size: 0.875rem; }
|
|
1045
|
+
.page-duration { margin-left: auto; color: var(--text-dim); font-size: 0.875rem; }
|
|
1046
|
+
|
|
1047
|
+
.audit-table {
|
|
1048
|
+
width: 100%;
|
|
1049
|
+
border-collapse: collapse;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.audit-table th {
|
|
1053
|
+
text-align: left;
|
|
1054
|
+
padding: 0.5rem 1rem;
|
|
1055
|
+
font-size: 0.75rem;
|
|
1056
|
+
text-transform: uppercase;
|
|
1057
|
+
color: var(--text-dim);
|
|
1058
|
+
border-bottom: 1px solid var(--border);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.audit-table td {
|
|
1062
|
+
padding: 0.6rem 1rem;
|
|
1063
|
+
border-bottom: 1px solid var(--border);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.audit-table tr:last-child td { border-bottom: none; }
|
|
1067
|
+
|
|
1068
|
+
.icon-col { width: 40px; }
|
|
1069
|
+
.icon { text-align: center; font-size: 1.1rem; }
|
|
1070
|
+
.duration { color: var(--text-dim); font-size: 0.875rem; }
|
|
1071
|
+
|
|
1072
|
+
.severity-pass .icon { color: var(--pass); }
|
|
1073
|
+
.severity-fail .icon { color: var(--fail); }
|
|
1074
|
+
.severity-warning .icon { color: var(--warning); }
|
|
1075
|
+
.severity-skip .icon { color: var(--skip); }
|
|
1076
|
+
|
|
1077
|
+
footer {
|
|
1078
|
+
text-align: center;
|
|
1079
|
+
margin-top: 2rem;
|
|
1080
|
+
padding-top: 1rem;
|
|
1081
|
+
border-top: 1px solid var(--border);
|
|
1082
|
+
color: var(--text-dim);
|
|
1083
|
+
font-size: 0.75rem;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
footer a { color: var(--text-dim); }
|
|
1087
|
+
</style>
|
|
1088
|
+
</head>
|
|
1089
|
+
<body>
|
|
1090
|
+
<div class="container">
|
|
1091
|
+
<header>
|
|
1092
|
+
<h1>Webguardx Report</h1>
|
|
1093
|
+
<div class="meta">
|
|
1094
|
+
${escapeHtml(result.config.baseURL)} •
|
|
1095
|
+
${escapeHtml(result.timestamp)} •
|
|
1096
|
+
${result.config.auditsEnabled.join(", ")}
|
|
1097
|
+
</div>
|
|
1098
|
+
</header>
|
|
1099
|
+
|
|
1100
|
+
<div class="overall-status ${statusClass}">${overallStatus}</div>
|
|
1101
|
+
|
|
1102
|
+
<div class="summary-grid">
|
|
1103
|
+
<div class="summary-card">
|
|
1104
|
+
<div class="value">${summary.totalAudits}</div>
|
|
1105
|
+
<div class="label">Total</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
<div class="summary-card pass">
|
|
1108
|
+
<div class="value">${summary.passed}</div>
|
|
1109
|
+
<div class="label">Passed</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
<div class="summary-card fail">
|
|
1112
|
+
<div class="value">${summary.failed}</div>
|
|
1113
|
+
<div class="label">Failed</div>
|
|
1114
|
+
</div>
|
|
1115
|
+
<div class="summary-card warning">
|
|
1116
|
+
<div class="value">${summary.warnings}</div>
|
|
1117
|
+
<div class="label">Warnings</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
<div class="summary-card skip">
|
|
1120
|
+
<div class="value">${summary.skipped}</div>
|
|
1121
|
+
<div class="label">Skipped</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div class="summary-card">
|
|
1124
|
+
<div class="value">${(summary.duration / 1e3).toFixed(1)}s</div>
|
|
1125
|
+
<div class="label">Duration</div>
|
|
1126
|
+
</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
|
|
1129
|
+
${result.pages.map(renderPageSection).join("")}
|
|
1130
|
+
|
|
1131
|
+
<footer>
|
|
1132
|
+
Generated by <a href="https://github.com/user/webguardx">webguardx</a>
|
|
1133
|
+
</footer>
|
|
1134
|
+
</div>
|
|
1135
|
+
</body>
|
|
1136
|
+
</html>`;
|
|
1137
|
+
}
|
|
1138
|
+
function reportHtml(result, runDir) {
|
|
1139
|
+
const filePath = path12.join(runDir, "report.html");
|
|
1140
|
+
ensureDir(runDir);
|
|
1141
|
+
fs5.writeFileSync(filePath, buildHtml(result), "utf-8");
|
|
1142
|
+
log.dim(` HTML report: ${filePath}`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/reporters/junit.ts
|
|
1146
|
+
import fs6 from "fs";
|
|
1147
|
+
import path13 from "path";
|
|
1148
|
+
function escapeXml(str) {
|
|
1149
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1150
|
+
}
|
|
1151
|
+
function buildTestCase(audit) {
|
|
1152
|
+
const name = escapeXml(`${audit.page} - ${audit.audit}`);
|
|
1153
|
+
const time = audit.duration ? (audit.duration / 1e3).toFixed(3) : "0.000";
|
|
1154
|
+
if (audit.severity === "pass") {
|
|
1155
|
+
return ` <testcase name="${name}" classname="${escapeXml(audit.page)}" time="${time}" />
|
|
1156
|
+
`;
|
|
1157
|
+
}
|
|
1158
|
+
if (audit.severity === "skip") {
|
|
1159
|
+
return ` <testcase name="${name}" classname="${escapeXml(audit.page)}" time="${time}">
|
|
1160
|
+
<skipped message="${escapeXml(audit.message)}" />
|
|
1161
|
+
</testcase>
|
|
1162
|
+
`;
|
|
1163
|
+
}
|
|
1164
|
+
const tag = audit.severity === "fail" ? "failure" : "failure";
|
|
1165
|
+
return ` <testcase name="${name}" classname="${escapeXml(audit.page)}" time="${time}">
|
|
1166
|
+
<${tag} message="${escapeXml(audit.message)}" type="${escapeXml(audit.severity)}">${escapeXml(audit.message)}</${tag}>
|
|
1167
|
+
</testcase>
|
|
1168
|
+
`;
|
|
1169
|
+
}
|
|
1170
|
+
function buildTestSuite(page, index) {
|
|
1171
|
+
const tests = page.audits.length;
|
|
1172
|
+
const failures = page.audits.filter((a) => a.severity === "fail").length;
|
|
1173
|
+
const skipped = page.audits.filter((a) => a.severity === "skip").length;
|
|
1174
|
+
const time = (page.duration / 1e3).toFixed(3);
|
|
1175
|
+
let xml = ` <testsuite name="${escapeXml(page.page)}" tests="${tests}" failures="${failures}" skipped="${skipped}" time="${time}" id="${index}">
|
|
1176
|
+
`;
|
|
1177
|
+
for (const audit of page.audits) {
|
|
1178
|
+
xml += buildTestCase(audit);
|
|
1179
|
+
}
|
|
1180
|
+
xml += ` </testsuite>
|
|
1181
|
+
`;
|
|
1182
|
+
return xml;
|
|
1183
|
+
}
|
|
1184
|
+
function reportJunit(result, runDir) {
|
|
1185
|
+
const { summary } = result;
|
|
1186
|
+
const time = (summary.duration / 1e3).toFixed(3);
|
|
1187
|
+
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1188
|
+
`;
|
|
1189
|
+
xml += `<testsuites name="webguardx" tests="${summary.totalAudits}" failures="${summary.failed}" skipped="${summary.skipped}" time="${time}">
|
|
1190
|
+
`;
|
|
1191
|
+
result.pages.forEach((page, i) => {
|
|
1192
|
+
xml += buildTestSuite(page, i);
|
|
1193
|
+
});
|
|
1194
|
+
xml += `</testsuites>
|
|
1195
|
+
`;
|
|
1196
|
+
const filePath = path13.join(runDir, "results.xml");
|
|
1197
|
+
ensureDir(runDir);
|
|
1198
|
+
fs6.writeFileSync(filePath, xml, "utf-8");
|
|
1199
|
+
log.dim(` JUnit report: ${filePath}`);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/reporters/screenshot.ts
|
|
1203
|
+
function reportScreenshots(result) {
|
|
1204
|
+
const captured = result.pages.filter((p) => p.screenshotPath);
|
|
1205
|
+
if (captured.length > 0) {
|
|
1206
|
+
log.dim(` Screenshots: ${captured.length} page(s) captured`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/reporters/index.ts
|
|
1211
|
+
async function runReporters(result, config, runDir, pluginReporters = []) {
|
|
1212
|
+
const formats = config.output.formats;
|
|
1213
|
+
if (formats.includes("terminal")) {
|
|
1214
|
+
reportTerminal(result);
|
|
1215
|
+
}
|
|
1216
|
+
log.dim(" Output:");
|
|
1217
|
+
if (formats.includes("json")) {
|
|
1218
|
+
reportJson(result, runDir);
|
|
1219
|
+
}
|
|
1220
|
+
if (formats.includes("html")) {
|
|
1221
|
+
reportHtml(result, runDir);
|
|
1222
|
+
}
|
|
1223
|
+
if (formats.includes("junit")) {
|
|
1224
|
+
reportJunit(result, runDir);
|
|
1225
|
+
}
|
|
1226
|
+
if (config.output.screenshots) {
|
|
1227
|
+
reportScreenshots(result);
|
|
1228
|
+
}
|
|
1229
|
+
for (const reporter of pluginReporters) {
|
|
1230
|
+
try {
|
|
1231
|
+
await reporter.run(result, runDir, config);
|
|
1232
|
+
log.dim(` ${reporter.name}: done`);
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
log.warn(`Reporter '${reporter.name}' failed: ${err.message}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
log.plain("");
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/plugins/registry.ts
|
|
1241
|
+
var PluginRegistry = class {
|
|
1242
|
+
plugins = [];
|
|
1243
|
+
register(plugin) {
|
|
1244
|
+
this.plugins.push(plugin);
|
|
1245
|
+
}
|
|
1246
|
+
getPluginAudits() {
|
|
1247
|
+
return this.plugins.flatMap((p) => p.audits ?? []);
|
|
1248
|
+
}
|
|
1249
|
+
getPluginReporters() {
|
|
1250
|
+
return this.plugins.flatMap((p) => p.reporters ?? []);
|
|
1251
|
+
}
|
|
1252
|
+
async runHook(hookName, ctx) {
|
|
1253
|
+
for (const plugin of this.plugins) {
|
|
1254
|
+
const hook = plugin.hooks?.[hookName];
|
|
1255
|
+
if (hook) {
|
|
1256
|
+
try {
|
|
1257
|
+
await hook(ctx);
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
log.warn(
|
|
1260
|
+
`Plugin '${plugin.name}' hook '${hookName}' failed: ${err.message}`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
// src/plugins/loader.ts
|
|
1269
|
+
async function loadPlugins(pluginEntries) {
|
|
1270
|
+
const plugins = [];
|
|
1271
|
+
for (const entry of pluginEntries) {
|
|
1272
|
+
if (typeof entry === "string") {
|
|
1273
|
+
try {
|
|
1274
|
+
const { createJiti } = await import("jiti");
|
|
1275
|
+
const jiti = createJiti(import.meta.url);
|
|
1276
|
+
const mod = await jiti.import(entry);
|
|
1277
|
+
const plugin = mod.default ?? mod;
|
|
1278
|
+
plugins.push(plugin);
|
|
1279
|
+
log.dim(` Loaded plugin: ${plugin.name ?? entry}`);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
log.warn(`Failed to load plugin '${entry}': ${err.message}`);
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
plugins.push(entry);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return plugins;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// src/runner/index.ts
|
|
1291
|
+
async function run(config, options = {}) {
|
|
1292
|
+
const start = Date.now();
|
|
1293
|
+
if (options.headed !== void 0) {
|
|
1294
|
+
config.browser.headless = !options.headed;
|
|
1295
|
+
}
|
|
1296
|
+
const registry = new PluginRegistry();
|
|
1297
|
+
if (config.plugins.length > 0) {
|
|
1298
|
+
const plugins = await loadPlugins(config.plugins);
|
|
1299
|
+
for (const plugin of plugins) {
|
|
1300
|
+
registry.register(plugin);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const { runDir } = setupRunDirectory(config.output.dir);
|
|
1304
|
+
const authResult = await authenticate(config.auth, runDir);
|
|
1305
|
+
const browser = await chromium2.launch({
|
|
1306
|
+
headless: config.browser.headless
|
|
1307
|
+
});
|
|
1308
|
+
const contextOptions = {
|
|
1309
|
+
viewport: config.browser.viewport
|
|
1310
|
+
};
|
|
1311
|
+
if (authResult?.storageStatePath) {
|
|
1312
|
+
contextOptions.storageState = authResult.storageStatePath;
|
|
1313
|
+
}
|
|
1314
|
+
if (authResult?.extraHeaders) {
|
|
1315
|
+
contextOptions.extraHTTPHeaders = authResult.extraHeaders;
|
|
1316
|
+
}
|
|
1317
|
+
const browserContext = await browser.newContext(contextOptions);
|
|
1318
|
+
await registry.runHook("beforeAll", { config, browserContext });
|
|
1319
|
+
let audits = getEnabledAudits(
|
|
1320
|
+
config.audits,
|
|
1321
|
+
registry.getPluginAudits(),
|
|
1322
|
+
config.customAudits
|
|
1323
|
+
);
|
|
1324
|
+
if (options.auditsFilter?.length) {
|
|
1325
|
+
audits = audits.filter((a) => options.auditsFilter.includes(a.name));
|
|
1326
|
+
}
|
|
1327
|
+
const enabledAuditNames = audits.map((a) => a.name);
|
|
1328
|
+
let pages = config.pages;
|
|
1329
|
+
if (options.pagesFilter?.length) {
|
|
1330
|
+
pages = pages.filter((p) => options.pagesFilter.includes(p.name));
|
|
1331
|
+
}
|
|
1332
|
+
log.plain("");
|
|
1333
|
+
log.info(`Base URL: ${config.baseURL}`);
|
|
1334
|
+
log.info(`Pages: ${pages.length}`);
|
|
1335
|
+
log.info(`Audits: ${enabledAuditNames.join(", ")}`);
|
|
1336
|
+
if (config.plugins.length > 0) {
|
|
1337
|
+
log.info(`Plugins: ${config.plugins.length}`);
|
|
1338
|
+
}
|
|
1339
|
+
if (config.runner.concurrency > 1) {
|
|
1340
|
+
log.info(`Workers: ${config.runner.concurrency}`);
|
|
1341
|
+
}
|
|
1342
|
+
log.plain("");
|
|
1343
|
+
let pageResults;
|
|
1344
|
+
if (config.runner.concurrency > 1) {
|
|
1345
|
+
pageResults = await runPagesParallel(
|
|
1346
|
+
pages,
|
|
1347
|
+
config,
|
|
1348
|
+
browserContext,
|
|
1349
|
+
audits,
|
|
1350
|
+
runDir,
|
|
1351
|
+
registry,
|
|
1352
|
+
config.runner.concurrency,
|
|
1353
|
+
config.runner.failFast
|
|
1354
|
+
);
|
|
1355
|
+
} else {
|
|
1356
|
+
pageResults = [];
|
|
1357
|
+
for (let i = 0; i < pages.length; i++) {
|
|
1358
|
+
const pageEntry = pages[i];
|
|
1359
|
+
log.plain(` [${i + 1}/${pages.length}] ${pageEntry.name} (${pageEntry.path})`);
|
|
1360
|
+
const result = await runPageAudits(
|
|
1361
|
+
pageEntry,
|
|
1362
|
+
config,
|
|
1363
|
+
browserContext,
|
|
1364
|
+
audits,
|
|
1365
|
+
runDir,
|
|
1366
|
+
registry
|
|
1367
|
+
);
|
|
1368
|
+
pageResults.push(result);
|
|
1369
|
+
for (const audit of result.audits) {
|
|
1370
|
+
const icon = audit.passed ? " \u2713" : audit.severity === "warning" ? " \u26A0" : " \u2717";
|
|
1371
|
+
const duration = audit.duration ? `${audit.duration}ms` : "";
|
|
1372
|
+
log.plain(`${icon} ${audit.audit.padEnd(20)} ${audit.message.padEnd(30)} ${duration}`);
|
|
1373
|
+
}
|
|
1374
|
+
log.plain("");
|
|
1375
|
+
if (config.runner.failFast && result.audits.some((a) => a.severity === "fail")) {
|
|
1376
|
+
log.warn("Fail fast enabled \u2014 stopping after first failure");
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
await browserContext.close();
|
|
1382
|
+
await browser.close();
|
|
1383
|
+
const allAudits = pageResults.flatMap((p) => p.audits);
|
|
1384
|
+
const runResult = {
|
|
1385
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1386
|
+
config: {
|
|
1387
|
+
baseURL: config.baseURL,
|
|
1388
|
+
totalPages: pages.length,
|
|
1389
|
+
auditsEnabled: enabledAuditNames
|
|
1390
|
+
},
|
|
1391
|
+
pages: pageResults,
|
|
1392
|
+
summary: {
|
|
1393
|
+
totalAudits: allAudits.length,
|
|
1394
|
+
passed: allAudits.filter((a) => a.severity === "pass").length,
|
|
1395
|
+
failed: allAudits.filter((a) => a.severity === "fail").length,
|
|
1396
|
+
warnings: allAudits.filter((a) => a.severity === "warning").length,
|
|
1397
|
+
skipped: allAudits.filter((a) => a.severity === "skip").length,
|
|
1398
|
+
duration: Date.now() - start
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
await registry.runHook("afterAll", { config, result: runResult });
|
|
1402
|
+
await runReporters(runResult, config, runDir, registry.getPluginReporters());
|
|
1403
|
+
return runResult;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/notifications/webhook.ts
|
|
1407
|
+
function createWebhookNotifier(url, options = {}) {
|
|
1408
|
+
return {
|
|
1409
|
+
name: `webhook:${new URL(url).hostname}`,
|
|
1410
|
+
async send(result, config) {
|
|
1411
|
+
if (options.onlyOnFailure && result.summary.failed === 0) return;
|
|
1412
|
+
await fetch(url, {
|
|
1413
|
+
method: options.method ?? "POST",
|
|
1414
|
+
headers: {
|
|
1415
|
+
"Content-Type": "application/json",
|
|
1416
|
+
...options.headers
|
|
1417
|
+
},
|
|
1418
|
+
body: JSON.stringify({
|
|
1419
|
+
tool: "webguardx",
|
|
1420
|
+
status: result.summary.failed > 0 ? "fail" : "pass",
|
|
1421
|
+
baseURL: config.baseURL,
|
|
1422
|
+
timestamp: result.timestamp,
|
|
1423
|
+
summary: result.summary
|
|
1424
|
+
})
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/notifications/slack.ts
|
|
1431
|
+
function createSlackNotifier(webhookUrl, options = {}) {
|
|
1432
|
+
return {
|
|
1433
|
+
name: "slack",
|
|
1434
|
+
async send(result, config) {
|
|
1435
|
+
if (options.onlyOnFailure && result.summary.failed === 0) return;
|
|
1436
|
+
const color = result.summary.failed > 0 ? "#ef4444" : "#22c55e";
|
|
1437
|
+
const status = result.summary.failed > 0 ? "FAIL" : "PASS";
|
|
1438
|
+
const { summary } = result;
|
|
1439
|
+
await fetch(webhookUrl, {
|
|
1440
|
+
method: "POST",
|
|
1441
|
+
headers: { "Content-Type": "application/json" },
|
|
1442
|
+
body: JSON.stringify({
|
|
1443
|
+
channel: options.channel,
|
|
1444
|
+
attachments: [
|
|
1445
|
+
{
|
|
1446
|
+
color,
|
|
1447
|
+
title: `Webguardx: ${status}`,
|
|
1448
|
+
title_link: config.baseURL,
|
|
1449
|
+
text: `${summary.passed} passed, ${summary.failed} failed, ${summary.warnings} warnings`,
|
|
1450
|
+
fields: [
|
|
1451
|
+
{ title: "Base URL", value: config.baseURL, short: true },
|
|
1452
|
+
{ title: "Pages", value: `${config.pages.length}`, short: true },
|
|
1453
|
+
{ title: "Duration", value: `${(summary.duration / 1e3).toFixed(1)}s`, short: true }
|
|
1454
|
+
],
|
|
1455
|
+
footer: "webguardx",
|
|
1456
|
+
ts: Math.floor(Date.now() / 1e3)
|
|
1457
|
+
}
|
|
1458
|
+
]
|
|
1459
|
+
})
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/notifications/index.ts
|
|
1466
|
+
async function sendNotifications(channels, result, config) {
|
|
1467
|
+
for (const channel of channels) {
|
|
1468
|
+
try {
|
|
1469
|
+
await channel.send(result, config);
|
|
1470
|
+
log.dim(` Notification sent: ${channel.name}`);
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
log.warn(
|
|
1473
|
+
`Notification '${channel.name}' failed: ${err.message}`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// src/baseline/storage.ts
|
|
1480
|
+
import path14 from "path";
|
|
1481
|
+
import fs7 from "fs";
|
|
1482
|
+
var BASELINE_FILENAME = "baseline.json";
|
|
1483
|
+
function saveBaseline(result, outputDir) {
|
|
1484
|
+
const baselinePath = path14.join(outputDir, BASELINE_FILENAME);
|
|
1485
|
+
fs7.writeFileSync(baselinePath, JSON.stringify(result, null, 2));
|
|
1486
|
+
log.success(`Baseline saved: ${baselinePath}`);
|
|
1487
|
+
}
|
|
1488
|
+
function loadBaseline(outputDir) {
|
|
1489
|
+
const baselinePath = path14.join(outputDir, BASELINE_FILENAME);
|
|
1490
|
+
if (!fs7.existsSync(baselinePath)) return null;
|
|
1491
|
+
try {
|
|
1492
|
+
return JSON.parse(fs7.readFileSync(baselinePath, "utf-8"));
|
|
1493
|
+
} catch {
|
|
1494
|
+
log.warn("Failed to parse baseline file");
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/baseline/diff.ts
|
|
1500
|
+
function compareRuns(baseline, current) {
|
|
1501
|
+
const changes = [];
|
|
1502
|
+
const baselineMap = /* @__PURE__ */ new Map();
|
|
1503
|
+
for (const page of baseline.pages) {
|
|
1504
|
+
for (const audit of page.audits) {
|
|
1505
|
+
baselineMap.set(`${page.page}::${audit.audit}`, {
|
|
1506
|
+
severity: audit.severity,
|
|
1507
|
+
message: audit.message
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const currentMap = /* @__PURE__ */ new Map();
|
|
1512
|
+
for (const page of current.pages) {
|
|
1513
|
+
for (const audit of page.audits) {
|
|
1514
|
+
const key = `${page.page}::${audit.audit}`;
|
|
1515
|
+
const entry = { severity: audit.severity, message: audit.message };
|
|
1516
|
+
currentMap.set(key, entry);
|
|
1517
|
+
const prev = baselineMap.get(key);
|
|
1518
|
+
if (!prev) {
|
|
1519
|
+
changes.push({
|
|
1520
|
+
page: page.page,
|
|
1521
|
+
audit: audit.audit,
|
|
1522
|
+
type: "new",
|
|
1523
|
+
current: entry
|
|
1524
|
+
});
|
|
1525
|
+
} else if (prev.severity !== audit.severity) {
|
|
1526
|
+
const type = audit.severity === "pass" || audit.severity === "skip" ? "improvement" : "regression";
|
|
1527
|
+
changes.push({
|
|
1528
|
+
page: page.page,
|
|
1529
|
+
audit: audit.audit,
|
|
1530
|
+
type,
|
|
1531
|
+
baseline: prev,
|
|
1532
|
+
current: entry
|
|
1533
|
+
});
|
|
1534
|
+
} else {
|
|
1535
|
+
changes.push({
|
|
1536
|
+
page: page.page,
|
|
1537
|
+
audit: audit.audit,
|
|
1538
|
+
type: "unchanged",
|
|
1539
|
+
baseline: prev,
|
|
1540
|
+
current: entry
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
for (const [key, val] of baselineMap) {
|
|
1546
|
+
if (!currentMap.has(key)) {
|
|
1547
|
+
const [page, audit] = key.split("::");
|
|
1548
|
+
changes.push({ page, audit, type: "removed", baseline: val });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return {
|
|
1552
|
+
baselineTimestamp: baseline.timestamp,
|
|
1553
|
+
currentTimestamp: current.timestamp,
|
|
1554
|
+
changes,
|
|
1555
|
+
summary: {
|
|
1556
|
+
regressions: changes.filter((c) => c.type === "regression").length,
|
|
1557
|
+
improvements: changes.filter((c) => c.type === "improvement").length,
|
|
1558
|
+
unchanged: changes.filter((c) => c.type === "unchanged").length,
|
|
1559
|
+
newAudits: changes.filter((c) => c.type === "new").length,
|
|
1560
|
+
removedAudits: changes.filter((c) => c.type === "removed").length
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
export {
|
|
1565
|
+
AccessibilityAudit,
|
|
1566
|
+
BrokenLinksAudit,
|
|
1567
|
+
ConsoleErrorsAudit,
|
|
1568
|
+
ContentVisibilityAudit,
|
|
1569
|
+
HttpStatusAudit,
|
|
1570
|
+
LighthouseAudit,
|
|
1571
|
+
PluginRegistry,
|
|
1572
|
+
compareRuns,
|
|
1573
|
+
createSlackNotifier,
|
|
1574
|
+
createWebhookNotifier,
|
|
1575
|
+
defineConfig,
|
|
1576
|
+
loadBaseline,
|
|
1577
|
+
loadConfig,
|
|
1578
|
+
loadPlugins,
|
|
1579
|
+
run,
|
|
1580
|
+
saveBaseline,
|
|
1581
|
+
sendNotifications
|
|
1582
|
+
};
|
|
1583
|
+
//# sourceMappingURL=index.js.map
|