@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.
- package/dist/cli/bin.js +958 -283
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -5
- package/dist/cli/commands/audit.js +441 -17
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +91 -9
- package/dist/index.js +786 -240
- package/dist/index.js.map +1 -1
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{types-DeKpgkG-.d.ts → types-QG-QhCYh.d.ts} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as Site, a as AuditResult, A as AuditName } from '../../types-
|
|
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
|
|
36
|
-
*
|
|
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"]
|
|
136
|
-
testingFreq: f["testing freq"]
|
|
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
|
|
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) {
|