@reddoorla/maintenance 0.49.0 → 0.51.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.
@@ -1,4 +1,4 @@
1
- import { S as Site, a as AuditResult, A as AuditName } from '../../types-DeKpgkG-.js';
1
+ import { S as Site, a as AuditResult, A as AuditName } from '../../types-QG-QhCYh.js';
2
2
 
3
3
  type AuditCommandOptions = {
4
4
  only?: string;
@@ -32,10 +32,8 @@ declare function parseConcurrency(value: string | undefined): boolean | number;
32
32
  * null when there's nothing to warn about. Keeps the mixed-provenance result
33
33
  * table from silently confusing the operator. */
34
34
  declare function deployedUrlNotice(which: AuditName[], url: string | undefined, cwd: string): string | null;
35
- /** A fleet site needs a local checkout unless every requested audit can run
36
- * against its deployed URL. Today only lighthouse has a deployed mode, so a
37
- * site is checkout-free exactly when it has a `deployedUrl` and lighthouse is
38
- * the only requested audit. */
35
+ /** A fleet site needs a local checkout unless every requested audit is checkout-free AND the site
36
+ * has a `deployedUrl` for them to run against. */
39
37
  declare function auditNeedsCheckout(site: Site, which: AuditName[]): boolean;
40
38
  /** Apply a single-site `--url` to the resolved sites. Returns the input
41
39
  * untouched when no url is given; otherwise requires exactly one site and
@@ -38,6 +38,38 @@ var init_credentials = __esm({
38
38
  }
39
39
  });
40
40
 
41
+ // src/reports/airtable/throttle.ts
42
+ function createMinIntervalThrottle(opts) {
43
+ const { minIntervalMs, now, delay } = opts;
44
+ return function wrap(fn) {
45
+ let chain = Promise.resolve();
46
+ let last = Number.NEGATIVE_INFINITY;
47
+ return (...args) => {
48
+ chain = chain.then(async () => {
49
+ const wait = minIntervalMs - (now() - last);
50
+ if (wait > 0) await delay(wait);
51
+ last = now();
52
+ fn(...args);
53
+ }).catch(() => {
54
+ });
55
+ };
56
+ };
57
+ }
58
+ function applyThrottle(base, opts) {
59
+ const real = base._base?.runAction;
60
+ if (typeof real !== "function") return base;
61
+ const wrap = createMinIntervalThrottle(opts);
62
+ const throttled = wrap(real.bind(base._base));
63
+ base._base.runAction = throttled;
64
+ base.runAction = throttled;
65
+ return base;
66
+ }
67
+ var init_throttle = __esm({
68
+ "src/reports/airtable/throttle.ts"() {
69
+ "use strict";
70
+ }
71
+ });
72
+
41
73
  // src/reports/airtable/client.ts
42
74
  var client_exports = {};
43
75
  __export(client_exports, {
@@ -61,12 +93,20 @@ function readAirtableConfig() {
61
93
  return { apiKey, baseId };
62
94
  }
63
95
  function openBase(cfg) {
64
- return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
96
+ const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
97
+ return applyThrottle(base, {
98
+ minIntervalMs: MIN_REQUEST_INTERVAL_MS,
99
+ now: () => Date.now(),
100
+ delay: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms))
101
+ });
65
102
  }
103
+ var MIN_REQUEST_INTERVAL_MS;
66
104
  var init_client = __esm({
67
105
  "src/reports/airtable/client.ts"() {
68
106
  "use strict";
69
107
  init_credentials();
108
+ init_throttle();
109
+ MIN_REQUEST_INTERVAL_MS = 220;
70
110
  }
71
111
  });
72
112
 
@@ -74,12 +114,15 @@ var init_client = __esm({
74
114
  var websites_exports = {};
75
115
  __export(websites_exports, {
76
116
  ACTIVE_STATUSES: () => ACTIVE_STATUSES,
117
+ SEVERITY_RANK: () => SEVERITY_RANK,
77
118
  WEBSITES_TABLE: () => WEBSITES_TABLE,
78
119
  getWebsiteBySlug: () => getWebsiteBySlug,
79
120
  isDashboardVisible: () => isDashboardVisible,
80
121
  listWebsites: () => listWebsites,
81
122
  mapRow: () => mapRow,
123
+ normalizeSecurityAdvisory: () => normalizeSecurityAdvisory,
82
124
  parseNotifyRouting: () => parseNotifyRouting,
125
+ parseSecurityAdvisories: () => parseSecurityAdvisories,
83
126
  siteSlug: () => siteSlug,
84
127
  updateA11yCounts: () => updateA11yCounts,
85
128
  updateAuditFields: () => updateAuditFields,
@@ -159,6 +202,15 @@ function mapRow(rec) {
159
202
  securityVulnsHigh: f["Security Vulns High"] ?? null,
160
203
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
161
204
  securityVulnsLow: f["Security Vulns Low"] ?? null,
205
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
206
+ securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
207
+ certDaysRemaining: f["Cert days remaining"] ?? null,
208
+ domainCheckedAt: f["Domain checked at"] ?? null,
209
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
210
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
211
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
212
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
213
+ browserCheckedAt: f["Browser checked at"] ?? null,
162
214
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
163
215
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
164
216
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -215,12 +267,61 @@ function depsFields(counts) {
215
267
  }
216
268
  return fields;
217
269
  }
270
+ function normalizeSecurityAdvisory(raw) {
271
+ if (!raw || typeof raw !== "object") return null;
272
+ const e = raw;
273
+ const module = typeof e["module"] === "string" ? e["module"] : null;
274
+ const severity = e["severity"];
275
+ if (module === null) return null;
276
+ if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
277
+ return null;
278
+ const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
279
+ return {
280
+ module,
281
+ severity,
282
+ title: typeof e["title"] === "string" ? e["title"] : "",
283
+ cves,
284
+ url: typeof e["url"] === "string" ? e["url"] : null
285
+ };
286
+ }
287
+ function parseSecurityAdvisories(raw) {
288
+ if (typeof raw !== "string" || raw.trim() === "") return null;
289
+ let parsed;
290
+ try {
291
+ parsed = JSON.parse(raw);
292
+ } catch {
293
+ return null;
294
+ }
295
+ if (!Array.isArray(parsed)) return null;
296
+ return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
297
+ }
298
+ function securityAdvisoryFields(advisories) {
299
+ const capped = [...advisories].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]).slice(0, MAX_PERSISTED_ADVISORIES);
300
+ return { "Security advisories": JSON.stringify(capped) };
301
+ }
218
302
  function securityFields(counts) {
219
303
  return {
220
304
  "Security Vulns Critical": counts.critical,
221
305
  "Security Vulns High": counts.high,
222
306
  "Security Vulns Moderate": counts.moderate,
223
- "Security Vulns Low": counts.low
307
+ "Security Vulns Low": counts.low,
308
+ // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent
309
+ // audit (a clean count from months ago must not silently keep ticking the box).
310
+ "Last security audit at": (/* @__PURE__ */ new Date()).toISOString()
311
+ };
312
+ }
313
+ function domainFields(result) {
314
+ const fields = { "Domain checked at": result.checkedAt };
315
+ if (result.certDaysRemaining !== null) fields["Cert days remaining"] = result.certDaysRemaining;
316
+ return fields;
317
+ }
318
+ function browserFields(r) {
319
+ return {
320
+ "Crossbrowser OK": r.desktopOk,
321
+ "Mobile OK": r.mobileOk,
322
+ "Links OK": r.linksOk,
323
+ "Broken links": r.brokenLinks,
324
+ "Browser checked at": r.checkedAt
224
325
  };
225
326
  }
