@riverbankcms/qa 0.2.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.js ADDED
@@ -0,0 +1,2178 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/run.ts
4
+ import { CommanderError as CommanderError2 } from "commander";
5
+
6
+ // src/cli/program.ts
7
+ import { Command } from "commander";
8
+ import { readFileSync as readFileSync3 } from "fs";
9
+
10
+ // src/cli/qa-command.ts
11
+ import { CommanderError } from "commander";
12
+
13
+ // src/url/scope.ts
14
+ function compilePathPatterns(include, exclude) {
15
+ return {
16
+ include: include.map((p) => new RegExp(p)),
17
+ exclude: exclude.map((p) => new RegExp(p))
18
+ };
19
+ }
20
+ function isAllowedOrigin(target, allowedOrigins) {
21
+ return allowedOrigins.has(target.origin);
22
+ }
23
+ function isInScopePath(pathname, patterns) {
24
+ for (const rx of patterns.exclude) {
25
+ if (rx.test(pathname)) return false;
26
+ }
27
+ if (patterns.include.length === 0) return true;
28
+ for (const rx of patterns.include) {
29
+ if (rx.test(pathname)) return true;
30
+ }
31
+ return false;
32
+ }
33
+ function isNonHttpLink(href) {
34
+ const trimmed = href.trim();
35
+ if (trimmed === "" || trimmed === "#") return true;
36
+ const lower = trimmed.toLowerCase();
37
+ return lower.startsWith("mailto:") || lower.startsWith("tel:") || lower.startsWith("javascript:");
38
+ }
39
+
40
+ // src/extract/links.ts
41
+ function decodeHtmlEntities(text) {
42
+ return text.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&#39;", "'");
43
+ }
44
+ function normalizeWhitespace(text) {
45
+ return text.replace(/\s+/g, " ").trim();
46
+ }
47
+ function extractAnchorLinks(html) {
48
+ const links = [];
49
+ const anchorRe = /<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi;
50
+ let match;
51
+ while (match = anchorRe.exec(html)) {
52
+ const href = match[1] ?? match[2] ?? match[3] ?? "";
53
+ if (!href || isNonHttpLink(href)) continue;
54
+ const inner = match[4] ?? "";
55
+ const text = normalizeWhitespace(
56
+ decodeHtmlEntities(inner.replace(/<[^>]+>/g, ""))
57
+ );
58
+ links.push({
59
+ href,
60
+ anchorText: text || void 0
61
+ });
62
+ }
63
+ return links;
64
+ }
65
+
66
+ // src/http/fetch.ts
67
+ import { Agent } from "undici";
68
+ function createDispatcher(ignoreHttpsErrors) {
69
+ if (!ignoreHttpsErrors) return void 0;
70
+ return new Agent({
71
+ connect: {
72
+ rejectUnauthorized: false
73
+ }
74
+ });
75
+ }
76
+ function mergeHeaders(headers, userAgent) {
77
+ const h = new Headers();
78
+ for (const [k, v] of Object.entries(headers)) {
79
+ h.set(k, v);
80
+ }
81
+ if (userAgent) h.set("user-agent", userAgent);
82
+ return h;
83
+ }
84
+ async function fetchWithRedirects(url, opts) {
85
+ const dispatcher = createDispatcher(opts.ignoreHttpsErrors);
86
+ const headers = mergeHeaders(opts.headers, opts.userAgent);
87
+ const redirectChain = [];
88
+ let current = url;
89
+ for (let hop = 0; hop <= opts.maxRedirects; hop++) {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs);
92
+ try {
93
+ const init = {
94
+ method: "GET",
95
+ redirect: "manual",
96
+ headers,
97
+ signal: controller.signal
98
+ };
99
+ if (dispatcher) {
100
+ init.dispatcher = dispatcher;
101
+ }
102
+ const res = await fetch(current, init);
103
+ const status = res.status;
104
+ const contentType = res.headers.get("content-type") ?? void 0;
105
+ if (status >= 300 && status < 400) {
106
+ const location = res.headers.get("location") ?? void 0;
107
+ redirectChain.push({
108
+ url: current,
109
+ status,
110
+ ...location ? { location } : {}
111
+ });
112
+ if (!location) {
113
+ const base2 = {
114
+ url,
115
+ finalUrl: current,
116
+ status,
117
+ redirectChain
118
+ };
119
+ if (contentType) base2.contentType = contentType;
120
+ return base2;
121
+ }
122
+ if (hop === opts.maxRedirects) {
123
+ const base2 = {
124
+ url,
125
+ finalUrl: current,
126
+ status,
127
+ redirectChain,
128
+ error: `maxRedirects exceeded (${opts.maxRedirects})`
129
+ };
130
+ if (contentType) base2.contentType = contentType;
131
+ return base2;
132
+ }
133
+ const next = new URL(location, current).toString();
134
+ current = next;
135
+ continue;
136
+ }
137
+ const shouldReadBody = opts.readBody === true || contentType?.toLowerCase().includes("text/") === true || contentType?.toLowerCase().includes("application/xml") === true || contentType?.toLowerCase().includes("application/xhtml+xml") === true || contentType?.toLowerCase().includes("application/json") === true;
138
+ const bodyText = shouldReadBody ? await res.text() : void 0;
139
+ const base = {
140
+ url,
141
+ finalUrl: current,
142
+ status,
143
+ redirectChain
144
+ };
145
+ if (contentType) base.contentType = contentType;
146
+ if (bodyText !== void 0) base.bodyText = bodyText;
147
+ return base;
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ return {
151
+ url,
152
+ finalUrl: current,
153
+ redirectChain,
154
+ error: message
155
+ };
156
+ } finally {
157
+ clearTimeout(timeout);
158
+ }
159
+ }
160
+ return {
161
+ url,
162
+ finalUrl: current,
163
+ redirectChain,
164
+ error: `maxRedirects exceeded (${opts.maxRedirects})`
165
+ };
166
+ }
167
+
168
+ // src/url/normalize.ts
169
+ function isDefaultPort(scheme, port) {
170
+ if (!port) return false;
171
+ if (scheme === "http:" && port === "80") return true;
172
+ if (scheme === "https:" && port === "443") return true;
173
+ return false;
174
+ }
175
+ function shouldStripParam(paramName, cfg) {
176
+ const lower = paramName.toLowerCase();
177
+ for (const pattern of cfg.ignoredQueryParamPatterns) {
178
+ try {
179
+ if (new RegExp(pattern, "i").test(lower)) return true;
180
+ } catch {
181
+ }
182
+ }
183
+ if (cfg.dropQueryParamsExceptAllowlist) {
184
+ return !cfg.allowlistedQueryParams.includes(lower);
185
+ }
186
+ if (!cfg.stripTrackingParams) return false;
187
+ if (cfg.trackingParamNames.includes(lower)) return true;
188
+ for (const prefix of cfg.trackingParamPrefixes) {
189
+ if (lower.startsWith(prefix)) return true;
190
+ }
191
+ return false;
192
+ }
193
+ function normalizeUrl(input, cfg) {
194
+ const url = new URL(input);
195
+ url.hash = "";
196
+ url.protocol = url.protocol.toLowerCase();
197
+ url.hostname = url.hostname.toLowerCase();
198
+ if (isDefaultPort(url.protocol, url.port)) {
199
+ url.port = "";
200
+ }
201
+ if (cfg.normalizeTrailingSlash) {
202
+ if (url.pathname !== "/" && url.pathname.endsWith("/")) {
203
+ url.pathname = url.pathname.slice(0, -1);
204
+ }
205
+ }
206
+ const params = new URLSearchParams(url.search);
207
+ const kept = [];
208
+ for (const [key, value] of params.entries()) {
209
+ if (shouldStripParam(key, cfg)) continue;
210
+ kept.push([key, value]);
211
+ }
212
+ kept.sort((a, b) => {
213
+ if (a[0] < b[0]) return -1;
214
+ if (a[0] > b[0]) return 1;
215
+ if (a[1] < b[1]) return -1;
216
+ if (a[1] > b[1]) return 1;
217
+ return 0;
218
+ });
219
+ url.search = kept.length === 0 ? "" : new URLSearchParams(kept).toString();
220
+ const normalizedUrl = url.toString();
221
+ return { normalizedUrl, key: normalizedUrl };
222
+ }
223
+
224
+ // src/crawl/sitemap.ts
225
+ function extractLocs(xml) {
226
+ const locs = [];
227
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
228
+ let match;
229
+ while (match = re.exec(xml)) {
230
+ const raw = match[1]?.trim();
231
+ if (raw) locs.push(raw);
232
+ }
233
+ return locs;
234
+ }
235
+ function looksLikeSitemapIndex(xml) {
236
+ return /<sitemapindex\b/i.test(xml);
237
+ }
238
+ async function fetchSitemapUrls(sitemapUrl, cfg) {
239
+ const res = await fetchWithRedirects(sitemapUrl, {
240
+ timeoutMs: cfg.timeoutMs,
241
+ maxRedirects: cfg.maxRedirects,
242
+ headers: cfg.headers,
243
+ userAgent: cfg.userAgent,
244
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors,
245
+ readBody: true
246
+ });
247
+ if (!res.status || res.status >= 400) {
248
+ throw new Error(`HTTP ${res.status ?? "error"}`);
249
+ }
250
+ const xml = res.bodyText ?? "";
251
+ if (!xml) return [];
252
+ const locs = extractLocs(xml);
253
+ if (!looksLikeSitemapIndex(xml)) {
254
+ return locs;
255
+ }
256
+ const urls = [];
257
+ const maxChildSitemaps = 25;
258
+ for (const child of locs.slice(0, maxChildSitemaps)) {
259
+ const childRes = await fetchWithRedirects(child, {
260
+ timeoutMs: cfg.timeoutMs,
261
+ maxRedirects: cfg.maxRedirects,
262
+ headers: cfg.headers,
263
+ userAgent: cfg.userAgent,
264
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors,
265
+ readBody: true
266
+ });
267
+ if (!childRes.status || childRes.status >= 400) continue;
268
+ const childXml = childRes.bodyText ?? "";
269
+ for (const url of extractLocs(childXml)) urls.push(url);
270
+ }
271
+ return urls;
272
+ }
273
+
274
+ // src/crawl/seeds.ts
275
+ async function discoverSeeds(cfg) {
276
+ const warnings = [];
277
+ const base = new URL(cfg.baseUrl);
278
+ const seeds = [base.toString()];
279
+ if (cfg.sitemap) {
280
+ const sitemapUrl = new URL("/sitemap.xml", base.origin).toString();
281
+ try {
282
+ const urls = await fetchSitemapUrls(sitemapUrl, cfg);
283
+ for (const url of urls) seeds.push(url);
284
+ } catch (error) {
285
+ const msg = error instanceof Error ? error.message : String(error);
286
+ warnings.push(`Failed to fetch sitemap: ${sitemapUrl} (${msg})`);
287
+ }
288
+ }
289
+ return { seeds, warnings };
290
+ }
291
+
292
+ // src/extract/seo.ts
293
+ import * as cheerio from "cheerio";
294
+ function cleanText(value) {
295
+ return value.replace(/\s+/g, " ").trim();
296
+ }
297
+ function extractSeo(html) {
298
+ const $ = cheerio.load(html);
299
+ const rawTitle = $("head > title").first().text();
300
+ const title = cleanText(rawTitle);
301
+ const rawDescription = $('meta[name="description"]').attr("content") ?? "";
302
+ const description = cleanText(rawDescription);
303
+ const h1s = $("h1");
304
+ const h1Count = h1s.length;
305
+ const h1Text = h1Count > 0 ? cleanText(h1s.first().text()) : "";
306
+ const canonical = cleanText($('link[rel="canonical"]').attr("href") ?? "");
307
+ const robots = cleanText($('meta[name="robots"]').attr("content") ?? "");
308
+ const lang = cleanText($("html").attr("lang") ?? "");
309
+ const out = { h1Count };
310
+ if (title) out.title = title;
311
+ if (description) out.description = description;
312
+ if (h1Text) out.h1Text = h1Text;
313
+ if (canonical) out.canonical = canonical;
314
+ if (robots) out.robots = robots;
315
+ if (lang) out.lang = lang;
316
+ return out;
317
+ }
318
+
319
+ // src/report/constants.ts
320
+ var REPORT_SCHEMA_VERSION = "1.0.0";
321
+
322
+ // src/crawl/url-policy.ts
323
+ function pathKey(u) {
324
+ return `${u.origin}${u.pathname}`;
325
+ }
326
+ function isLimitedPath(cfg, u) {
327
+ return cfg.crawl.limitedPathPrefixes.some((p) => u.pathname.startsWith(p));
328
+ }
329
+ function getPaginationValue(cfg, u) {
330
+ for (const name of cfg.crawl.paginationParamNames) {
331
+ const v = u.searchParams.get(name);
332
+ if (!v) continue;
333
+ const n = Number(v);
334
+ if (Number.isFinite(n)) return n;
335
+ }
336
+ return void 0;
337
+ }
338
+ function hasOnlyAllowedQueryParams(allowed, u) {
339
+ for (const key of u.searchParams.keys()) {
340
+ if (!allowed.has(key.toLowerCase())) return false;
341
+ }
342
+ return true;
343
+ }
344
+ function createUrlPolicyState() {
345
+ return {
346
+ variantsByPath: /* @__PURE__ */ new Map(),
347
+ warnedPaths: /* @__PURE__ */ new Set()
348
+ };
349
+ }
350
+ function shouldEnqueueUrl(cfg, state, normalizedUrl) {
351
+ const u = new URL(normalizedUrl);
352
+ const paginationValue = getPaginationValue(cfg, u);
353
+ if (paginationValue !== void 0 && paginationValue > cfg.crawl.paginationLimit) {
354
+ return { allow: false, reason: "pagination_limit" };
355
+ }
356
+ if (isLimitedPath(cfg, u) && u.search) {
357
+ const allowed = new Set(cfg.crawl.paginationParamNames.map((n) => n.toLowerCase()));
358
+ if (!hasOnlyAllowedQueryParams(allowed, u)) {
359
+ return { allow: false, reason: "limited_path" };
360
+ }
361
+ }
362
+ const key = pathKey(u);
363
+ const variant = u.search;
364
+ const set = state.variantsByPath.get(key) ?? /* @__PURE__ */ new Set();
365
+ if (!set.has(variant) && set.size >= cfg.crawl.perPathQueryVariantLimit) {
366
+ return { allow: false, reason: "query_variant_limit" };
367
+ }
368
+ set.add(variant);
369
+ state.variantsByPath.set(key, set);
370
+ return { allow: true };
371
+ }
372
+
373
+ // src/http/host-delay.ts
374
+ var HostDelayLimiter = class {
375
+ delayMs;
376
+ tailByOrigin = /* @__PURE__ */ new Map();
377
+ lastByOrigin = /* @__PURE__ */ new Map();
378
+ constructor(delayMs) {
379
+ this.delayMs = Math.max(0, Math.floor(delayMs));
380
+ }
381
+ async waitFor(url) {
382
+ if (this.delayMs <= 0) return;
383
+ const origin = new URL(url).origin;
384
+ const prev = this.tailByOrigin.get(origin) ?? Promise.resolve();
385
+ const next = prev.then(async () => {
386
+ const now = Date.now();
387
+ const last = this.lastByOrigin.get(origin) ?? 0;
388
+ const remaining = this.delayMs - (now - last);
389
+ if (remaining > 0) {
390
+ await new Promise((resolve) => setTimeout(resolve, remaining));
391
+ }
392
+ this.lastByOrigin.set(origin, Date.now());
393
+ });
394
+ this.tailByOrigin.set(
395
+ origin,
396
+ next.catch(() => {
397
+ })
398
+ );
399
+ await next;
400
+ }
401
+ };
402
+
403
+ // src/crawl/robots.ts
404
+ function normalizeUserAgent(ua) {
405
+ return ua.trim().toLowerCase();
406
+ }
407
+ function parseRobotsTxt(text) {
408
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean);
409
+ const groups = [];
410
+ let current = null;
411
+ for (const line of lines) {
412
+ const idx = line.indexOf(":");
413
+ if (idx <= 0) continue;
414
+ const key = line.slice(0, idx).trim().toLowerCase();
415
+ const value = line.slice(idx + 1).trim();
416
+ if (key === "user-agent") {
417
+ if (!current || current.allow.length + current.disallow.length > 0) {
418
+ current = { userAgents: [], allow: [], disallow: [] };
419
+ groups.push(current);
420
+ }
421
+ current.userAgents.push(normalizeUserAgent(value));
422
+ continue;
423
+ }
424
+ if (!current) continue;
425
+ if (key === "allow") current.allow.push(value);
426
+ if (key === "disallow") current.disallow.push(value);
427
+ }
428
+ return groups;
429
+ }
430
+ function longestMatch(pathname, rules) {
431
+ let best = -1;
432
+ for (const rule of rules) {
433
+ if (rule === "") continue;
434
+ if (rule === "/") return Math.max(best, 1);
435
+ if (pathname.startsWith(rule)) best = Math.max(best, rule.length);
436
+ }
437
+ return best;
438
+ }
439
+ function pickGroups(groups, ua) {
440
+ const uaNorm = normalizeUserAgent(ua);
441
+ const specific = groups.filter((g) => g.userAgents.some((a) => a !== "*" && uaNorm.includes(a)));
442
+ if (specific.length) return specific;
443
+ const star = groups.filter((g) => g.userAgents.includes("*"));
444
+ return star;
445
+ }
446
+ var RobotsPolicy = class {
447
+ constructor(cfg) {
448
+ this.cfg = cfg;
449
+ }
450
+ cache = /* @__PURE__ */ new Map();
451
+ async canFetch(url) {
452
+ if (!this.cfg.respectRobots) return { allowed: true };
453
+ const u = new URL(url);
454
+ const origin = u.origin;
455
+ const cached = this.cache.get(origin);
456
+ let parsed = cached;
457
+ if (!parsed) {
458
+ const robotsUrl = new URL("/robots.txt", origin).toString();
459
+ const res = await fetchWithRedirects(robotsUrl, {
460
+ timeoutMs: this.cfg.timeoutMs,
461
+ maxRedirects: this.cfg.maxRedirects,
462
+ headers: this.cfg.headers,
463
+ userAgent: this.cfg.userAgent,
464
+ ignoreHttpsErrors: this.cfg.ignoreHttpsErrors,
465
+ readBody: true
466
+ });
467
+ if (!res.status || res.status >= 400) {
468
+ parsed = "missing";
469
+ } else {
470
+ try {
471
+ const txt = res.bodyText ?? "";
472
+ parsed = txt ? parseRobotsTxt(txt) : "missing";
473
+ } catch {
474
+ parsed = "error";
475
+ }
476
+ }
477
+ this.cache.set(origin, parsed);
478
+ }
479
+ if (parsed === "missing" || parsed === "error") return { allowed: true };
480
+ const groups = pickGroups(parsed, this.cfg.userAgent ?? "qa");
481
+ if (groups.length === 0) return { allowed: true };
482
+ const pathname = u.pathname || "/";
483
+ const allowLen = Math.max(...groups.map((g) => longestMatch(pathname, g.allow)), -1);
484
+ const disallowLen = Math.max(...groups.map((g) => longestMatch(pathname, g.disallow)), -1);
485
+ if (disallowLen > allowLen) return { allowed: false, reason: "disallowed" };
486
+ return { allowed: true };
487
+ }
488
+ };
489
+
490
+ // src/crawl/crawler.ts
491
+ function addReferrer(state, targetKey, ref) {
492
+ const list = state.referrersByKey.get(targetKey) ?? [];
493
+ if (list.some((r) => r.url === ref.url && r.anchorText === ref.anchorText)) {
494
+ return;
495
+ }
496
+ list.push(ref);
497
+ state.referrersByKey.set(targetKey, list);
498
+ }
499
+ function uniqByKey(items, keyFn) {
500
+ const seen = /* @__PURE__ */ new Set();
501
+ const out = [];
502
+ for (const item of items) {
503
+ const key = keyFn(item);
504
+ if (seen.has(key)) continue;
505
+ seen.add(key);
506
+ out.push(item);
507
+ }
508
+ return out;
509
+ }
510
+ async function runWithConcurrency(concurrency, tasks) {
511
+ const results = new Array(tasks.length);
512
+ let index = 0;
513
+ async function worker() {
514
+ while (true) {
515
+ const current = index;
516
+ index += 1;
517
+ if (current >= tasks.length) return;
518
+ const task = tasks[current];
519
+ if (!task) return;
520
+ results[current] = await task();
521
+ }
522
+ }
523
+ const workers = Array.from({ length: Math.max(1, concurrency) }, () => worker());
524
+ await Promise.all(workers);
525
+ return results;
526
+ }
527
+ async function crawlSite(cfg) {
528
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
529
+ const runWarnings = [];
530
+ const capsReached = {
531
+ maxPages: false,
532
+ maxDepth: false,
533
+ maxRedirects: false,
534
+ queryVariantLimit: false,
535
+ paginationLimit: false,
536
+ externalLinksLimit: false
537
+ };
538
+ const allowedOrigins = /* @__PURE__ */ new Set([new URL(cfg.baseUrl).origin]);
539
+ const patterns = compilePathPatterns(cfg.include, cfg.exclude);
540
+ const state = {
541
+ pagesByKey: /* @__PURE__ */ new Map(),
542
+ referrersByKey: /* @__PURE__ */ new Map()
543
+ };
544
+ const urlPolicyState = createUrlPolicyState();
545
+ const hostDelay = new HostDelayLimiter(cfg.crawl.requestDelayMs);
546
+ const robots = new RobotsPolicy(cfg);
547
+ const seedResult = await discoverSeeds(cfg);
548
+ runWarnings.push(...seedResult.warnings);
549
+ const normalizedSeeds = uniqByKey(
550
+ seedResult.seeds.filter((u) => {
551
+ try {
552
+ const parsed = new URL(u);
553
+ if (!isAllowedOrigin(parsed, allowedOrigins)) return false;
554
+ return isInScopePath(parsed.pathname, patterns);
555
+ } catch {
556
+ return false;
557
+ }
558
+ }).map((u) => normalizeUrl(u, cfg.url)).filter((n) => {
559
+ const decision = shouldEnqueueUrl(cfg, urlPolicyState, n.normalizedUrl);
560
+ if (decision.allow) return true;
561
+ if (decision.reason === "pagination_limit") capsReached.paginationLimit = true;
562
+ if (decision.reason === "query_variant_limit") capsReached.queryVariantLimit = true;
563
+ return false;
564
+ }),
565
+ (n) => n.key
566
+ ).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
567
+ let fetchedCount = 0;
568
+ let frontier = normalizedSeeds.map((s) => ({
569
+ url: s.normalizedUrl,
570
+ key: s.key,
571
+ depth: 0
572
+ }));
573
+ for (let depth = 0; depth <= cfg.maxDepth; depth++) {
574
+ if (frontier.length === 0) break;
575
+ const remaining = cfg.maxPages - fetchedCount;
576
+ if (remaining <= 0) {
577
+ capsReached.maxPages = true;
578
+ break;
579
+ }
580
+ const batch = frontier.filter((f) => !state.pagesByKey.has(f.key)).slice(0, remaining);
581
+ if (batch.length < frontier.filter((f) => !state.pagesByKey.has(f.key)).length) {
582
+ capsReached.maxPages = true;
583
+ }
584
+ const tasks = batch.map((item) => async () => {
585
+ await hostDelay.waitFor(item.url);
586
+ const robotsDecision = await robots.canFetch(item.url);
587
+ if (!robotsDecision.allowed) {
588
+ const page2 = {
589
+ url: item.url,
590
+ finalUrl: item.url,
591
+ redirectChain: [],
592
+ depth: item.depth,
593
+ discoveredFrom: [],
594
+ error: "Disallowed by robots.txt"
595
+ };
596
+ return { item, page: page2, discovered: [] };
597
+ }
598
+ const res = await fetchWithRedirects(item.url, {
599
+ timeoutMs: cfg.timeoutMs,
600
+ maxRedirects: cfg.maxRedirects,
601
+ headers: cfg.headers,
602
+ userAgent: cfg.userAgent,
603
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors
604
+ });
605
+ if (res.error === `maxRedirects exceeded (${cfg.maxRedirects})`) {
606
+ capsReached.maxRedirects = true;
607
+ }
608
+ const page = {
609
+ url: item.url,
610
+ finalUrl: res.finalUrl,
611
+ status: res.status,
612
+ redirectChain: res.redirectChain.map((h) => ({
613
+ url: h.url,
614
+ status: h.status,
615
+ location: h.location
616
+ })),
617
+ depth: item.depth,
618
+ discoveredFrom: [],
619
+ error: res.error
620
+ };
621
+ const discovered = [];
622
+ const isHtml = res.contentType?.toLowerCase().includes("text/html") === true || res.contentType?.toLowerCase().includes("application/xhtml+xml") === true;
623
+ if (res.bodyText && isHtml) {
624
+ const seo = extractSeo(res.bodyText);
625
+ page.title = seo.title;
626
+ page.description = seo.description;
627
+ page.h1Text = seo.h1Text;
628
+ page.h1Count = seo.h1Count;
629
+ page.canonical = seo.canonical;
630
+ page.robots = seo.robots;
631
+ page.lang = seo.lang;
632
+ const links = extractAnchorLinks(res.bodyText);
633
+ const outboundLinks = [];
634
+ for (const link of links) {
635
+ const href = link.href;
636
+ if (!href || isNonHttpLink(href)) continue;
637
+ try {
638
+ const abs = new URL(href, res.finalUrl).toString();
639
+ const absUrl = new URL(abs);
640
+ const internal = isAllowedOrigin(absUrl, allowedOrigins);
641
+ outboundLinks.push({
642
+ url: href,
643
+ resolvedUrl: abs,
644
+ internal,
645
+ anchorText: link.anchorText
646
+ });
647
+ if (!internal) continue;
648
+ if (!isInScopePath(absUrl.pathname, patterns)) continue;
649
+ discovered.push({ url: abs, anchorText: link.anchorText });
650
+ } catch {
651
+ }
652
+ }
653
+ page.outboundLinks = outboundLinks;
654
+ }
655
+ return { item, page, discovered };
656
+ });
657
+ const results = await runWithConcurrency(cfg.concurrency, tasks);
658
+ const nextKeys = /* @__PURE__ */ new Set();
659
+ for (const r of results) {
660
+ fetchedCount += 1;
661
+ state.pagesByKey.set(r.item.key, r.page);
662
+ const refs = state.referrersByKey.get(r.item.key) ?? [];
663
+ r.page.discoveredFrom = refs.slice().sort((a, b) => a.url < b.url ? -1 : a.url > b.url ? 1 : 0);
664
+ for (const d of r.discovered) {
665
+ const normalized = normalizeUrl(d.url, cfg.url);
666
+ const decision = shouldEnqueueUrl(cfg, urlPolicyState, normalized.normalizedUrl);
667
+ if (!decision.allow) {
668
+ if (decision.reason === "pagination_limit") {
669
+ capsReached.paginationLimit = true;
670
+ const msg = `Pagination limit hit (limit=${cfg.crawl.paginationLimit}) for ${normalized.normalizedUrl}`;
671
+ if (!runWarnings.includes(msg)) runWarnings.push(msg);
672
+ } else if (decision.reason === "query_variant_limit") {
673
+ capsReached.queryVariantLimit = true;
674
+ const msg = `Query variant limit hit (limit=${cfg.crawl.perPathQueryVariantLimit}) for ${new URL(normalized.normalizedUrl).pathname}`;
675
+ if (!runWarnings.includes(msg)) runWarnings.push(msg);
676
+ }
677
+ continue;
678
+ }
679
+ nextKeys.add(normalized.key);
680
+ addReferrer(state, normalized.key, { url: r.page.finalUrl, anchorText: d.anchorText });
681
+ }
682
+ }
683
+ if (depth === cfg.maxDepth) {
684
+ if (nextKeys.size > 0) capsReached.maxDepth = true;
685
+ break;
686
+ }
687
+ const nextCandidates = [];
688
+ for (const key of nextKeys) {
689
+ if (state.pagesByKey.has(key)) continue;
690
+ nextCandidates.push({ url: key, key, depth: depth + 1 });
691
+ }
692
+ frontier = uniqByKey(nextCandidates, (n) => n.key).sort(
693
+ (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0
694
+ );
695
+ }
696
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
697
+ const pages = Array.from(state.pagesByKey.entries()).map(([, p]) => p).sort((a, b) => {
698
+ if (a.depth !== b.depth) return a.depth - b.depth;
699
+ if (a.url < b.url) return -1;
700
+ if (a.url > b.url) return 1;
701
+ return 0;
702
+ });
703
+ const report = {
704
+ schemaVersion: REPORT_SCHEMA_VERSION,
705
+ run: {
706
+ startedAt,
707
+ endedAt,
708
+ baseUrl: cfg.baseUrl,
709
+ config: {
710
+ mode: cfg.mode,
711
+ reportFormat: cfg.reportFormat,
712
+ outputDir: cfg.outputDir,
713
+ maxPages: cfg.maxPages,
714
+ maxDepth: cfg.maxDepth,
715
+ maxRedirects: cfg.maxRedirects,
716
+ concurrency: cfg.concurrency,
717
+ timeoutMs: cfg.timeoutMs,
718
+ sitemap: cfg.sitemap,
719
+ respectRobots: cfg.respectRobots,
720
+ externalLinks: cfg.externalLinks,
721
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors,
722
+ render: {
723
+ maxRenderPages: cfg.render.maxRenderPages,
724
+ screenshotFullPage: cfg.render.screenshotFullPage,
725
+ breakpoints: cfg.render.breakpoints
726
+ },
727
+ crawl: {
728
+ perPathQueryVariantLimit: cfg.crawl.perPathQueryVariantLimit,
729
+ paginationParamNames: cfg.crawl.paginationParamNames,
730
+ paginationLimit: cfg.crawl.paginationLimit,
731
+ limitedPathPrefixes: cfg.crawl.limitedPathPrefixes,
732
+ requestDelayMs: cfg.crawl.requestDelayMs
733
+ },
734
+ external: {
735
+ maxExternalLinksChecked: cfg.external.maxExternalLinksChecked
736
+ },
737
+ include: cfg.include,
738
+ exclude: cfg.exclude,
739
+ userAgent: cfg.userAgent
740
+ },
741
+ warnings: runWarnings,
742
+ capsReached
743
+ },
744
+ pages
745
+ };
746
+ return { report };
747
+ }
748
+
749
+ // src/config/defaults.ts
750
+ function defaultConfigForBaseUrl(baseUrl) {
751
+ const url = new URL(baseUrl);
752
+ const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
753
+ return {
754
+ baseUrl,
755
+ outputDir: "qa-output",
756
+ mode: "crawl",
757
+ reportFormat: "both",
758
+ maxPages: 500,
759
+ maxDepth: 6,
760
+ maxRedirects: 10,
761
+ concurrency: 4,
762
+ timeoutMs: 15e3,
763
+ sitemap: true,
764
+ respectRobots: !isLocalhost,
765
+ externalLinks: false,
766
+ ignoreHttpsErrors: false,
767
+ userAgent: void 0,
768
+ headers: {},
769
+ include: [],
770
+ exclude: [],
771
+ url: {
772
+ normalizeTrailingSlash: true,
773
+ stripTrackingParams: true,
774
+ trackingParamPrefixes: ["utm_"],
775
+ trackingParamNames: ["gclid", "fbclid", "msclkid", "ref"],
776
+ dropQueryParamsExceptAllowlist: false,
777
+ allowlistedQueryParams: ["page"],
778
+ ignoredQueryParamPatterns: ["session", "token"]
779
+ },
780
+ seo: {
781
+ titleMin: 10,
782
+ titleMax: 60,
783
+ descriptionMin: 50,
784
+ descriptionMax: 160
785
+ },
786
+ render: {
787
+ maxRenderPages: 50,
788
+ screenshotFullPage: true,
789
+ breakpoints: {
790
+ mobile: { width: 375, height: 812 },
791
+ tablet: { width: 768, height: 1024 },
792
+ desktop: { width: 1440, height: 900 }
793
+ }
794
+ },
795
+ crawl: {
796
+ perPathQueryVariantLimit: 3,
797
+ paginationParamNames: ["page"],
798
+ paginationLimit: 5,
799
+ limitedPathPrefixes: ["/search", "/calendar", "/events"],
800
+ requestDelayMs: 0
801
+ },
802
+ external: {
803
+ maxExternalLinksChecked: 0
804
+ }
805
+ };
806
+ }
807
+
808
+ // src/config/load-config.ts
809
+ import { readFileSync } from "fs";
810
+ import YAML from "yaml";
811
+ function loadConfigFile(configPath) {
812
+ const raw = readFileSync(configPath, "utf8");
813
+ const trimmed = raw.trim();
814
+ if (configPath.endsWith(".json")) {
815
+ return JSON.parse(trimmed);
816
+ }
817
+ try {
818
+ const parsed = YAML.parse(trimmed);
819
+ if (parsed && typeof parsed === "object") return parsed;
820
+ } catch {
821
+ }
822
+ return JSON.parse(trimmed);
823
+ }
824
+
825
+ // src/config/merge.ts
826
+ function mergeConfig(base, override) {
827
+ return {
828
+ ...base,
829
+ ...override,
830
+ headers: {
831
+ ...base.headers,
832
+ ...override.headers ?? {}
833
+ },
834
+ include: override.include ?? base.include,
835
+ exclude: override.exclude ?? base.exclude,
836
+ url: {
837
+ ...base.url,
838
+ ...override.url ?? {}
839
+ },
840
+ seo: {
841
+ ...base.seo,
842
+ ...override.seo ?? {}
843
+ },
844
+ render: {
845
+ ...base.render,
846
+ ...override.render ?? {},
847
+ breakpoints: {
848
+ ...base.render.breakpoints,
849
+ ...override.render?.breakpoints ?? {}
850
+ }
851
+ },
852
+ crawl: {
853
+ ...base.crawl,
854
+ ...override.crawl ?? {}
855
+ },
856
+ external: {
857
+ ...base.external,
858
+ ...override.external ?? {}
859
+ }
860
+ };
861
+ }
862
+
863
+ // src/config/parse-headers.ts
864
+ function parseHeaderPairs(pairs) {
865
+ if (!pairs || pairs.length === 0) return {};
866
+ const headers = {};
867
+ for (const pair of pairs) {
868
+ const index = pair.indexOf(":");
869
+ if (index <= 0) continue;
870
+ const key = pair.slice(0, index).trim();
871
+ const value = pair.slice(index + 1).trim();
872
+ if (!key) continue;
873
+ headers[key] = value;
874
+ }
875
+ return headers;
876
+ }
877
+
878
+ // src/report/write-json.ts
879
+ import { mkdirSync, writeFileSync } from "fs";
880
+ import { join } from "path";
881
+ function writeJsonReport(outputDir, report) {
882
+ mkdirSync(outputDir, { recursive: true });
883
+ const path = join(outputDir, "report.json");
884
+ writeFileSync(path, `${JSON.stringify(report, null, 2)}
885
+ `, "utf8");
886
+ return path;
887
+ }
888
+
889
+ // src/checks/generate-issues.ts
890
+ function isLocalBaseUrl(baseUrl) {
891
+ const u = new URL(baseUrl);
892
+ return u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1";
893
+ }
894
+ function severityForNoindex(cfg) {
895
+ return isLocalBaseUrl(cfg.baseUrl) ? "info" : "warning";
896
+ }
897
+ function push(issues, severity, type, message, context) {
898
+ const issue = { severity, type, message };
899
+ if (context) issue.context = context;
900
+ issues.push(issue);
901
+ }
902
+ function hasNoindex(robots) {
903
+ if (!robots) return false;
904
+ return robots.toLowerCase().split(",").some((t) => t.trim() === "noindex");
905
+ }
906
+ function generateIssues(cfg, report) {
907
+ const issues = [];
908
+ const baseOrigin = new URL(cfg.baseUrl).origin;
909
+ for (const page of report.pages) {
910
+ if (page.redirectChain.length >= 2) {
911
+ push(issues, "warning", "redirect_chain_long", `Redirect chain length ${page.redirectChain.length}`, {
912
+ url: page.url,
913
+ finalUrl: page.finalUrl,
914
+ chain: page.redirectChain,
915
+ referrers: page.discoveredFrom
916
+ });
917
+ } else if (page.redirectChain.length === 1) {
918
+ push(issues, "info", "redirect", "URL redirects", {
919
+ url: page.url,
920
+ finalUrl: page.finalUrl,
921
+ chain: page.redirectChain,
922
+ referrers: page.discoveredFrom
923
+ });
924
+ }
925
+ if (page.error) {
926
+ if (page.error === "Disallowed by robots.txt") {
927
+ push(issues, "info", "robots_disallowed", "Skipped (disallowed by robots.txt)", {
928
+ url: page.url,
929
+ referrers: page.discoveredFrom
930
+ });
931
+ continue;
932
+ }
933
+ push(issues, "error", "fetch_error", "Failed to fetch page", {
934
+ url: page.url,
935
+ error: page.error,
936
+ referrers: page.discoveredFrom
937
+ });
938
+ continue;
939
+ }
940
+ if (typeof page.status === "number" && page.status >= 400) {
941
+ push(issues, "error", "broken_internal_link", `HTTP ${page.status}`, {
942
+ url: page.url,
943
+ status: page.status,
944
+ referrers: page.discoveredFrom
945
+ });
946
+ continue;
947
+ }
948
+ const isHtml = page.h1Count !== void 0 || page.title !== void 0 || page.description !== void 0;
949
+ if (!isHtml) continue;
950
+ if (page.status !== 200) continue;
951
+ if (!page.title) {
952
+ push(issues, "error", "missing_title", "Missing <title>", { url: page.finalUrl });
953
+ } else {
954
+ if (page.title.length < cfg.seo.titleMin) {
955
+ push(issues, "warning", "title_too_short", "Title too short", {
956
+ url: page.finalUrl,
957
+ length: page.title.length,
958
+ min: cfg.seo.titleMin
959
+ });
960
+ }
961
+ if (page.title.length > cfg.seo.titleMax) {
962
+ push(issues, "warning", "title_too_long", "Title too long", {
963
+ url: page.finalUrl,
964
+ length: page.title.length,
965
+ max: cfg.seo.titleMax
966
+ });
967
+ }
968
+ }
969
+ if (!page.description) {
970
+ push(issues, "warning", "missing_meta_description", 'Missing meta[name="description"]', {
971
+ url: page.finalUrl
972
+ });
973
+ } else {
974
+ if (page.description.length < cfg.seo.descriptionMin) {
975
+ push(issues, "warning", "meta_description_too_short", "Meta description too short", {
976
+ url: page.finalUrl,
977
+ length: page.description.length,
978
+ min: cfg.seo.descriptionMin
979
+ });
980
+ }
981
+ if (page.description.length > cfg.seo.descriptionMax) {
982
+ push(issues, "warning", "meta_description_too_long", "Meta description too long", {
983
+ url: page.finalUrl,
984
+ length: page.description.length,
985
+ max: cfg.seo.descriptionMax
986
+ });
987
+ }
988
+ }
989
+ const h1Count = page.h1Count ?? 0;
990
+ if (h1Count === 0) {
991
+ push(issues, "warning", "missing_h1", "Missing <h1>", { url: page.finalUrl });
992
+ } else if (h1Count > 1) {
993
+ push(issues, "warning", "multiple_h1", "Multiple <h1> elements", {
994
+ url: page.finalUrl,
995
+ count: h1Count
996
+ });
997
+ }
998
+ if (!page.canonical) {
999
+ push(issues, "warning", "missing_canonical", "Missing canonical link", { url: page.finalUrl });
1000
+ } else {
1001
+ try {
1002
+ const canonicalUrl = new URL(page.canonical, page.finalUrl);
1003
+ if (canonicalUrl.origin !== baseOrigin) {
1004
+ push(issues, "warning", "canonical_cross_origin", "Canonical points to different origin", {
1005
+ url: page.finalUrl,
1006
+ canonical: canonicalUrl.toString(),
1007
+ baseOrigin
1008
+ });
1009
+ }
1010
+ } catch {
1011
+ push(issues, "warning", "canonical_invalid", "Canonical is not a valid URL", {
1012
+ url: page.finalUrl,
1013
+ canonical: page.canonical
1014
+ });
1015
+ }
1016
+ }
1017
+ if (hasNoindex(page.robots)) {
1018
+ push(issues, severityForNoindex(cfg), "noindex", "noindex present", {
1019
+ url: page.finalUrl,
1020
+ robots: page.robots
1021
+ });
1022
+ }
1023
+ }
1024
+ function duplicateIssue(type, value, urls) {
1025
+ if (urls.length <= 1) return;
1026
+ push(issues, "warning", type, `Duplicate ${type.replace("duplicate_", "").replaceAll("_", " ")}`, {
1027
+ value,
1028
+ pages: urls.slice().sort(),
1029
+ count: urls.length
1030
+ });
1031
+ }
1032
+ const titles = /* @__PURE__ */ new Map();
1033
+ const descriptions = /* @__PURE__ */ new Map();
1034
+ const h1s = /* @__PURE__ */ new Map();
1035
+ for (const page of report.pages) {
1036
+ if (page.status !== 200) continue;
1037
+ if (page.title) titles.set(page.title, [...titles.get(page.title) ?? [], page.finalUrl]);
1038
+ if (page.description)
1039
+ descriptions.set(page.description, [
1040
+ ...descriptions.get(page.description) ?? [],
1041
+ page.finalUrl
1042
+ ]);
1043
+ if (page.h1Text) h1s.set(page.h1Text, [...h1s.get(page.h1Text) ?? [], page.finalUrl]);
1044
+ }
1045
+ for (const [value, urls] of titles) duplicateIssue("duplicate_title", value, urls);
1046
+ for (const [value, urls] of descriptions) duplicateIssue("duplicate_description", value, urls);
1047
+ for (const [value, urls] of h1s) duplicateIssue("duplicate_h1", value, urls);
1048
+ return issues;
1049
+ }
1050
+
1051
+ // src/report/write-html.ts
1052
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1053
+ import { join as join2 } from "path";
1054
+
1055
+ // src/report/build-html.ts
1056
+ function escapeHtml(value) {
1057
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1058
+ }
1059
+ function formatDurationMs(startedAt, endedAt) {
1060
+ const start = Date.parse(startedAt);
1061
+ const end = Date.parse(endedAt);
1062
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return void 0;
1063
+ const ms = Math.max(0, end - start);
1064
+ if (ms < 1e3) return `${ms}ms`;
1065
+ const s = Math.round(ms / 100) / 10;
1066
+ if (s < 60) return `${s}s`;
1067
+ const m = Math.floor(s / 60);
1068
+ const rs = Math.round((s - m * 60) * 10) / 10;
1069
+ return `${m}m ${rs}s`;
1070
+ }
1071
+ function issueCountsByType(issues) {
1072
+ const counts = /* @__PURE__ */ new Map();
1073
+ for (const issue of issues) counts.set(issue.type, (counts.get(issue.type) ?? 0) + 1);
1074
+ return [...counts.entries()].map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
1075
+ }
1076
+ function issueCountsBySeverity(issues) {
1077
+ const out = { error: 0, warning: 0, info: 0 };
1078
+ for (const issue of issues) out[issue.severity] = (out[issue.severity] ?? 0) + 1;
1079
+ return out;
1080
+ }
1081
+ function getPageIssues(issues, page) {
1082
+ const urls = /* @__PURE__ */ new Set([page.url, page.finalUrl]);
1083
+ return issues.filter((i) => {
1084
+ const u = i.context?.url;
1085
+ return typeof u === "string" && urls.has(u);
1086
+ });
1087
+ }
1088
+ function safeString(value) {
1089
+ return typeof value === "string" && value.trim() ? value : void 0;
1090
+ }
1091
+ function brokenLinkEntries(issues) {
1092
+ const broken = issues.filter((i) => i.type === "broken_internal_link" || i.type === "fetch_error");
1093
+ const out = [];
1094
+ for (const issue of broken) {
1095
+ const url = safeString(issue.context?.url);
1096
+ if (!url) continue;
1097
+ out.push({ url, issue });
1098
+ }
1099
+ return out.sort((a, b) => a.url.localeCompare(b.url));
1100
+ }
1101
+ function duplicateClusters(issues) {
1102
+ const types = /* @__PURE__ */ new Set(["duplicate_title", "duplicate_description", "duplicate_h1"]);
1103
+ return issues.filter((i) => types.has(i.type)).sort((a, b) => a.type.localeCompare(b.type));
1104
+ }
1105
+ function pageTitle(page) {
1106
+ return page.title ?? page.finalUrl;
1107
+ }
1108
+ function statusBadge(page) {
1109
+ const status = page.status;
1110
+ if (typeof status !== "number") return `<span class="badge badge-muted">unknown</span>`;
1111
+ if (status >= 400) return `<span class="badge badge-error">${status}</span>`;
1112
+ if (status >= 300) return `<span class="badge badge-warn">${status}</span>`;
1113
+ return `<span class="badge badge-ok">${status}</span>`;
1114
+ }
1115
+ function formatMaybe(value) {
1116
+ return value ? escapeHtml(value) : `<span class="muted">\u2014</span>`;
1117
+ }
1118
+ function renderReferrers(referrers) {
1119
+ if (!Array.isArray(referrers) || referrers.length === 0) {
1120
+ return `<div class="muted">No referrers captured.</div>`;
1121
+ }
1122
+ const rows = referrers.map((r) => {
1123
+ if (!r || typeof r !== "object") return "";
1124
+ const rec = r;
1125
+ const url = safeString(rec["url"]);
1126
+ const anchorText = safeString(rec["anchorText"]);
1127
+ const urlHtml = url ? `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>` : `<span class="muted">\u2014</span>`;
1128
+ const anchorHtml = anchorText ? escapeHtml(anchorText) : `<span class="muted">\u2014</span>`;
1129
+ return `<li>${urlHtml}<span class="sep"> \xB7 </span><span class="muted">${anchorHtml}</span></li>`;
1130
+ }).filter(Boolean).join("");
1131
+ return `<ul class="list">${rows}</ul>`;
1132
+ }
1133
+ function buildHtmlReport(report) {
1134
+ const issues = report.issues ?? [];
1135
+ const bySeverity = issueCountsBySeverity(issues);
1136
+ const byType = issueCountsByType(issues);
1137
+ const duration = formatDurationMs(report.run.startedAt, report.run.endedAt);
1138
+ const caps = report.run.capsReached;
1139
+ const brokenLinks = brokenLinkEntries(issues);
1140
+ const duplicates = duplicateClusters(issues);
1141
+ const pagesHtml = report.pages.map((page) => {
1142
+ const pageIssues = getPageIssues(issues, page);
1143
+ const inboundCount = page.discoveredFrom.length;
1144
+ const outbound = page.outboundLinks ?? [];
1145
+ const outboundInternal = outbound.filter((l) => l.internal).length;
1146
+ const outboundExternal = outbound.filter((l) => !l.internal).length;
1147
+ const renderResults = page.render?.results ?? [];
1148
+ const issueList = pageIssues.length ? `<ul class="list">${pageIssues.map((i) => `<li><span class="sev sev-${i.severity}">${i.severity}</span> <code>${escapeHtml(i.type)}</code> \u2014 ${escapeHtml(i.message)}</li>`).join("")}</ul>` : `<div class="muted">No issues captured for this page.</div>`;
1149
+ const redirectChainHtml = page.redirectChain.length === 0 ? `<span class="muted">\u2014</span>` : `<ol class="list">${page.redirectChain.map(
1150
+ (h) => `<li><code>${escapeHtml(String(h.status))}</code> ${escapeHtml(h.url)}${h.location ? ` <span class="muted">\u2192</span> ${escapeHtml(h.location)}` : ""}</li>`
1151
+ ).join("")}</ol>`;
1152
+ const inboundHtml = inboundCount === 0 ? `<div class="muted">\u2014</div>` : `<ul class="list">${page.discoveredFrom.map((r) => {
1153
+ const a = r.anchorText ? ` <span class="muted">(${escapeHtml(r.anchorText)})</span>` : "";
1154
+ return `<li><a href="${escapeHtml(r.url)}">${escapeHtml(r.url)}</a>${a}</li>`;
1155
+ }).join("")}</ul>`;
1156
+ const renderHtml = renderResults.length === 0 ? `<div class="muted">Not rendered.</div>` : `<div class="grid">${renderResults.map((r) => {
1157
+ const consoleCount = r.consoleErrors.length;
1158
+ const reqFailCount = r.requestFailures.length;
1159
+ const meta = [
1160
+ `viewport ${r.viewport.width}\xD7${r.viewport.height}`,
1161
+ r.fullPage ? "full page" : "viewport only",
1162
+ typeof r.pageStatus === "number" ? `status ${r.pageStatus}` : "status unknown",
1163
+ r.pageError ? "page error" : "ok",
1164
+ consoleCount ? `${consoleCount} console errors` : "0 console errors",
1165
+ reqFailCount ? `${reqFailCount} request failures` : "0 request failures"
1166
+ ].join(" \xB7 ");
1167
+ return `
1168
+ <div class="card">
1169
+ <div><strong>${escapeHtml(r.breakpoint)}</strong> <span class="muted">${escapeHtml(meta)}</span></div>
1170
+ <div style="margin-top:8px">
1171
+ <a href="${escapeHtml(r.screenshotPath)}" target="_blank" rel="noreferrer">
1172
+ <img src="${escapeHtml(r.screenshotPath)}" alt="${escapeHtml(page.finalUrl)} ${escapeHtml(r.breakpoint)}" style="width:100%; border-radius:8px; border:1px solid var(--border)" />
1173
+ </a>
1174
+ </div>
1175
+ </div>
1176
+ `.trim();
1177
+ }).join("")}</div>`;
1178
+ return `
1179
+ <details class="card" id="page-${encodeURIComponent(page.finalUrl)}">
1180
+ <summary class="card-summary">
1181
+ ${statusBadge(page)}
1182
+ <span class="page-title">${escapeHtml(pageTitle(page))}</span>
1183
+ <span class="muted">${escapeHtml(page.finalUrl)}</span>
1184
+ ${renderResults.length ? `<span class="badge badge-muted">rendered</span>` : ""}
1185
+ </summary>
1186
+ <div class="grid">
1187
+ <div>
1188
+ <h4>Overview</h4>
1189
+ <table class="table">
1190
+ <tr><th>URL</th><td><code>${escapeHtml(page.url)}</code></td></tr>
1191
+ <tr><th>Final URL</th><td><code>${escapeHtml(page.finalUrl)}</code></td></tr>
1192
+ <tr><th>Status</th><td>${statusBadge(page)}</td></tr>
1193
+ <tr><th>Depth</th><td>${escapeHtml(String(page.depth))}</td></tr>
1194
+ <tr><th>Inbound links</th><td>${escapeHtml(String(inboundCount))}</td></tr>
1195
+ <tr><th>Outbound links</th><td>${escapeHtml(String(outbound.length))} <span class="muted">(internal ${outboundInternal}, external ${outboundExternal})</span></td></tr>
1196
+ </table>
1197
+ </div>
1198
+ <div>
1199
+ <h4>SEO</h4>
1200
+ <table class="table">
1201
+ <tr><th>Title</th><td>${formatMaybe(page.title)}</td></tr>
1202
+ <tr><th>Description</th><td>${formatMaybe(page.description)}</td></tr>
1203
+ <tr><th>H1</th><td>${formatMaybe(page.h1Text)} <span class="muted">(count ${escapeHtml(String(page.h1Count ?? 0))})</span></td></tr>
1204
+ <tr><th>Canonical</th><td>${formatMaybe(page.canonical)}</td></tr>
1205
+ <tr><th>Robots</th><td>${formatMaybe(page.robots)}</td></tr>
1206
+ <tr><th>Lang</th><td>${formatMaybe(page.lang)}</td></tr>
1207
+ </table>
1208
+ </div>
1209
+ </div>
1210
+
1211
+ <div class="grid">
1212
+ <div>
1213
+ <h4>Redirect Chain</h4>
1214
+ ${redirectChainHtml}
1215
+ </div>
1216
+ <div>
1217
+ <h4>Referrers</h4>
1218
+ ${inboundHtml}
1219
+ </div>
1220
+ </div>
1221
+
1222
+ <h4>Render</h4>
1223
+ ${renderHtml}
1224
+
1225
+ <h4>Issues</h4>
1226
+ ${issueList}
1227
+ </details>
1228
+ `.trim();
1229
+ }).join("\n");
1230
+ const brokenLinksHtml = brokenLinks.length === 0 ? `<div class="muted">No broken links found.</div>` : `<div class="stack">${brokenLinks.map(({ url, issue }) => {
1231
+ const statusRaw = issue.context?.["status"];
1232
+ const status = typeof statusRaw === "number" ? String(statusRaw) : typeof statusRaw === "string" ? statusRaw : void 0;
1233
+ const message = issue.type === "fetch_error" ? safeString(issue.context?.error) ?? issue.message : issue.message;
1234
+ return `
1235
+ <details class="card">
1236
+ <summary class="card-summary">
1237
+ <span class="sev sev-${issue.severity}">${issue.severity}</span>
1238
+ <span class="page-title">${escapeHtml(url)}</span>
1239
+ ${status ? `<span class="muted">HTTP ${escapeHtml(status)}</span>` : ""}
1240
+ </summary>
1241
+ <div class="muted">${escapeHtml(message)}</div>
1242
+ <h4>Referrers</h4>
1243
+ ${renderReferrers(issue.context?.referrers)}
1244
+ </details>
1245
+ `.trim();
1246
+ }).join("\n")}</div>`;
1247
+ const duplicatesHtml = duplicates.length === 0 ? `<div class="muted">No duplicates found.</div>` : `<div class="stack">${duplicates.map((issue) => {
1248
+ const value = safeString(issue.context?.value) ?? "";
1249
+ const pages = Array.isArray(issue.context?.pages) ? issue.context?.pages : [];
1250
+ return `
1251
+ <details class="card">
1252
+ <summary class="card-summary">
1253
+ <span class="sev sev-${issue.severity}">${issue.severity}</span>
1254
+ <code>${escapeHtml(issue.type)}</code>
1255
+ <span class="muted">${escapeHtml(String(issue.context?.count ?? pages.length))} pages</span>
1256
+ </summary>
1257
+ <div><strong>Value</strong>: ${value ? `<code>${escapeHtml(value)}</code>` : `<span class="muted">\u2014</span>`}</div>
1258
+ <h4>Pages</h4>
1259
+ <ul class="list">${pages.map((p) => typeof p === "string" ? `<li><a href="${escapeHtml(p)}">${escapeHtml(p)}</a></li>` : "").filter(Boolean).join("")}</ul>
1260
+ </details>
1261
+ `.trim();
1262
+ }).join("\n")}</div>`;
1263
+ const capsHtml = `
1264
+ <ul class="list">
1265
+ <li>maxPages: ${caps.maxPages ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1266
+ <li>maxDepth: ${caps.maxDepth ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1267
+ <li>maxRedirects: ${caps.maxRedirects ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1268
+ <li>queryVariantLimit: ${caps.queryVariantLimit ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1269
+ <li>paginationLimit: ${caps.paginationLimit ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1270
+ <li>externalLinksLimit: ${caps.externalLinksLimit ? `<span class="badge badge-warn">reached</span>` : `<span class="badge badge-ok">ok</span>`}</li>
1271
+ </ul>
1272
+ `.trim();
1273
+ const issueTypeList = byType.map(({ type, count }) => `<li><code>${escapeHtml(type)}</code> <span class="muted">(${count})</span></li>`).join("");
1274
+ const configJson = escapeHtml(JSON.stringify(report.run.config, null, 2));
1275
+ return `<!doctype html>
1276
+ <html lang="en">
1277
+ <head>
1278
+ <meta charset="utf-8" />
1279
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1280
+ <title>QA Report \u2014 ${escapeHtml(report.run.baseUrl)}</title>
1281
+ <style>
1282
+ :root { color-scheme: light; --bg:#0b0f14; --panel:#111827; --text:#e5e7eb; --muted:#9ca3af; --ok:#10b981; --warn:#f59e0b; --err:#ef4444; --info:#60a5fa; --border:#1f2937; }
1283
+ body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color: var(--text); }
1284
+ a { color: #93c5fd; }
1285
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.95em; }
1286
+ .container { max-width: 1100px; margin: 0 auto; padding: 24px; }
1287
+ .header { display:flex; flex-wrap:wrap; gap: 12px; align-items: baseline; }
1288
+ .header h1 { margin: 0; font-size: 20px; }
1289
+ .muted { color: var(--muted); }
1290
+ .grid { display:grid; grid-template-columns: 1fr; gap: 16px; }
1291
+ @media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
1292
+ .card { background: rgba(17,24,39,0.9); border: 1px solid var(--border); border-radius: 10px; padding: 12px; }
1293
+ .card-summary { cursor:pointer; display:flex; flex-wrap:wrap; gap: 10px; align-items:center; }
1294
+ .page-title { font-weight: 600; }
1295
+ .table { width:100%; border-collapse: collapse; }
1296
+ .table th { text-align:left; width: 140px; color: var(--muted); font-weight: 500; padding: 6px 0; }
1297
+ .table td { padding: 6px 0; }
1298
+ .list { margin: 8px 0 0; padding-left: 18px; }
1299
+ .sep { color: var(--muted); }
1300
+ .stack { display:flex; flex-direction:column; gap: 12px; }
1301
+ .badge { display:inline-block; border-radius: 999px; padding: 2px 8px; font-size: 12px; border: 1px solid var(--border); }
1302
+ .badge-ok { background: rgba(16,185,129,0.15); border-color: rgba(16,185,129,0.25); }
1303
+ .badge-warn { background: rgba(245,158,11,0.15); border-color: rgba(245,158,11,0.25); }
1304
+ .badge-error { background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.25); }
1305
+ .badge-muted { background: rgba(156,163,175,0.12); border-color: rgba(156,163,175,0.25); }
1306
+ .sev { display:inline-block; border-radius: 6px; padding: 2px 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
1307
+ .sev-error { background: rgba(239,68,68,0.15); color: var(--text); border: 1px solid rgba(239,68,68,0.25); }
1308
+ .sev-warning { background: rgba(245,158,11,0.15); color: var(--text); border: 1px solid rgba(245,158,11,0.25); }
1309
+ .sev-info { background: rgba(96,165,250,0.15); color: var(--text); border: 1px solid rgba(96,165,250,0.25); }
1310
+ details > summary { list-style: none; }
1311
+ details > summary::-webkit-details-marker { display:none; }
1312
+ .section { margin-top: 18px; }
1313
+ pre { background: #0a0f1a; border: 1px solid var(--border); border-radius: 10px; padding: 12px; overflow:auto; }
1314
+ </style>
1315
+ </head>
1316
+ <body>
1317
+ <div class="container">
1318
+ <div class="header">
1319
+ <h1>QA Report</h1>
1320
+ <span class="muted">${escapeHtml(report.run.baseUrl)}</span>
1321
+ </div>
1322
+
1323
+ <div class="section card">
1324
+ <h3 style="margin-top:0">Run Summary</h3>
1325
+ <div class="grid">
1326
+ <div>
1327
+ <table class="table">
1328
+ <tr><th>Started</th><td><code>${escapeHtml(report.run.startedAt)}</code></td></tr>
1329
+ <tr><th>Ended</th><td><code>${escapeHtml(report.run.endedAt)}</code></td></tr>
1330
+ <tr><th>Duration</th><td>${duration ? `<code>${escapeHtml(duration)}</code>` : `<span class="muted">\u2014</span>`}</td></tr>
1331
+ <tr><th>Pages</th><td><code>${escapeHtml(String(report.pages.length))}</code></td></tr>
1332
+ <tr><th>Issues</th><td><code>${escapeHtml(String(issues.length))}</code> <span class="muted">(error ${bySeverity.error}, warning ${bySeverity.warning}, info ${bySeverity.info})</span></td></tr>
1333
+ </table>
1334
+ </div>
1335
+ <div>
1336
+ <div><strong>Caps reached</strong></div>
1337
+ ${capsHtml}
1338
+ ${report.run.warnings.length ? `<div style="margin-top:10px"><strong>Warnings</strong><ul class="list">${report.run.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></div>` : `<div style="margin-top:10px" class="muted">No run warnings.</div>`}
1339
+ </div>
1340
+ </div>
1341
+ </div>
1342
+
1343
+ <div class="section card">
1344
+ <h3 style="margin-top:0">Issue Dashboard</h3>
1345
+ ${issues.length ? `<ul class="list">${issueTypeList}</ul>` : `<div class="muted">No issues found.</div>`}
1346
+ </div>
1347
+
1348
+ <div class="section">
1349
+ <h2>Broken Links</h2>
1350
+ ${brokenLinksHtml}
1351
+ </div>
1352
+
1353
+ <div class="section">
1354
+ <h2>Duplicates</h2>
1355
+ ${duplicatesHtml}
1356
+ </div>
1357
+
1358
+ <div class="section">
1359
+ <h2>Pages</h2>
1360
+ <div class="stack">
1361
+ ${pagesHtml || `<div class="muted">No pages captured.</div>`}
1362
+ </div>
1363
+ </div>
1364
+
1365
+ <div class="section card">
1366
+ <h3 style="margin-top:0">Config</h3>
1367
+ <pre><code>${configJson}</code></pre>
1368
+ </div>
1369
+ </div>
1370
+ </body>
1371
+ </html>`;
1372
+ }
1373
+
1374
+ // src/report/write-html.ts
1375
+ function writeHtmlReport(outputDir, report) {
1376
+ mkdirSync2(outputDir, { recursive: true });
1377
+ const path = join2(outputDir, "report.html");
1378
+ const html = buildHtmlReport(report);
1379
+ writeFileSync2(path, html, "utf8");
1380
+ return path;
1381
+ }
1382
+
1383
+ // src/report/create.ts
1384
+ function createEmptyReport(cfg, startedAt) {
1385
+ return {
1386
+ schemaVersion: REPORT_SCHEMA_VERSION,
1387
+ run: {
1388
+ startedAt,
1389
+ endedAt: startedAt,
1390
+ baseUrl: cfg.baseUrl,
1391
+ config: {
1392
+ mode: cfg.mode,
1393
+ reportFormat: cfg.reportFormat,
1394
+ outputDir: cfg.outputDir,
1395
+ maxPages: cfg.maxPages,
1396
+ maxDepth: cfg.maxDepth,
1397
+ maxRedirects: cfg.maxRedirects,
1398
+ concurrency: cfg.concurrency,
1399
+ timeoutMs: cfg.timeoutMs,
1400
+ sitemap: cfg.sitemap,
1401
+ respectRobots: cfg.respectRobots,
1402
+ externalLinks: cfg.externalLinks,
1403
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors,
1404
+ render: {
1405
+ maxRenderPages: cfg.render.maxRenderPages,
1406
+ screenshotFullPage: cfg.render.screenshotFullPage,
1407
+ breakpoints: cfg.render.breakpoints
1408
+ },
1409
+ crawl: {
1410
+ perPathQueryVariantLimit: cfg.crawl.perPathQueryVariantLimit,
1411
+ paginationParamNames: cfg.crawl.paginationParamNames,
1412
+ paginationLimit: cfg.crawl.paginationLimit,
1413
+ limitedPathPrefixes: cfg.crawl.limitedPathPrefixes,
1414
+ requestDelayMs: cfg.crawl.requestDelayMs
1415
+ },
1416
+ external: {
1417
+ maxExternalLinksChecked: cfg.external.maxExternalLinksChecked
1418
+ },
1419
+ include: cfg.include,
1420
+ exclude: cfg.exclude,
1421
+ userAgent: cfg.userAgent
1422
+ },
1423
+ warnings: [],
1424
+ capsReached: {
1425
+ maxPages: false,
1426
+ maxDepth: false,
1427
+ maxRedirects: false,
1428
+ queryVariantLimit: false,
1429
+ paginationLimit: false,
1430
+ externalLinksLimit: false
1431
+ }
1432
+ },
1433
+ pages: [],
1434
+ issues: []
1435
+ };
1436
+ }
1437
+
1438
+ // src/render/select-pages.ts
1439
+ function isInternalToBase(baseUrl, candidateUrl) {
1440
+ try {
1441
+ const base = new URL(baseUrl);
1442
+ const u = new URL(candidateUrl);
1443
+ return u.origin === base.origin;
1444
+ } catch {
1445
+ return false;
1446
+ }
1447
+ }
1448
+ function sortCandidates(candidates) {
1449
+ return candidates.sort(
1450
+ (a, b) => a.group - b.group || a.rank - b.rank || a.url.localeCompare(b.url)
1451
+ );
1452
+ }
1453
+ async function selectRenderUrlsForFull(cfg, report) {
1454
+ const candidates = [];
1455
+ candidates.push({ url: cfg.baseUrl, group: 0, rank: 0 });
1456
+ const issueUrls = /* @__PURE__ */ new Set();
1457
+ for (const issue of report.issues ?? []) {
1458
+ const u = issue.context?.url;
1459
+ if (typeof u === "string" && isInternalToBase(cfg.baseUrl, u)) issueUrls.add(u);
1460
+ }
1461
+ for (const u of [...issueUrls].sort()) candidates.push({ url: u, group: 1, rank: 0 });
1462
+ const topInbound = report.pages.filter((p) => p.status === 200).slice().sort((a, b) => b.discoveredFrom.length - a.discoveredFrom.length || a.finalUrl.localeCompare(b.finalUrl)).slice(0, 20).map((p) => p.finalUrl);
1463
+ for (const [index, u] of topInbound.entries()) candidates.push({ url: u, group: 2, rank: index });
1464
+ if (cfg.sitemap) {
1465
+ const seedResult = await discoverSeeds(cfg);
1466
+ for (const u of seedResult.seeds) {
1467
+ if (!isInternalToBase(cfg.baseUrl, u)) continue;
1468
+ candidates.push({ url: u, group: 3, rank: 0 });
1469
+ }
1470
+ }
1471
+ const used = /* @__PURE__ */ new Set();
1472
+ const out = [];
1473
+ for (const c of sortCandidates(candidates)) {
1474
+ const normalized = normalizeUrl(c.url, cfg.url).normalizedUrl;
1475
+ if (used.has(normalized)) continue;
1476
+ used.add(normalized);
1477
+ out.push(normalized);
1478
+ if (out.length >= cfg.render.maxRenderPages) break;
1479
+ }
1480
+ return out;
1481
+ }
1482
+ async function selectRenderUrlsForRenderOnly(cfg) {
1483
+ const seedResult = await discoverSeeds(cfg);
1484
+ const candidates = seedResult.seeds.filter((u) => isInternalToBase(cfg.baseUrl, u)).map((u) => ({ url: u, group: 0, rank: 0 }));
1485
+ const used = /* @__PURE__ */ new Set();
1486
+ const out = [];
1487
+ for (const c of sortCandidates(candidates)) {
1488
+ const normalized = normalizeUrl(c.url, cfg.url).normalizedUrl;
1489
+ if (used.has(normalized)) continue;
1490
+ used.add(normalized);
1491
+ out.push(normalized);
1492
+ if (out.length >= cfg.render.maxRenderPages) break;
1493
+ }
1494
+ return out;
1495
+ }
1496
+
1497
+ // src/render/screenshot-path.ts
1498
+ import { createHash } from "crypto";
1499
+ import { mkdirSync as mkdirSync3 } from "fs";
1500
+ import { join as join3 } from "path";
1501
+ function slugifyPath(pathname) {
1502
+ if (!pathname || pathname === "/") return "home";
1503
+ return pathname.replace(/^\//, "").replace(/\/$/, "").split("/").filter(Boolean).join("-").replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "page";
1504
+ }
1505
+ function shortHash(value) {
1506
+ return createHash("sha1").update(value).digest("hex").slice(0, 10);
1507
+ }
1508
+ function getScreenshotRelativePath(input, used) {
1509
+ const u = new URL(input.url);
1510
+ const slug = slugifyPath(u.pathname);
1511
+ const queryHash = u.search ? shortHash(u.search) : "";
1512
+ const baseName = `${slug}${queryHash ? `__${queryHash}` : ""}__${input.breakpoint}.png`;
1513
+ let rel = `screenshots/${input.breakpoint}/${baseName}`;
1514
+ if (!used.has(rel)) {
1515
+ used.add(rel);
1516
+ return rel;
1517
+ }
1518
+ const disambiguator = shortHash(u.toString());
1519
+ rel = `screenshots/${input.breakpoint}/${slug}__${disambiguator}__${input.breakpoint}.png`;
1520
+ used.add(rel);
1521
+ return rel;
1522
+ }
1523
+ function ensureScreenshotDir(outputDir, breakpoint) {
1524
+ const dir = join3(outputDir, "screenshots", breakpoint);
1525
+ mkdirSync3(dir, { recursive: true });
1526
+ return dir;
1527
+ }
1528
+
1529
+ // src/render/run.ts
1530
+ import { join as join4 } from "path";
1531
+ import { chromium } from "playwright";
1532
+ function getOrCreatePage(report, url) {
1533
+ const existingIndex = report.pages.findIndex((p) => p.finalUrl === url || p.url === url);
1534
+ if (existingIndex >= 0) return { pageIndex: existingIndex };
1535
+ report.pages.push({
1536
+ url,
1537
+ finalUrl: url,
1538
+ redirectChain: [],
1539
+ depth: 0,
1540
+ discoveredFrom: []
1541
+ });
1542
+ return { pageIndex: report.pages.length - 1 };
1543
+ }
1544
+ async function renderPages(cfg, report, urls) {
1545
+ const usedScreenshots = /* @__PURE__ */ new Set();
1546
+ const breakpointEntries = Object.entries(cfg.render.breakpoints).sort((a, b) => a[0].localeCompare(b[0]));
1547
+ if (breakpointEntries.length === 0) return;
1548
+ for (const [breakpointName] of breakpointEntries) {
1549
+ ensureScreenshotDir(cfg.outputDir, breakpointName);
1550
+ }
1551
+ const browser = await chromium.launch({ headless: true });
1552
+ try {
1553
+ const contexts = await Promise.all(
1554
+ breakpointEntries.map(async ([name, viewport]) => {
1555
+ const context = await browser.newContext({
1556
+ viewport,
1557
+ ignoreHTTPSErrors: cfg.ignoreHttpsErrors,
1558
+ extraHTTPHeaders: cfg.headers,
1559
+ ...cfg.userAgent ? { userAgent: cfg.userAgent } : {}
1560
+ });
1561
+ return { name, viewport, context };
1562
+ })
1563
+ );
1564
+ try {
1565
+ for (const url of urls) {
1566
+ const { pageIndex } = getOrCreatePage(report, url);
1567
+ const pageRecord = report.pages[pageIndex];
1568
+ const renderResults = [];
1569
+ for (const { name: breakpoint, viewport, context } of contexts) {
1570
+ const consoleErrors = [];
1571
+ const requestFailures = [];
1572
+ let pageError;
1573
+ let pageStatus;
1574
+ const page = await context.newPage();
1575
+ page.on("console", (msg) => {
1576
+ if (msg.type() !== "error") return;
1577
+ const loc = msg.location();
1578
+ const location = {
1579
+ ...loc.url ? { url: loc.url } : {},
1580
+ ...loc.lineNumber ? { lineNumber: loc.lineNumber } : {},
1581
+ ...loc.columnNumber ? { columnNumber: loc.columnNumber } : {}
1582
+ };
1583
+ consoleErrors.push({
1584
+ type: msg.type(),
1585
+ text: msg.text(),
1586
+ location: Object.keys(location).length ? location : void 0
1587
+ });
1588
+ });
1589
+ page.on("pageerror", (err) => {
1590
+ pageError = err instanceof Error ? err.message : String(err);
1591
+ });
1592
+ page.on("requestfailed", (req) => {
1593
+ const failure = req.failure();
1594
+ requestFailures.push({
1595
+ url: req.url(),
1596
+ method: req.method(),
1597
+ resourceType: req.resourceType(),
1598
+ failure: failure?.errorText ?? void 0
1599
+ });
1600
+ });
1601
+ try {
1602
+ const response = await page.goto(url, { waitUntil: "load", timeout: cfg.timeoutMs });
1603
+ pageStatus = response?.status();
1604
+ } catch (error) {
1605
+ pageError = pageError ?? (error instanceof Error ? error.message : String(error));
1606
+ }
1607
+ const rel = getScreenshotRelativePath(
1608
+ { outputDir: cfg.outputDir, url, breakpoint },
1609
+ usedScreenshots
1610
+ );
1611
+ const abs = join4(cfg.outputDir, rel);
1612
+ const dir = join4(cfg.outputDir, "screenshots", breakpoint);
1613
+ try {
1614
+ await page.screenshot({
1615
+ path: abs,
1616
+ fullPage: cfg.render.screenshotFullPage
1617
+ });
1618
+ } catch (error) {
1619
+ pageError = pageError ?? (error instanceof Error ? error.message : String(error));
1620
+ } finally {
1621
+ await page.close();
1622
+ }
1623
+ ensureScreenshotDir(cfg.outputDir, breakpoint);
1624
+ void dir;
1625
+ renderResults.push({
1626
+ breakpoint,
1627
+ viewport,
1628
+ fullPage: cfg.render.screenshotFullPage,
1629
+ screenshotPath: rel,
1630
+ pageStatus,
1631
+ pageError,
1632
+ consoleErrors,
1633
+ requestFailures
1634
+ });
1635
+ }
1636
+ pageRecord.render = { results: renderResults };
1637
+ }
1638
+ } finally {
1639
+ await Promise.all(contexts.map((c) => c.context.close()));
1640
+ }
1641
+ } finally {
1642
+ await browser.close();
1643
+ }
1644
+ }
1645
+
1646
+ // src/checks/generate-render-issues.ts
1647
+ function push2(issues, issue) {
1648
+ issues.push(issue);
1649
+ }
1650
+ function generateRenderIssues(report) {
1651
+ const issues = [];
1652
+ for (const page of report.pages) {
1653
+ const render = page.render;
1654
+ if (!render) continue;
1655
+ for (const r of render.results) {
1656
+ const baseContext = {
1657
+ url: page.finalUrl,
1658
+ breakpoint: r.breakpoint,
1659
+ screenshotPath: r.screenshotPath
1660
+ };
1661
+ if (typeof r.pageStatus === "number" && r.pageStatus >= 400) {
1662
+ push2(issues, {
1663
+ severity: "error",
1664
+ type: "render_http_error",
1665
+ message: `Rendered page returned HTTP ${r.pageStatus}`,
1666
+ context: { ...baseContext, status: r.pageStatus }
1667
+ });
1668
+ }
1669
+ if (r.pageError) {
1670
+ push2(issues, {
1671
+ severity: "error",
1672
+ type: "render_page_error",
1673
+ message: "Page error while rendering",
1674
+ context: { ...baseContext, error: r.pageError }
1675
+ });
1676
+ }
1677
+ if (r.consoleErrors.length > 0) {
1678
+ push2(issues, {
1679
+ severity: "error",
1680
+ type: "render_console_error",
1681
+ message: `${r.consoleErrors.length} console error(s) while rendering`,
1682
+ context: { ...baseContext, count: r.consoleErrors.length, sample: r.consoleErrors.slice(0, 10) }
1683
+ });
1684
+ }
1685
+ if (r.requestFailures.length > 0) {
1686
+ push2(issues, {
1687
+ severity: "warning",
1688
+ type: "render_request_failed",
1689
+ message: `${r.requestFailures.length} failed request(s) while rendering`,
1690
+ context: { ...baseContext, count: r.requestFailures.length, sample: r.requestFailures.slice(0, 10) }
1691
+ });
1692
+ }
1693
+ }
1694
+ }
1695
+ return issues;
1696
+ }
1697
+
1698
+ // src/output/run-dir.ts
1699
+ import { createHash as createHash2 } from "crypto";
1700
+ import { mkdirSync as mkdirSync4 } from "fs";
1701
+ import { join as join5 } from "path";
1702
+ function slugify(value) {
1703
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1704
+ }
1705
+ function shortHash2(value) {
1706
+ return createHash2("sha1").update(value).digest("hex").slice(0, 10);
1707
+ }
1708
+ function formatTimestampForDir(iso) {
1709
+ return iso.replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
1710
+ }
1711
+ function slugifyUrlForDir(baseUrl) {
1712
+ const u = new URL(baseUrl);
1713
+ const host = u.host;
1714
+ const path = u.pathname && u.pathname !== "/" ? u.pathname : "";
1715
+ const raw = `${host}${path}`;
1716
+ const slug = slugify(raw);
1717
+ if (slug.length <= 80) return slug || "site";
1718
+ return `${slug.slice(0, 60)}-${shortHash2(raw)}`;
1719
+ }
1720
+ function createRunOutputDir(outputRootDir, baseUrl, startedAtIso) {
1721
+ mkdirSync4(outputRootDir, { recursive: true });
1722
+ const dirName = `${formatTimestampForDir(startedAtIso)}__${slugifyUrlForDir(baseUrl)}`;
1723
+ const outDir = join5(outputRootDir, dirName);
1724
+ mkdirSync4(outDir, { recursive: true });
1725
+ return outDir;
1726
+ }
1727
+
1728
+ // src/report/write-run-metadata.ts
1729
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
1730
+ import { join as join6, relative } from "path";
1731
+
1732
+ // src/version.ts
1733
+ import { readFileSync as readFileSync2 } from "fs";
1734
+ function getToolVersion() {
1735
+ try {
1736
+ const raw = readFileSync2(new URL("../package.json", import.meta.url), "utf8");
1737
+ const pkg = JSON.parse(raw);
1738
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
1739
+ } catch {
1740
+ return "0.0.0";
1741
+ }
1742
+ }
1743
+
1744
+ // src/report/write-run-metadata.ts
1745
+ function writeRunMetadata(outputDir, report, absoluteFilesWritten) {
1746
+ mkdirSync5(outputDir, { recursive: true });
1747
+ const path = join6(outputDir, "run-metadata.json");
1748
+ const files = absoluteFilesWritten.map((p) => relative(outputDir, p)).filter((p) => !p.startsWith("..")).sort();
1749
+ const metadata = {
1750
+ schemaVersion: REPORT_SCHEMA_VERSION,
1751
+ toolVersion: getToolVersion(),
1752
+ outputDir,
1753
+ startedAt: report.run.startedAt,
1754
+ endedAt: report.run.endedAt,
1755
+ baseUrl: report.run.baseUrl,
1756
+ files
1757
+ };
1758
+ writeFileSync3(path, `${JSON.stringify(metadata, null, 2)}
1759
+ `, "utf8");
1760
+ return path;
1761
+ }
1762
+
1763
+ // src/config/validate.ts
1764
+ function validateRegexList(patterns, label) {
1765
+ for (const pattern of patterns) {
1766
+ try {
1767
+ new RegExp(pattern);
1768
+ } catch (error) {
1769
+ const msg = error instanceof Error ? error.message : String(error);
1770
+ throw new Error(`Invalid ${label} regex: ${pattern} (${msg})`);
1771
+ }
1772
+ }
1773
+ }
1774
+
1775
+ // src/checks/check-external-links.ts
1776
+ function isExternalToBase(cfg, url) {
1777
+ try {
1778
+ const base = new URL(cfg.baseUrl);
1779
+ const u = new URL(url);
1780
+ return u.origin !== base.origin;
1781
+ } catch {
1782
+ return false;
1783
+ }
1784
+ }
1785
+ function addReferrer2(map, targetUrl, ref) {
1786
+ const entry = map.get(targetUrl) ?? { url: targetUrl, referrers: [] };
1787
+ if (!entry.referrers.some((r) => r.pageUrl === ref.pageUrl && r.url === ref.url && r.anchorText === ref.anchorText)) {
1788
+ entry.referrers.push(ref);
1789
+ }
1790
+ map.set(targetUrl, entry);
1791
+ }
1792
+ async function runWithConcurrency2(concurrency, tasks) {
1793
+ const results = new Array(tasks.length);
1794
+ let index = 0;
1795
+ async function worker() {
1796
+ while (true) {
1797
+ const current = index;
1798
+ index += 1;
1799
+ if (current >= tasks.length) return;
1800
+ const task = tasks[current];
1801
+ if (!task) return;
1802
+ results[current] = await task();
1803
+ }
1804
+ }
1805
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
1806
+ return results;
1807
+ }
1808
+ async function checkExternalLinks(cfg, report) {
1809
+ if (!cfg.externalLinks) return [];
1810
+ if (cfg.external.maxExternalLinksChecked <= 0) return [];
1811
+ const urlCfg = { ...cfg.url, dropQueryParamsExceptAllowlist: false };
1812
+ const targets = /* @__PURE__ */ new Map();
1813
+ for (const page of report.pages) {
1814
+ const outbound = page.outboundLinks ?? [];
1815
+ for (const link of outbound) {
1816
+ if (link.internal) continue;
1817
+ const resolved = link.resolvedUrl;
1818
+ if (!isExternalToBase(cfg, resolved)) continue;
1819
+ const normalized = normalizeUrl(resolved, urlCfg).normalizedUrl;
1820
+ addReferrer2(targets, normalized, {
1821
+ pageUrl: page.finalUrl,
1822
+ url: page.finalUrl,
1823
+ anchorText: link.anchorText
1824
+ });
1825
+ }
1826
+ }
1827
+ const unique = [...targets.values()].sort((a, b) => a.url.localeCompare(b.url));
1828
+ const limited = unique.slice(0, cfg.external.maxExternalLinksChecked);
1829
+ if (unique.length > limited.length) {
1830
+ report.run.capsReached.externalLinksLimit = true;
1831
+ report.run.warnings.push(
1832
+ `External link cap reached (maxExternalLinksChecked=${cfg.external.maxExternalLinksChecked}); checked ${limited.length} of ${unique.length}`
1833
+ );
1834
+ }
1835
+ const hostDelay = new HostDelayLimiter(cfg.crawl.requestDelayMs);
1836
+ const tasks = limited.map((t) => async () => {
1837
+ await hostDelay.waitFor(t.url);
1838
+ const res = await fetchWithRedirects(t.url, {
1839
+ timeoutMs: cfg.timeoutMs,
1840
+ maxRedirects: cfg.maxRedirects,
1841
+ headers: cfg.headers,
1842
+ userAgent: cfg.userAgent,
1843
+ ignoreHttpsErrors: cfg.ignoreHttpsErrors
1844
+ });
1845
+ if (res.error) {
1846
+ const issue = {
1847
+ severity: "warning",
1848
+ type: "external_link_fetch_error",
1849
+ message: "Failed to fetch external link",
1850
+ context: {
1851
+ url: t.url,
1852
+ error: res.error,
1853
+ referrers: t.referrers.map((r) => ({ url: r.pageUrl, anchorText: r.anchorText }))
1854
+ }
1855
+ };
1856
+ return issue;
1857
+ }
1858
+ const status = res.status ?? 0;
1859
+ if (status >= 400) {
1860
+ const issue = {
1861
+ severity: "warning",
1862
+ type: "external_link_http_error",
1863
+ message: `HTTP ${status}`,
1864
+ context: {
1865
+ url: t.url,
1866
+ status,
1867
+ referrers: t.referrers.map((r) => ({ url: r.pageUrl, anchorText: r.anchorText }))
1868
+ }
1869
+ };
1870
+ return issue;
1871
+ }
1872
+ return null;
1873
+ });
1874
+ const results = await runWithConcurrency2(cfg.concurrency, tasks);
1875
+ return results.filter((i) => i !== null);
1876
+ }
1877
+
1878
+ // src/cli/qa-command.ts
1879
+ function parseBoolean(value) {
1880
+ if (typeof value === "boolean") return value;
1881
+ if (typeof value !== "string") return void 0;
1882
+ const lower = value.toLowerCase().trim();
1883
+ if (lower === "true" || lower === "1" || lower === "yes") return true;
1884
+ if (lower === "false" || lower === "0" || lower === "no") return false;
1885
+ return void 0;
1886
+ }
1887
+ function parseJsonObject(value) {
1888
+ if (!value) return {};
1889
+ const parsed = JSON.parse(value);
1890
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1891
+ throw new Error("--headers must be a JSON object");
1892
+ }
1893
+ const out = {};
1894
+ for (const [k, v] of Object.entries(parsed)) {
1895
+ if (typeof v !== "string") continue;
1896
+ out[k] = v;
1897
+ }
1898
+ return out;
1899
+ }
1900
+ function failWithHelp(program, io, exitCode2, message) {
1901
+ io.stderr.write(`${message}
1902
+ `);
1903
+ program.outputHelp();
1904
+ throw new CommanderError(exitCode2, "qa.usage", message);
1905
+ }
1906
+ function toNumber(value) {
1907
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1908
+ if (typeof value === "string" && value.trim() !== "") {
1909
+ const n = Number(value);
1910
+ return Number.isFinite(n) ? n : void 0;
1911
+ }
1912
+ return void 0;
1913
+ }
1914
+ function resolveMode(value) {
1915
+ if (value === "crawl" || value === "render" || value === "full") return value;
1916
+ return void 0;
1917
+ }
1918
+ function resolveReportFormat(value) {
1919
+ if (value === "html" || value === "json" || value === "both") return value;
1920
+ return void 0;
1921
+ }
1922
+ function resolveFailOn(value) {
1923
+ if (value === "none" || value === "info" || value === "warning" || value === "error") return value;
1924
+ return void 0;
1925
+ }
1926
+ function computeFailExitCode(failOn, issuesCount) {
1927
+ if (failOn === "none") return 0;
1928
+ if (failOn === "error") return issuesCount.error > 0 ? 2 : 0;
1929
+ if (failOn === "warning") return issuesCount.error + issuesCount.warning > 0 ? 2 : 0;
1930
+ return issuesCount.error + issuesCount.warning + issuesCount.info > 0 ? 2 : 0;
1931
+ }
1932
+ function countIssuesBySeverity(issues) {
1933
+ let error = 0;
1934
+ let warning = 0;
1935
+ let info = 0;
1936
+ for (const i of issues) {
1937
+ if (i.severity === "error") error += 1;
1938
+ else if (i.severity === "warning") warning += 1;
1939
+ else if (i.severity === "info") info += 1;
1940
+ }
1941
+ return { error, warning, info };
1942
+ }
1943
+ function validateUrlOrFail(program, io, url) {
1944
+ let parsed;
1945
+ try {
1946
+ parsed = new URL(url);
1947
+ } catch {
1948
+ failWithHelp(program, io, 1, `Invalid URL: ${url}`);
1949
+ }
1950
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1951
+ failWithHelp(program, io, 1, `URL must be http(s): ${url}`);
1952
+ }
1953
+ }
1954
+ async function runQaCommand(program, io, url, options) {
1955
+ if (!url) {
1956
+ failWithHelp(program, io, 1, "Missing required argument: url");
1957
+ }
1958
+ validateUrlOrFail(program, io, url);
1959
+ const defaults = defaultConfigForBaseUrl(url);
1960
+ const startedAtIso = (/* @__PURE__ */ new Date()).toISOString();
1961
+ let fileConfig = {};
1962
+ if (options.config) {
1963
+ try {
1964
+ fileConfig = loadConfigFile(options.config);
1965
+ } catch (error) {
1966
+ const msg = error instanceof Error ? error.message : String(error);
1967
+ failWithHelp(program, io, 1, `Failed to load config: ${msg}`);
1968
+ }
1969
+ }
1970
+ const cliHeadersFromJson = (() => {
1971
+ try {
1972
+ return parseJsonObject(options.headers);
1973
+ } catch (error) {
1974
+ const msg = error instanceof Error ? error.message : String(error);
1975
+ failWithHelp(program, io, 1, msg);
1976
+ }
1977
+ })();
1978
+ const cliHeadersFromPairs = parseHeaderPairs(options.header);
1979
+ const merged = mergeConfig(defaults, fileConfig);
1980
+ const outputRootDir = options.output ?? merged.outputDir;
1981
+ const parsedMode = resolveMode(options.mode);
1982
+ if (options.mode !== void 0 && !parsedMode) {
1983
+ failWithHelp(program, io, 1, `Invalid --mode: ${String(options.mode)} (expected crawl|render|full)`);
1984
+ }
1985
+ const parsedReportFormat = resolveReportFormat(options.reportFormat);
1986
+ if (options.reportFormat !== void 0 && !parsedReportFormat) {
1987
+ failWithHelp(
1988
+ program,
1989
+ io,
1990
+ 1,
1991
+ `Invalid --report-format: ${String(options.reportFormat)} (expected html|json|both)`
1992
+ );
1993
+ }
1994
+ const parsedFailOn = resolveFailOn(options.failOn);
1995
+ if (options.failOn !== void 0 && !parsedFailOn) {
1996
+ failWithHelp(program, io, 1, `Invalid --fail-on: ${String(options.failOn)} (expected none|info|warning|error)`);
1997
+ }
1998
+ const cfg = mergeConfig(merged, {
1999
+ outputDir: createRunOutputDir(outputRootDir, url, startedAtIso),
2000
+ mode: parsedMode ?? merged.mode,
2001
+ reportFormat: parsedReportFormat ?? merged.reportFormat,
2002
+ maxPages: toNumber(options.maxPages) ?? merged.maxPages,
2003
+ maxDepth: toNumber(options.maxDepth) ?? merged.maxDepth,
2004
+ render: {
2005
+ maxRenderPages: toNumber(options.maxRenderPages) ?? merged.render.maxRenderPages
2006
+ },
2007
+ maxRedirects: toNumber(options.maxRedirects) ?? merged.maxRedirects,
2008
+ concurrency: toNumber(options.concurrency) ?? merged.concurrency,
2009
+ timeoutMs: toNumber(options.timeout) ?? merged.timeoutMs,
2010
+ respectRobots: parseBoolean(options.respectRobots) ?? merged.respectRobots,
2011
+ sitemap: parseBoolean(options.sitemap) ?? merged.sitemap,
2012
+ externalLinks: parseBoolean(options.externalLinks) ?? merged.externalLinks,
2013
+ external: {
2014
+ maxExternalLinksChecked: toNumber(options.maxExternalLinks) ?? merged.external.maxExternalLinksChecked
2015
+ },
2016
+ ignoreHttpsErrors: parseBoolean(options.ignoreHttpsErrors) ?? merged.ignoreHttpsErrors,
2017
+ userAgent: options.userAgent ?? merged.userAgent,
2018
+ headers: {
2019
+ ...merged.headers,
2020
+ ...cliHeadersFromJson,
2021
+ ...cliHeadersFromPairs
2022
+ },
2023
+ include: options.include ?? merged.include,
2024
+ exclude: options.exclude ?? merged.exclude
2025
+ });
2026
+ if (cfg.maxPages <= 0) failWithHelp(program, io, 1, "--max-pages must be > 0");
2027
+ if (cfg.maxDepth < 0) failWithHelp(program, io, 1, "--max-depth must be >= 0");
2028
+ if (cfg.render.maxRenderPages <= 0) failWithHelp(program, io, 1, "--max-render-pages must be > 0");
2029
+ if (cfg.maxRedirects < 0) failWithHelp(program, io, 1, "--max-redirects must be >= 0");
2030
+ if (cfg.concurrency <= 0) failWithHelp(program, io, 1, "--concurrency must be > 0");
2031
+ if (cfg.timeoutMs <= 0) failWithHelp(program, io, 1, "--timeout must be > 0");
2032
+ if (cfg.crawl.perPathQueryVariantLimit <= 0) failWithHelp(program, io, 1, "crawl.perPathQueryVariantLimit must be > 0");
2033
+ if (cfg.crawl.paginationLimit <= 0) failWithHelp(program, io, 1, "crawl.paginationLimit must be > 0");
2034
+ if (cfg.crawl.requestDelayMs < 0) failWithHelp(program, io, 1, "crawl.requestDelayMs must be >= 0");
2035
+ if (cfg.external.maxExternalLinksChecked < 0) failWithHelp(program, io, 1, "external.maxExternalLinksChecked must be >= 0");
2036
+ try {
2037
+ validateRegexList(cfg.include, "--include");
2038
+ validateRegexList(cfg.exclude, "--exclude");
2039
+ validateRegexList(cfg.url.ignoredQueryParamPatterns, "url.ignoredQueryParamPatterns");
2040
+ } catch (error) {
2041
+ const msg = error instanceof Error ? error.message : String(error);
2042
+ failWithHelp(program, io, 1, msg);
2043
+ }
2044
+ cfg.url.allowlistedQueryParams = cfg.url.allowlistedQueryParams.map((s) => s.toLowerCase());
2045
+ cfg.url.trackingParamNames = cfg.url.trackingParamNames.map((s) => s.toLowerCase());
2046
+ cfg.url.trackingParamPrefixes = cfg.url.trackingParamPrefixes.map((s) => s.toLowerCase());
2047
+ cfg.crawl.paginationParamNames = cfg.crawl.paginationParamNames.map((s) => s.toLowerCase());
2048
+ const preWarnings = [];
2049
+ if (cfg.externalLinks) {
2050
+ if (cfg.external.maxExternalLinksChecked <= 0) {
2051
+ preWarnings.push(
2052
+ "External link checking is enabled but maxExternalLinksChecked=0; no external links will be checked."
2053
+ );
2054
+ }
2055
+ }
2056
+ let report = createEmptyReport(cfg, startedAtIso);
2057
+ if (cfg.mode === "crawl") {
2058
+ const crawled = await crawlSite(cfg);
2059
+ report = crawled.report;
2060
+ report.run.warnings.unshift(...preWarnings);
2061
+ report.issues = generateIssues(cfg, report);
2062
+ report.issues = [...report.issues ?? [], ...await checkExternalLinks(cfg, report)];
2063
+ } else if (cfg.mode === "full") {
2064
+ const crawled = await crawlSite(cfg);
2065
+ report = crawled.report;
2066
+ report.run.warnings.unshift(...preWarnings);
2067
+ report.issues = generateIssues(cfg, report);
2068
+ report.issues = [...report.issues ?? [], ...await checkExternalLinks(cfg, report)];
2069
+ const renderUrls = await selectRenderUrlsForFull(cfg, report);
2070
+ await renderPages(cfg, report, renderUrls);
2071
+ report.issues = [...report.issues ?? [], ...generateRenderIssues(report)];
2072
+ report.run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
2073
+ } else if (cfg.mode === "render") {
2074
+ report.run.warnings.unshift(...preWarnings);
2075
+ const renderUrls = await selectRenderUrlsForRenderOnly(cfg);
2076
+ await renderPages(cfg, report, renderUrls);
2077
+ report.issues = [...report.issues ?? [], ...generateRenderIssues(report)];
2078
+ report.run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
2079
+ }
2080
+ const outputs = [];
2081
+ if (cfg.reportFormat === "json" || cfg.reportFormat === "both") {
2082
+ outputs.push(writeJsonReport(cfg.outputDir, report));
2083
+ }
2084
+ if (cfg.reportFormat === "html" || cfg.reportFormat === "both") {
2085
+ outputs.push(writeHtmlReport(cfg.outputDir, report));
2086
+ }
2087
+ outputs.push(writeRunMetadata(cfg.outputDir, report, outputs));
2088
+ for (const out of outputs) io.stdout.write(`Wrote ${out}
2089
+ `);
2090
+ const effectiveFailOn = (options.ci ? "error" : void 0) ?? parsedFailOn ?? "none";
2091
+ const counts = countIssuesBySeverity(report.issues ?? []);
2092
+ const exitCode2 = computeFailExitCode(effectiveFailOn, counts);
2093
+ if (exitCode2 !== 0) {
2094
+ io.stderr.write(
2095
+ `qa: failing due to issues (error=${counts.error}, warning=${counts.warning}, info=${counts.info}); output: ${cfg.outputDir}
2096
+ `
2097
+ );
2098
+ throw new CommanderError(exitCode2, "qa.failOn", "Issues found");
2099
+ }
2100
+ }
2101
+
2102
+ // src/cli/program.ts
2103
+ function readPackageVersion() {
2104
+ try {
2105
+ const raw = readFileSync3(new URL("../../package.json", import.meta.url), "utf8");
2106
+ const pkg = JSON.parse(raw);
2107
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
2108
+ } catch {
2109
+ return "0.0.0";
2110
+ }
2111
+ }
2112
+ function createProgram(io) {
2113
+ const program = new Command();
2114
+ program.name("qa").description("Website QA CLI").version(readPackageVersion()).showHelpAfterError().configureOutput({
2115
+ writeOut: (str) => {
2116
+ io.stdout.write(str);
2117
+ },
2118
+ writeErr: (str) => {
2119
+ io.stderr.write(str);
2120
+ }
2121
+ });
2122
+ program.argument("[url]", "Base URL (e.g. https://example.com)").option("--config <path>", "Path to YAML/JSON config file").option("--output <dir>", "Output root directory", "qa-output").option("--mode <mode>", "crawl|render|full", "crawl").option("--ci", "CI mode (non-zero exit when issues found)").option("--fail-on <severity>", "none|info|warning|error", "none").option("--max-pages <n>", "Hard cap on total pages", (v) => Number(v)).option("--max-depth <n>", "Hard cap on link depth", (v) => Number(v)).option("--max-render-pages <n>", "Hard cap on rendered pages (Phase 4)", (v) => Number(v)).option("--concurrency <n>", "Concurrent requests", (v) => Number(v)).option("--timeout <ms>", "Request timeout in milliseconds", (v) => Number(v)).option("--respect-robots <boolean>", "Respect robots.txt (v1)").option("--sitemap <boolean>", "Seed from /sitemap.xml").option("--external-links <boolean>", "Check external links (v1)").option("--max-external-links <n>", "Hard cap on external links checked (v1)", (v) => Number(v)).option("--ignore-https-errors <boolean>", "Ignore HTTPS certificate errors").option("--user-agent <string>", "Custom user-agent").option("--headers <json>", `Headers JSON object (e.g. '{"K":"V"}')`).option(
2123
+ "--header <pair>",
2124
+ "Header pair (repeatable) (e.g. 'Authorization: Bearer ...')",
2125
+ (value, previous) => {
2126
+ const next = previous ?? [];
2127
+ next.push(value);
2128
+ return next;
2129
+ }
2130
+ ).option(
2131
+ "--include <pattern>",
2132
+ "Include path regex (repeatable)",
2133
+ (value, previous) => {
2134
+ const next = previous ?? [];
2135
+ next.push(value);
2136
+ return next;
2137
+ }
2138
+ ).option(
2139
+ "--exclude <pattern>",
2140
+ "Exclude path regex (repeatable)",
2141
+ (value, previous) => {
2142
+ const next = previous ?? [];
2143
+ next.push(value);
2144
+ return next;
2145
+ }
2146
+ ).option("--report-format <format>", "html|json|both", "both").action(async (url, options) => {
2147
+ await runQaCommand(program, io, url, options);
2148
+ });
2149
+ program.command("hello").description("Smoke-test command").argument("[name]", "Name to greet", "world").action((name) => {
2150
+ io.stdout.write(`Hello, ${name}!
2151
+ `);
2152
+ });
2153
+ return program;
2154
+ }
2155
+
2156
+ // src/cli/run.ts
2157
+ async function runCli(argv, io) {
2158
+ const program = createProgram(io);
2159
+ program.exitOverride();
2160
+ try {
2161
+ await program.parseAsync(argv);
2162
+ return 0;
2163
+ } catch (error) {
2164
+ if (error instanceof CommanderError2) {
2165
+ if (error.code === "commander.helpDisplayed") return 0;
2166
+ return typeof error.exitCode === "number" ? error.exitCode : 1;
2167
+ }
2168
+ throw error;
2169
+ }
2170
+ }
2171
+
2172
+ // src/cli.ts
2173
+ var exitCode = await runCli(process.argv, {
2174
+ stdout: process.stdout,
2175
+ stderr: process.stderr
2176
+ });
2177
+ process.exitCode = exitCode;
2178
+ //# sourceMappingURL=cli.js.map