@reddoorla/maintenance 0.47.0 → 0.50.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
@@ -122,6 +122,9 @@ function parseNotifyRouting(raw) {
122
122
  function isDashboardVisible(site) {
123
123
  return site.status !== null && ACTIVE_STATUSES.has(site.status);
124
124
  }
125
+ function toFrequency(raw) {
126
+ return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
127
+ }
125
128
  function mapRow(rec) {
126
129
  const f = rec.fields;
127
130
  const attachments = f["Header image"] ?? [];
@@ -132,8 +135,8 @@ function mapRow(rec) {
132
135
  url: String(f["url"] ?? ""),
133
136
  status: f["Status"] ?? null,
134
137
  pointOfContact: f["point of contact"] ?? null,
135
- maintenanceFreq: f["maintenence freq"] ?? "None",
136
- testingFreq: f["testing freq"] ?? "None",
138
+ maintenanceFreq: toFrequency(f["maintenence freq"]),
139
+ testingFreq: toFrequency(f["testing freq"]),
137
140
  maintenanceDay: f["maintenance day"] ?? null,
138
141
  testingDay: f["testing day"] ?? null,
139
142
  ga4PropertyId: f["GA4 property ID"] ?? null,
@@ -156,6 +159,14 @@ function mapRow(rec) {
156
159
  securityVulnsHigh: f["Security Vulns High"] ?? null,
157
160
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
158
161
  securityVulnsLow: f["Security Vulns Low"] ?? null,
162
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
163
+ certDaysRemaining: f["Cert days remaining"] ?? null,
164
+ domainCheckedAt: f["Domain checked at"] ?? null,
165
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
166
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
167
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
168
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
169
+ browserCheckedAt: f["Browser checked at"] ?? null,
159
170
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
160
171
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
161
172
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -217,7 +228,24 @@ function securityFields(counts) {
217
228
  "Security Vulns Critical": counts.critical,
218
229
  "Security Vulns High": counts.high,
219
230
  "Security Vulns Moderate": counts.moderate,
220
- "Security Vulns Low": counts.low
231
+ "Security Vulns Low": counts.low,
232
+ // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent
233
+ // audit (a clean count from months ago must not silently keep ticking the box).
234
+ "Last security audit at": (/* @__PURE__ */ new Date()).toISOString()
235
+ };
236
+ }
237
+ function domainFields(result) {
238
+ const fields = { "Domain checked at": result.checkedAt };
239
+ if (result.certDaysRemaining !== null) fields["Cert days remaining"] = result.certDaysRemaining;
240
+ return fields;
241
+ }
242
+ function browserFields(r) {
243
+ return {
244
+ "Crossbrowser OK": r.desktopOk,
245
+ "Mobile OK": r.mobileOk,
246
+ "Links OK": r.linksOk,
247
+ "Broken links": r.brokenLinks,
248
+ "Browser checked at": r.checkedAt
221
249
  };
222
250
  }
223
251
  async function updateScores(base, recordId, scores) {
@@ -238,6 +266,8 @@ async function updateAuditFields(base, recordId, audits) {
238
266
  if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
239
267
  if (audits.deps) Object.assign(fields, depsFields(audits.deps));
240
268
  if (audits.security) Object.assign(fields, securityFields(audits.security));
269
+ if (audits.domain) Object.assign(fields, domainFields(audits.domain));
270
+ if (audits.browser) Object.assign(fields, browserFields(audits.browser));
241
271
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
242
272
  return fields;
243
273
  }
@@ -256,7 +286,7 @@ async function updateLaunched(base, recordId, at) {
256
286
  const fields = { Status: "maintenance", "Launched at": at };
257
287
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
258
288
  }
259
- var WEBSITES_TABLE, ACTIVE_STATUSES;
289
+ var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
260
290
  var init_websites = __esm({
261
291
  "src/reports/airtable/websites.ts"() {
262
292
  "use strict";
@@ -265,6 +295,7 @@ var init_websites = __esm({
265
295
  "maintenance",
266
296
  "launch period"
267
297
  ]);
298
+ FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
268
299
  }
269
300
  });
270
301
 
@@ -431,6 +462,53 @@ var init_security_airtable = __esm({
431
462
  }
432
463
  });
433
464
 
465
+ // src/audits/domain-airtable.ts
466
+ function hasDomainResult(result) {
467
+ if (result.audit !== "domain") return false;
468
+ const d = result.details;
469
+ return !!d && typeof d.checkedAt === "string";
470
+ }
471
+ function domainResultFromAudit(result) {
472
+ if (result.audit !== "domain") {
473
+ throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);
474
+ }
475
+ const d = result.details;
476
+ return {
477
+ certDaysRemaining: typeof d?.certDaysRemaining === "number" ? d.certDaysRemaining : null,
478
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
479
+ };
480
+ }
481
+ var init_domain_airtable = __esm({
482
+ "src/audits/domain-airtable.ts"() {
483
+ "use strict";
484
+ }
485
+ });
486
+
487
+ // src/audits/browser-airtable.ts
488
+ function hasBrowserResult(result) {
489
+ if (result.audit !== "browser") return false;
490
+ const d = result.details;
491
+ return !!d && typeof d.checkedAt === "string";
492
+ }
493
+ function browserFieldsFromAudit(result) {
494
+ if (result.audit !== "browser") {
495
+ throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);
496
+ }
497
+ const d = result.details;
498
+ return {
499
+ desktopOk: d?.desktopOk === true,
500
+ mobileOk: d?.mobileOk === true,
501
+ linksOk: d?.linksOk === true,
502
+ brokenLinks: typeof d?.brokenLinks === "number" ? d.brokenLinks : 0,
503
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
504
+ };
505
+ }
506
+ var init_browser_airtable = __esm({
507
+ "src/audits/browser-airtable.ts"() {
508
+ "use strict";
509
+ }
510
+ });
511
+
434
512
  // src/audits/write-audits-to-airtable.ts
435
513
  var write_audits_to_airtable_exports = {};
436
514
  __export(write_audits_to_airtable_exports, {
@@ -441,22 +519,14 @@ __export(write_audits_to_airtable_exports, {
441
519
  async function writeAuditsToAirtable(args) {
442
520
  const { base, websites, slug, results } = args;
443
521
  const lhResult = results.find((r) => r.audit === "lighthouse");
444
- if (!lhResult) {
445
- throw Object.assign(
446
- new Error(
447
- "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
448
- ),
449
- { exitCode: 2 }
450
- );
451
- }
452
522
  const target = websites.find((w) => siteSlug(w.name) === slug);
453
523
  if (!target) {
454
524
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
455
525
  }
456
526
  const writes = [];
457
527
  const audits = {};
458
- const lhHasScores = hasRealScores(lhResult);
459
- if (lhHasScores) {
528
+ const lhHasScores = lhResult ? hasRealScores(lhResult) : false;
529
+ if (lhResult && lhHasScores) {
460
530
  const scores = lighthouseScoresFromResult(lhResult);
461
531
  audits.scores = scores;
462
532
  writes.push({ audit: "lighthouse", counts: scores });
@@ -479,10 +549,22 @@ async function writeAuditsToAirtable(args) {
479
549
  audits.security = counts;
480
550
  writes.push({ audit: "security", counts });
481
551
  }
552
+ const dom = results.find((r) => r.audit === "domain");
553
+ if (dom && hasDomainResult(dom)) {
554
+ const result = domainResultFromAudit(dom);
555
+ audits.domain = result;
556
+ writes.push({ audit: "domain", counts: result });
557
+ }
558
+ const browser = results.find((r) => r.audit === "browser");
559
+ if (browser && hasBrowserResult(browser)) {
560
+ const fields = browserFieldsFromAudit(browser);
561
+ audits.browser = fields;
562
+ writes.push({ audit: "browser", counts: fields });
563
+ }
482
564
  if (Object.keys(audits).length > 0) {
483
565
  await updateAuditFields(base, target.id, audits);
484
566
  }
485
- if (!lhHasScores) {
567
+ if (lhResult && !lhHasScores) {
486
568
  const persisted = writes.map((w) => w.audit);
487
569
  throw Object.assign(
488
570
  new Error(
@@ -533,6 +615,8 @@ var init_write_audits_to_airtable = __esm({
533
615
  init_a11y_airtable();
534
616
  init_deps_airtable();
535
617
  init_security_airtable();
618
+ init_domain_airtable();
619
+ init_browser_airtable();
536
620
  }
537
621
  });
538
622
 
@@ -1481,13 +1565,348 @@ async function a11yAudit(ctx) {
1481
1565
  }
1482
1566
  }
1483
1567
 
1568
+ // src/audits/domain.ts
1569
+ import { promises as dnsPromises } from "dns";
1570
+ import tls from "tls";
1571
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
1572
+ async function checkDomain(url, deps) {
1573
+ let host;
1574
+ try {
1575
+ host = new URL(url).hostname;
1576
+ } catch {
1577
+ return { resolved: false, certDaysRemaining: null };
1578
+ }
1579
+ try {
1580
+ await deps.lookup(host);
1581
+ } catch {
1582
+ return { resolved: false, certDaysRemaining: null };
1583
+ }
1584
+ let validTo;
1585
+ try {
1586
+ validTo = await deps.certValidTo(host);
1587
+ } catch {
1588
+ validTo = null;
1589
+ }
1590
+ if (!validTo || Number.isNaN(validTo.getTime()))
1591
+ return { resolved: true, certDaysRemaining: null };
1592
+ return {
1593
+ resolved: true,
1594
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
1595
+ };
1596
+ }
1597
+ function defaultDomainDeps(now) {
1598
+ return {
1599
+ lookup: async (host) => {
1600
+ await dnsPromises.lookup(host);
1601
+ },
1602
+ certValidTo: (host) => new Promise((resolvePromise) => {
1603
+ const socket = tls.connect(
1604
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
1605
+ () => {
1606
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
1607
+ socket.end();
1608
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
1609
+ resolvePromise(validTo);
1610
+ }
1611
+ );
1612
+ socket.on("error", () => resolvePromise(null));
1613
+ socket.on("timeout", () => {
1614
+ socket.destroy();
1615
+ resolvePromise(null);
1616
+ });
1617
+ }),
1618
+ now
1619
+ };
1620
+ }
1621
+ async function domainAudit(ctx) {
1622
+ const { site } = ctx;
1623
+ const label = siteLabel(site);
1624
+ if (!site.deployedUrl) {
1625
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
1626
+ }
1627
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1628
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
1629
+ const check = await checkDomain(site.deployedUrl, deps);
1630
+ const checkedAt = now.toISOString();
1631
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
1632
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
1633
+ return {
1634
+ audit: "domain",
1635
+ site: label,
1636
+ status,
1637
+ summary,
1638
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
1639
+ };
1640
+ }
1641
+
1642
+ // src/audits/route-discovery.ts
1643
+ var DEFAULT_CAP = 15;
1644
+ function parseSitemapUrls(xml) {
1645
+ const out = [];
1646
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
1647
+ let m;
1648
+ while ((m = re.exec(xml)) !== null) {
1649
+ const url = m[1];
1650
+ if (url) out.push(url.trim());
1651
+ }
1652
+ return out;
1653
+ }
1654
+ function parseHtmlLinks(html, baseUrl) {
1655
+ const out = /* @__PURE__ */ new Set();
1656
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
1657
+ let m;
1658
+ while ((m = re.exec(html)) !== null) {
1659
+ const href = m[1];
1660
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
1661
+ try {
1662
+ const u = new URL(href, baseUrl);
1663
+ if (u.origin !== new URL(baseUrl).origin) continue;
1664
+ out.add(u.pathname);
1665
+ } catch {
1666
+ }
1667
+ }
1668
+ return [...out];
1669
+ }
1670
+ function family(pathname) {
1671
+ return pathname.split("/").filter(Boolean)[0] ?? "";
1672
+ }
1673
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
1674
+ const seen = /* @__PURE__ */ new Set(["/"]);
1675
+ const buckets = /* @__PURE__ */ new Map();
1676
+ for (const raw of urlsOrPaths) {
1677
+ let pathname;
1678
+ try {
1679
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
1680
+ } catch {
1681
+ continue;
1682
+ }
1683
+ if (pathname === "/") continue;
1684
+ if (seen.has(pathname)) continue;
1685
+ seen.add(pathname);
1686
+ const fam = family(pathname);
1687
+ const arr = buckets.get(fam) ?? [];
1688
+ arr.push(pathname);
1689
+ buckets.set(fam, arr);
1690
+ }
1691
+ const result = ["/"];
1692
+ const families = [...buckets.values()];
1693
+ let guard = 0;
1694
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
1695
+ for (const fam of families) {
1696
+ if (result.length >= cap) break;
1697
+ const next = fam.shift();
1698
+ if (next) result.push(next);
1699
+ }
1700
+ }
1701
+ return result;
1702
+ }
1703
+ function familyCountsOf(paths) {
1704
+ const counts = {};
1705
+ for (const p of paths) {
1706
+ const key = p === "/" ? "/" : `/${family(p)}`;
1707
+ counts[key] = (counts[key] ?? 0) + 1;
1708
+ }
1709
+ return counts;
1710
+ }
1711
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
1712
+ const origin = new URL(deployedUrl).origin;
1713
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
1714
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
1715
+ if (sitemapXml) {
1716
+ const urls = parseSitemapUrls(sitemapXml);
1717
+ if (urls.length > 0) {
1718
+ const paths = sampleRoutePaths(urls, cap);
1719
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
1720
+ }
1721
+ }
1722
+ const homeHtml = await deps.fetchText(origin);
1723
+ if (homeHtml) {
1724
+ const links = parseHtmlLinks(homeHtml, origin);
1725
+ if (links.length > 0) {
1726
+ const paths = sampleRoutePaths(links, cap);
1727
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
1728
+ }
1729
+ }
1730
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
1731
+ }
1732
+
1733
+ // src/audits/browser.ts
1734
+ function isBroken(status) {
1735
+ return status === null || status >= 400;
1736
+ }
1737
+ function summarizeBrowser(routes, links, familyCounts) {
1738
+ const desktopChecks = routes.flatMap((r) => r.desktop);
1739
+ const mobileChecks = routes.flatMap((r) => r.mobile);
1740
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
1741
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
1742
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
1743
+ const linksOk = links.length > 0 && brokenLinks === 0;
1744
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
1745
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
1746
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
1747
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
1748
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
1749
+ }
1750
+ async function browserAudit(ctx) {
1751
+ const { site } = ctx;
1752
+ const label = siteLabel(site);
1753
+ if (!site.deployedUrl) {
1754
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
1755
+ }
1756
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1757
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
1758
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
1759
+ try {
1760
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
1761
+ const routeResults = await runner.probe(discovered.routes);
1762
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
1763
+ const linkResults = await runner.checkLinks(internalLinks);
1764
+ const summary = summarizeBrowser(
1765
+ routeResults,
1766
+ linkResults,
1767
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
1768
+ );
1769
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
1770
+ return {
1771
+ audit: "browser",
1772
+ site: label,
1773
+ status,
1774
+ summary: summary.note,
1775
+ details: { ...summary, checkedAt: now.toISOString() }
1776
+ };
1777
+ } finally {
1778
+ await runner.close?.();
1779
+ }
1780
+ }
1781
+ function defaultDiscoverDeps() {
1782
+ return {
1783
+ fetchText: async (url) => {
1784
+ try {
1785
+ const res = await fetch(url, { redirect: "follow" });
1786
+ if (!res.ok) return null;
1787
+ return await res.text();
1788
+ } catch {
1789
+ return null;
1790
+ }
1791
+ }
1792
+ };
1793
+ }
1794
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
1795
+ var PAGE_TIMEOUT_MS = 3e4;
1796
+ async function defaultBrowserRunner() {
1797
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
1798
+ const desktopEngines = [
1799
+ { engine: "chromium", type: chromium },
1800
+ { engine: "firefox", type: firefox },
1801
+ { engine: "webkit", type: webkit }
1802
+ ];
1803
+ const mobileTargets = [
1804
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
1805
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
1806
+ ];
1807
+ return {
1808
+ async probe(urls) {
1809
+ const results = [];
1810
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
1811
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
1812
+ try {
1813
+ for (const url of urls) {
1814
+ const desktop = [];
1815
+ const linkSet = /* @__PURE__ */ new Set();
1816
+ for (let i = 0; i < desktopEngines.length; i++) {
1817
+ const engine = desktopEngines[i].engine;
1818
+ const browser = browsers[i];
1819
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
1820
+ const page = await ctx.newPage();
1821
+ const errors = [];
1822
+ page.on("pageerror", (e) => errors.push(String(e)));
1823
+ let ok = false;
1824
+ try {
1825
+ const resp = await page.goto(url, {
1826
+ waitUntil: "domcontentloaded",
1827
+ timeout: PAGE_TIMEOUT_MS
1828
+ });
1829
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
1830
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
1831
+ if (engine === "chromium") {
1832
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
1833
+ const origin = new URL(url).origin;
1834
+ for (const h of hrefs) {
1835
+ try {
1836
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
1837
+ } catch {
1838
+ }
1839
+ }
1840
+ }
1841
+ } catch {
1842
+ ok = false;
1843
+ } finally {
1844
+ await ctx.close().catch(() => {
1845
+ });
1846
+ }
1847
+ desktop.push({ engine, ok });
1848
+ }
1849
+ const mobile = [];
1850
+ for (let i = 0; i < mobileTargets.length; i++) {
1851
+ const { device, descriptor } = mobileTargets[i];
1852
+ const browser = mobileBrowsers[i];
1853
+ const ctx = await browser.newContext({ ...descriptor });
1854
+ const page = await ctx.newPage();
1855
+ const errors = [];
1856
+ page.on("pageerror", (e) => errors.push(String(e)));
1857
+ let ok = false;
1858
+ try {
1859
+ const resp = await page.goto(url, {
1860
+ waitUntil: "domcontentloaded",
1861
+ timeout: PAGE_TIMEOUT_MS
1862
+ });
1863
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
1864
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
1865
+ } catch {
1866
+ ok = false;
1867
+ } finally {
1868
+ await ctx.close().catch(() => {
1869
+ });
1870
+ }
1871
+ mobile.push({ device, ok });
1872
+ }
1873
+ results.push({ url, desktop, mobile, links: [...linkSet] });
1874
+ }
1875
+ } finally {
1876
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
1877
+ })));
1878
+ }
1879
+ return results;
1880
+ },
1881
+ async checkLinks(urls) {
1882
+ const out = [];
1883
+ for (const url of urls) {
1884
+ let status;
1885
+ try {
1886
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
1887
+ if (res.status === 405 || res.status === 501) {
1888
+ res = await fetch(url, { method: "GET", redirect: "follow" });
1889
+ }
1890
+ status = res.status;
1891
+ } catch {
1892
+ status = null;
1893
+ }
1894
+ out.push({ url, status });
1895
+ }
1896
+ return out;
1897
+ }
1898
+ };
1899
+ }
1900
+
1484
1901
  // src/audits/index.ts
1485
1902
  var REGISTRY = {
1486
1903
  deps: depsAudit,
1487
1904
  lint: lintAudit,
1488
1905
  security: securityAudit,
1489
1906
  lighthouse: lighthouseAudit,
1490
- a11y: a11yAudit
1907
+ a11y: a11yAudit,
1908
+ domain: domainAudit,
1909
+ browser: browserAudit
1491
1910
  };
1492
1911
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
1493
1912
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -1898,8 +2317,13 @@ function deployedUrlNotice(which, url, cwd) {
1898
2317
  if (others.length === 0) return null;
1899
2318
  return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
1900
2319
  }
2320
+ var CHECKOUT_FREE_AUDITS = /* @__PURE__ */ new Set([
2321
+ "lighthouse",
2322
+ "domain",
2323
+ "browser"
2324
+ ]);
1901
2325
  function auditNeedsCheckout(site, which) {
1902
- const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
2326
+ const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => CHECKOUT_FREE_AUDITS.has(n));
1903
2327
  return !deployedCapable;
1904
2328
  }
1905
2329
  function applyDeployedUrl(sites, url) {