226
327
  async function updateScores(base, recordId, scores) {
@@ -241,6 +342,10 @@ async function updateAuditFields(base, recordId, audits) {
241
342
  if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
242
343
  if (audits.deps) Object.assign(fields, depsFields(audits.deps));
243
344
  if (audits.security) Object.assign(fields, securityFields(audits.security));
345
+ if (audits.securityAdvisories)
346
+ Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));
347
+ if (audits.domain) Object.assign(fields, domainFields(audits.domain));
348
+ if (audits.browser) Object.assign(fields, browserFields(audits.browser));
244
349
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
245
350
  return fields;
246
351
  }
@@ -259,7 +364,7 @@ async function updateLaunched(base, recordId, at) {
259
364
  const fields = { Status: "maintenance", "Launched at": at };
260
365
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
261
366
  }
262
- var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
367
+ var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES, SEVERITY_RANK, MAX_PERSISTED_ADVISORIES;
263
368
  var init_websites = __esm({
264
369
  "src/reports/airtable/websites.ts"() {
265
370
  "use strict";
@@ -269,6 +374,13 @@ var init_websites = __esm({
269
374
  "launch period"
270
375
  ]);
271
376
  FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
377
+ SEVERITY_RANK = {
378
+ critical: 0,
379
+ high: 1,
380
+ moderate: 2,
381
+ low: 3
382
+ };
383
+ MAX_PERSISTED_ADVISORIES = 25;
272
384
  }
273
385
  });
274
386
 
@@ -429,9 +541,66 @@ function securityCountsFromResult(result) {
429
541
  const c = details?.counts ?? { low: 0, moderate: 0, high: 0, critical: 0 };
430
542
  return { critical: c.critical, high: c.high, moderate: c.moderate, low: c.low };
431
543
  }
544
+ function advisoriesFromResult(result) {
545
+ if (result.audit !== "security") {
546
+ throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);
547
+ }
548
+ const details = result.details;
549
+ const raw = details?.advisories;
550
+ if (!Array.isArray(raw)) return [];
551
+ return raw.map(normalizeSecurityAdvisory).filter((a) => a !== null);
552
+ }
432
553
  var init_security_airtable = __esm({
433
554
  "src/audits/security-airtable.ts"() {
434
555
  "use strict";
556
+ init_websites();
557
+ }
558
+ });
559
+
560
+ // src/audits/domain-airtable.ts
561
+ function hasDomainResult(result) {
562
+ if (result.audit !== "domain") return false;
563
+ const d = result.details;
564
+ return !!d && typeof d.checkedAt === "string";
565
+ }
566
+ function domainResultFromAudit(result) {
567
+ if (result.audit !== "domain") {
568
+ throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);
569
+ }
570
+ const d = result.details;
571
+ return {
572
+ certDaysRemaining: typeof d?.certDaysRemaining === "number" ? d.certDaysRemaining : null,
573
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
574
+ };
575
+ }
576
+ var init_domain_airtable = __esm({
577
+ "src/audits/domain-airtable.ts"() {
578
+ "use strict";
579
+ }
580
+ });
581
+
582
+ // src/audits/browser-airtable.ts
583
+ function hasBrowserResult(result) {
584
+ if (result.audit !== "browser") return false;
585
+ const d = result.details;
586
+ return !!d && typeof d.checkedAt === "string";
587
+ }
588
+ function browserFieldsFromAudit(result) {
589
+ if (result.audit !== "browser") {
590
+ throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);
591
+ }
592
+ const d = result.details;
593
+ return {
594
+ desktopOk: d?.desktopOk === true,
595
+ mobileOk: d?.mobileOk === true,
596
+ linksOk: d?.linksOk === true,
597
+ brokenLinks: typeof d?.brokenLinks === "number" ? d.brokenLinks : 0,
598
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
599
+ };
600
+ }
601
+ var init_browser_airtable = __esm({
602
+ "src/audits/browser-airtable.ts"() {
603
+ "use strict";
435
604
  }
436
605
  });
437
606
 
@@ -445,22 +614,14 @@ __export(write_audits_to_airtable_exports, {
445
614
  async function writeAuditsToAirtable(args) {
446
615
  const { base, websites, slug, results } = args;
447
616
  const lhResult = results.find((r) => r.audit === "lighthouse");
448
- if (!lhResult) {
449
- throw Object.assign(
450
- new Error(
451
- "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
452
- ),
453
- { exitCode: 2 }
454
- );
455
- }
456
617
  const target = websites.find((w) => siteSlug(w.name) === slug);
457
618
  if (!target) {
458
619
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
459
620
  }
460
621
  const writes = [];
461
622
  const audits = {};
462
- const lhHasScores = hasRealScores(lhResult);
463
- if (lhHasScores) {
623
+ const lhHasScores = lhResult ? hasRealScores(lhResult) : false;
624
+ if (lhResult && lhHasScores) {
464
625
  const scores = lighthouseScoresFromResult(lhResult);
465
626
  audits.scores = scores;
466
627
  writes.push({ audit: "lighthouse", counts: scores });
@@ -481,12 +642,25 @@ async function writeAuditsToAirtable(args) {
481
642
  if (sec && hasSecurityCounts(sec)) {
482
643
  const counts = securityCountsFromResult(sec);
483
644
  audits.security = counts;
645
+ audits.securityAdvisories = advisoriesFromResult(sec);
484
646
  writes.push({ audit: "security", counts });
485
647
  }
648
+ const dom = results.find((r) => r.audit === "domain");
649
+ if (dom && hasDomainResult(dom)) {
650
+ const result = domainResultFromAudit(dom);
651
+ audits.domain = result;
652
+ writes.push({ audit: "domain", counts: result });
653
+ }
654
+ const browser = results.find((r) => r.audit === "browser");
655
+ if (browser && hasBrowserResult(browser)) {
656
+ const fields = browserFieldsFromAudit(browser);
657
+ audits.browser = fields;
658
+ writes.push({ audit: "browser", counts: fields });
659
+ }
486
660
  if (Object.keys(audits).length > 0) {
487
661
  await updateAuditFields(base, target.id, audits);
488
662
  }
489
- if (!lhHasScores) {
663
+ if (lhResult && !lhHasScores) {
490
664
  const persisted = writes.map((w) => w.audit);
491
665
  throw Object.assign(
492
666
  new Error(
@@ -537,6 +711,8 @@ var init_write_audits_to_airtable = __esm({
537
711
  init_a11y_airtable();
538
712
  init_deps_airtable();
539
713
  init_security_airtable();
714
+ init_domain_airtable();
715
+ init_browser_airtable();
540
716
  }
541
717
  });
542
718
 
@@ -1485,13 +1661,348 @@ async function a11yAudit(ctx) {
1485
1661
  }
1486
1662
  }
1487
1663
 
1664
+ // src/audits/domain.ts
1665
+ import { promises as dnsPromises } from "dns";
1666
+ import tls from "tls";
1667
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
1668
+ async function checkDomain(url, deps) {
1669
+ let host;
1670
+ try {
1671
+ host = new URL(url).hostname;
1672
+ } catch {
1673
+ return { resolved: false, certDaysRemaining: null };
1674
+ }
1675
+ try {
1676
+ await deps.lookup(host);
1677
+ } catch {
1678
+ return { resolved: false, certDaysRemaining: null };
1679
+ }
1680
+ let validTo;
1681
+ try {
1682
+ validTo = await deps.certValidTo(host);
1683
+ } catch {
1684
+ validTo = null;
1685
+ }
1686
+ if (!validTo || Number.isNaN(validTo.getTime()))
1687
+ return { resolved: true, certDaysRemaining: null };
1688
+ return {
1689
+ resolved: true,
1690
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
1691
+ };
1692
+ }
1693
+ function defaultDomainDeps(now) {
1694
+ return {
1695
+ lookup: async (host) => {
1696
+ await dnsPromises.lookup(host);
1697
+ },
1698
+ certValidTo: (host) => new Promise((resolvePromise) => {
1699
+ const socket = tls.connect(
1700
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
1701
+ () => {
1702
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
1703
+ socket.end();
1704
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
1705
+ resolvePromise(validTo);
1706
+ }
1707
+ );
1708
+ socket.on("error", () => resolvePromise(null));
1709
+ socket.on("timeout", () => {
1710
+ socket.destroy();
1711
+ resolvePromise(null);
1712
+ });
1713
+ }),
1714
+ now
1715
+ };
1716
+ }
1717
+ async function domainAudit(ctx) {
1718
+ const { site } = ctx;
1719
+ const label = siteLabel(site);
1720
+ if (!site.deployedUrl) {
1721
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
1722
+ }
1723
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1724
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
1725
+ const check = await checkDomain(site.deployedUrl, deps);
1726
+ const checkedAt = now.toISOString();
1727
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
1728
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
1729
+ return {
1730
+ audit: "domain",
1731
+ site: label,
1732
+ status,
1733
+ summary,
1734
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
1735
+ };
1736
+ }
1737
+
1738
+ // src/audits/route-discovery.ts
1739
+ var DEFAULT_CAP = 15;
1740
+ function parseSitemapUrls(xml) {
1741
+ const out = [];
1742
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
1743
+ let m;
1744
+ while ((m = re.exec(xml)) !== null) {
1745
+ const url = m[1];
1746
+ if (url) out.push(url.trim());
1747
+ }
1748
+ return out;
1749
+ }
1750
+ function parseHtmlLinks(html, baseUrl) {
1751
+ const out = /* @__PURE__ */ new Set();
1752
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
1753
+ let m;
1754
+ while ((m = re.exec(html)) !== null) {
1755
+ const href = m[1];
1756
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
1757
+ try {
1758
+ const u = new URL(href, baseUrl);
1759
+ if (u.origin !== new URL(baseUrl).origin) continue;
1760
+ out.add(u.pathname);
1761
+ } catch {
1762
+ }
1763
+ }
1764
+ return [...out];
1765
+ }
1766
+ function family(pathname) {
1767
+ return pathname.split("/").filter(Boolean)[0] ?? "";
1768
+ }
1769
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
1770
+ const seen = /* @__PURE__ */ new Set(["/"]);
1771
+ const buckets = /* @__PURE__ */ new Map();
1772
+ for (const raw of urlsOrPaths) {
1773
+ let pathname;
1774
+ try {
1775
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
1776
+ } catch {
1777
+ continue;
1778
+ }
1779
+ if (pathname === "/") continue;
1780
+ if (seen.has(pathname)) continue;
1781
+ seen.add(pathname);
1782
+ const fam = family(pathname);
1783
+ const arr = buckets.get(fam) ?? [];
1784
+ arr.push(pathname);
1785
+ buckets.set(fam, arr);
1786
+ }
1787
+ const result = ["/"];
1788
+ const families = [...buckets.values()];
1789
+ let guard = 0;
1790
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
1791
+ for (const fam of families) {
1792
+ if (result.length >= cap) break;
1793
+ const next = fam.shift();
1794
+ if (next) result.push(next);
1795
+ }
1796
+ }
1797
+ return result;
1798
+ }
1799
+ function familyCountsOf(paths) {
1800
+ const counts = {};
1801
+ for (const p of paths) {
1802
+ const key = p === "/" ? "/" : `/${family(p)}`;
1803
+ counts[key] = (counts[key] ?? 0) + 1;
1804
+ }
1805
+ return counts;
1806
+ }
1807
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
1808
+ const origin = new URL(deployedUrl).origin;
1809
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
1810
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
1811
+ if (sitemapXml) {
1812
+ const urls = parseSitemapUrls(sitemapXml);
1813
+ if (urls.length > 0) {
1814
+ const paths = sampleRoutePaths(urls, cap);
1815
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
1816
+ }
1817
+ }
1818
+ const homeHtml = await deps.fetchText(origin);
1819
+ if (homeHtml) {
1820
+ const links = parseHtmlLinks(homeHtml, origin);
1821
+ if (links.length > 0) {
1822
+ const paths = sampleRoutePaths(links, cap);
1823
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
1824
+ }
1825
+ }
1826
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
1827
+ }
1828
+
1829
+ // src/audits/browser.ts
1830
+ function isBroken(status) {
1831
+ return status === null || status >= 400;
1832
+ }
1833
+ function summarizeBrowser(routes, links, familyCounts) {
1834
+ const desktopChecks = routes.flatMap((r) => r.desktop);
1835
+ const mobileChecks = routes.flatMap((r) => r.mobile);
1836
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
1837
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
1838
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
1839
+ const linksOk = links.length > 0 && brokenLinks === 0;
1840
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
1841
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
1842
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
1843
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
1844
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
1845
+ }
1846
+ async function browserAudit(ctx) {
1847
+ const { site } = ctx;
1848
+ const label = siteLabel(site);
1849
+ if (!site.deployedUrl) {
1850
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
1851
+ }
1852
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1853
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
1854
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
1855
+ try {
1856
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
1857
+ const routeResults = await runner.probe(discovered.routes);
1858
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
1859
+ const linkResults = await runner.checkLinks(internalLinks);
1860
+ const summary = summarizeBrowser(
1861
+ routeResults,
1862
+ linkResults,
1863
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
1864
+ );
1865
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
1866
+ return {
1867
+ audit: "browser",
1868
+ site: label,
1869
+ status,
1870
+ summary: summary.note,
1871
+ details: { ...summary, checkedAt: now.toISOString() }
1872
+ };
1873
+ } finally {
1874
+ await runner.close?.();
1875
+ }
1876
+ }
1877
+ function defaultDiscoverDeps() {
1878
+ return {
1879
+ fetchText: async (url) => {
1880
+ try {
1881
+ const res = await fetch(url, { redirect: "follow" });
1882
+ if (!res.ok) return null;
1883
+ return await res.text();
1884
+ } catch {
1885
+ return null;
1886
+ }
1887
+ }
1888
+ };
1889
+ }
1890
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
1891
+ var PAGE_TIMEOUT_MS = 3e4;
1892
+ async function defaultBrowserRunner() {
1893
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
1894
+ const desktopEngines = [
1895
+ { engine: "chromium", type: chromium },
1896
+ { engine: "firefox", type: firefox },
1897
+ { engine: "webkit", type: webkit }
1898
+ ];
1899
+ const mobileTargets = [
1900
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
1901
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
1902
+ ];
1903
+ return {
1904
+ async probe(urls) {
1905
+ const results = [];
1906
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
1907
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
1908
+ try {
1909
+ for (const url of urls) {
1910
+ const desktop = [];
1911
+ const linkSet = /* @__PURE__ */ new Set();
1912
+ for (let i = 0; i < desktopEngines.length; i++) {
1913
+ const engine = desktopEngines[i].engine;
1914
+ const browser = browsers[i];
1915
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
1916
+ const page = await ctx.newPage();
1917
+ const errors = [];
1918
+ page.on("pageerror", (e) => errors.push(String(e)));
1919
+ let ok = false;
1920
+ try {
1921
+ const resp = await page.goto(url, {
1922
+ waitUntil: "domcontentloaded",
1923
+ timeout: PAGE_TIMEOUT_MS
1924
+ });
1925
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
1926
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
1927
+ if (engine === "chromium") {
1928
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
1929
+ const origin = new URL(url).origin;
1930
+ for (const h of hrefs) {
1931
+ try {
1932
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
1933
+ } catch {
1934
+ }
1935
+ }
1936
+ }
1937
+ } catch {
1938
+ ok = false;
1939
+ } finally {
1940
+ await ctx.close().catch(() => {
1941
+ });
1942
+ }
1943
+ desktop.push({ engine, ok });
1944
+ }
1945
+ const mobile = [];
1946
+ for (let i = 0; i < mobileTargets.length; i++) {
1947
+ const { device, descriptor } = mobileTargets[i];
1948
+ const browser = mobileBrowsers[i];
1949
+ const ctx = await browser.newContext({ ...descriptor });
1950
+ const page = await ctx.newPage();
1951
+ const errors = [];
1952
+ page.on("pageerror", (e) => errors.push(String(e)));
1953
+ let ok = false;
1954
+ try {
1955
+ const resp = await page.goto(url, {
1956
+ waitUntil: "domcontentloaded",
1957
+ timeout: PAGE_TIMEOUT_MS
1958
+ });
1959
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
1960
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
1961
+ } catch {
1962
+ ok = false;
1963
+ } finally {
1964
+ await ctx.close().catch(() => {
1965
+ });
1966
+ }
1967
+ mobile.push({ device, ok });
1968
+ }
1969
+ results.push({ url, desktop, mobile, links: [...linkSet] });
1970
+ }
1971
+ } finally {
1972
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
1973
+ })));
1974
+ }
1975
+ return results;
1976
+ },
1977
+ async checkLinks(urls) {
1978
+ const out = [];
1979
+ for (const url of urls) {
1980
+ let status;
1981
+ try {
1982
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
1983
+ if (res.status === 405 || res.status === 501) {
1984
+ res = await fetch(url, { method: "GET", redirect: "follow" });
1985
+ }
1986
+ status = res.status;
1987
+ } catch {
1988
+ status = null;
1989
+ }
1990
+ out.push({ url, status });
1991
+ }
1992
+ return out;
1993
+ }
1994
+ };
1995
+ }
1996
+
1488
1997
  // src/audits/index.ts
1489
1998
  var REGISTRY = {
1490
1999
  deps: depsAudit,
1491
2000
  lint: lintAudit,
1492
2001
  security: securityAudit,
1493
2002
  lighthouse: lighthouseAudit,
1494
- a11y: a11yAudit
2003
+ a11y: a11yAudit,
2004
+ domain: domainAudit,
2005
+ browser: browserAudit
1495
2006
  };
1496
2007
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
1497
2008
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -1902,8 +2413,13 @@ function deployedUrlNotice(which, url, cwd) {
1902
2413
  if (others.length === 0) return null;
1903
2414
  return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
1904
2415
  }
2416
+ var CHECKOUT_FREE_AUDITS = /* @__PURE__ */ new Set([
2417
+ "lighthouse",
2418
+ "domain",
2419
+ "browser"
2420
+ ]);
1905
2421
  function auditNeedsCheckout(site, which) {
1906
- const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
2422
+ const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => CHECKOUT_FREE_AUDITS.has(n));
1907
2423
  return !deployedCapable;
1908
2424
  }
1909
2425
  function applyDeployedUrl(sites, url) {