@analitikisler/ai-tag-debugger 0.15.0 → 0.17.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.
@@ -0,0 +1,104 @@
1
+ // Machine-readable outputs for CI: junit.xml, which GitHub Actions, GitLab and Jenkins show as test
2
+ // results, and summary.json, a small file a script can read without parsing capture.json.
3
+ import path from "node:path";
4
+ import { groupFindings, summarize } from "./checks.js";
5
+ import { messages } from "./i18n.js";
6
+ import { platformLabel } from "./report.js";
7
+
8
+ // The same order the CLI uses for --fail-on: a finding fails when its severity is at or above the level.
9
+ const LEVELS = ["broken", "risk", "warning", "info"];
10
+
11
+ /** Whether a finding counts as a failure under --fail-on. Accepted (baselined) findings never do. */
12
+ export function failsAt(finding, failOn = "broken") {
13
+ if (failOn === "never" || finding.accepted === true) return false;
14
+ const level = LEVELS.indexOf(finding.severity);
15
+ return level !== -1 && level <= LEVELS.indexOf(failOn);
16
+ }
17
+
18
+ /** How long a run took in seconds, from its start to its last step or hit; 0 when the run has no timing. */
19
+ export function runSeconds(run) {
20
+ if (typeof run?.startedAt !== "number") return 0;
21
+ const last = Math.max(...[...(run.steps ?? []), ...(run.hits ?? [])].map((x) => x?.t ?? 0));
22
+ return last > run.startedAt ? Math.round((last - run.startedAt) / 10) / 100 : 0;
23
+ }
24
+
25
+ /** The one-line name of a finding: platform and event when it has them, then the message. */
26
+ export function findingTitle(f) {
27
+ const what = [f.platform ? platformLabel(f.platform) : "", f.event ?? ""].filter(Boolean).join(" ");
28
+ const line = what ? `${what}: ${f.detail}` : f.detail ?? f.check;
29
+ return line.length > 120 ? `${line.slice(0, 119).trimEnd()}…` : line;
30
+ }
31
+
32
+ const escapeXml = (s) => String(s ?? "").replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]);
33
+ // Control characters are not allowed in XML 1.0 at all, so they are dropped rather than escaped.
34
+ // eslint-disable-next-line no-control-regex
35
+ const xmlText = (s) => escapeXml(String(s ?? "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""));
36
+ const attrs = (o) => Object.entries(o).map(([k, v]) => ` ${k}="${xmlText(v)}"`).join("");
37
+
38
+ const belongsTo = (f, run) => f.journey === run.journeyId && f.viewport === run.viewport;
39
+
40
+ // A case is { xml, failed }: a failure carries the detail in its body, a pass keeps it in system-out.
41
+ const testcase = ({ classname, name, time = 0, failed, type, text, message = name }) => ({
42
+ failed,
43
+ xml: `<testcase${attrs({ classname, name, time })}>${failed ? `<failure${attrs({ message, type })}>${xmlText(text)}</failure>` : text ? `<system-out>${xmlText(text)}</system-out>` : ""}</testcase>`,
44
+ });
45
+
46
+ const findingCase = (f, failOn) => testcase({ classname: f.check, name: findingTitle(f), failed: failsAt(f, failOn), type: f.severity, text: f.detail });
47
+
48
+ function suite(name, cases, seconds) {
49
+ const failures = cases.filter((c) => c.failed).length;
50
+ return { failures, tests: cases.length, xml: ` <testsuite${attrs({ name, tests: cases.length, failures, errors: 0, skipped: 0, time: seconds })}>\n${cases.map((c) => ` ${c.xml}`).join("\n")}\n </testsuite>` };
51
+ }
52
+
53
+ /**
54
+ * JUnit XML: one suite per journey and viewport, one case per finding, plus a "journey completed" case
55
+ * that fails when the journey did not reach its goal. Findings not tied to a run go in a suite of their own.
56
+ */
57
+ export function renderJunit({ runs = [], findings = [], lang = "en", failOn = "broken", version = "", site = "" }) {
58
+ const m = messages(lang).ui;
59
+ const suites = runs.map((run) => {
60
+ // With --fail-on never the CLI exits 0 whatever happened, so the JUnit file must not fail the job on its own.
61
+ const journey = testcase({ classname: "journey", name: m.junitJourney, time: runSeconds(run), failed: failOn !== "never" && run.completed === false, type: "incomplete", text: run.note, message: run.note ?? "" });
62
+ return suite(`${run.journeyId} (${run.viewport})`, [journey, ...findings.filter((f) => belongsTo(f, run)).map((f) => findingCase(f, failOn))], runSeconds(run));
63
+ });
64
+ const loose = findings.filter((f) => !runs.some((run) => belongsTo(f, run)));
65
+ if (loose.length) suites.push(suite(m.junitSite, loose.map((f) => findingCase(f, failOn)), 0));
66
+ const totals = { tests: suites.reduce((n, s) => n + s.tests, 0), failures: suites.reduce((n, s) => n + s.failures, 0), time: runs.reduce((n, r) => n + runSeconds(r), 0) };
67
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites${attrs({ name: `ai-tag-debugger ${site}`.trim(), ...(version ? { version } : {}), ...totals })}>\n${suites.map((s) => s.xml).join("\n")}\n</testsuites>\n`;
68
+ }
69
+
70
+ /** The run in one small object: counts, journeys, findings and where the other files are. */
71
+ export function buildSummary({ runs = [], findings = [], counts, plan = null, against = null, ai = null, lang = "en", reportPath, outDir, changes = null, version = "", site = "", metrics = null, trend = null }) {
72
+ // Only the run folder's name and file names relative to it: a summary uploaded as a CI artifact must not carry the runner's paths.
73
+ const dir = outDir ?? (reportPath ? path.dirname(reportPath) : ".");
74
+ const file = (name) => name;
75
+ return {
76
+ version,
77
+ site,
78
+ generatedAt: new Date().toISOString(),
79
+ lang,
80
+ ai: ai ? { name: ai.name, model: ai.model } : null,
81
+ plan: { id: against?.id ?? null, events: (plan?.events ?? []).map((e) => `${String(e.platform ?? "").toLowerCase()}:${e.name}`) },
82
+ journeys: runs.map((r) => ({ id: r.journeyId, viewport: r.viewport, completed: r.completed !== false, note: r.note ?? "", steps: r.steps?.length ?? 0, hits: r.hits?.length ?? 0 })),
83
+ counts: counts ?? summarize(groupFindings(findings)),
84
+ // The deterministic verdict (see src/metrics.js): its status, the headline key, and the data quality score.
85
+ verdict: metrics ? { status: metrics.verdict.status, headline: metrics.verdict.headline } : null,
86
+ score: metrics?.score?.score ?? null,
87
+ trend: trend ? { direction: trend.direction, runs: trend.runs } : null,
88
+ findings: findings.map((f) => ({
89
+ check: f.check,
90
+ severity: f.severity,
91
+ platform: f.platform ?? null,
92
+ event: f.event ?? null,
93
+ journey: f.journey ?? null,
94
+ viewport: f.viewport ?? null,
95
+ title: findingTitle(f),
96
+ message: f.detail ?? "",
97
+ ...(f.platform && f.event ? { key: `${f.platform}:${f.event}` } : {}),
98
+ ...(f.accepted === true ? { accepted: true } : {}),
99
+ })),
100
+ changes: changes ? { fixed: changes.fixed ?? [], new: changes.new ?? [] } : null,
101
+ run: path.basename(dir),
102
+ files: { html: file("report.html"), md: file("report.md"), csv: file("timeline.csv"), capture: file("capture.json"), ...(against ? { planCheck: file("plan-check.json") } : {}), junit: file("junit.xml"), summary: file("summary.json") },
103
+ };
104
+ }
package/src/cli.js CHANGED
@@ -5,7 +5,9 @@ import { parseArgs } from "node:util";
5
5
  import { loadConfig, runAudit } from "./audit.js";
6
6
  import { messages } from "./i18n.js";
7
7
  import { helpText } from "./help.js";
8
+ import { validatePlan } from "./schema.js";
8
9
  import { replaySavedSite, writeSavedSteps } from "./replay.js";
10
+ import { readBaseline, writeBaseline } from "./baseline.js";
9
11
  import { savePlan } from "./plan.js";
10
12
  import { DEFAULT_DATA_DIR } from "./workspace.js";
11
13
  import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
@@ -25,6 +27,7 @@ async function main(args = process.argv.slice(2)) {
25
27
  out: { type: "string" },
26
28
  journey: { type: "string", multiple: true },
27
29
  viewport: { type: "string", multiple: true },
30
+ concurrency: { type: "string" },
28
31
  platforms: { type: "string", multiple: true },
29
32
  providers: { type: "string", multiple: true },
30
33
  "allow-host": { type: "string", multiple: true },
@@ -36,6 +39,8 @@ async function main(args = process.argv.slice(2)) {
36
39
  "base-url": { type: "string" },
37
40
  lang: { type: "string" },
38
41
  "fail-on": { type: "string", default: "broken" },
42
+ baseline: { type: "string" },
43
+ "update-baseline": { type: "boolean", default: false },
39
44
  headed: { type: "boolean", default: false },
40
45
  "no-branding": { type: "boolean", default: false },
41
46
  "keep-bodies": { type: "boolean", default: false },
@@ -53,6 +58,8 @@ async function main(args = process.argv.slice(2)) {
53
58
  from: { type: "string" },
54
59
  topic: { type: "string" },
55
60
  format: { type: "string" },
61
+ limit: { type: "string" },
62
+ json: { type: "boolean", default: false },
56
63
  force: { type: "boolean", default: false },
57
64
  yes: { type: "boolean", short: "y", default: false },
58
65
  help: { type: "boolean", short: "h", default: false },
@@ -131,6 +138,18 @@ async function main(args = process.argv.slice(2)) {
131
138
  console.log(`✓ Saved plan ${id}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties in ${rel.startsWith("..") ? dir : rel}`);
132
139
  return;
133
140
  }
141
+ if (command === "history" && !values.help) {
142
+ await showHistory(values);
143
+ return;
144
+ }
145
+ if (command === "doctor" && !values.help) {
146
+ const { doctorChecks } = await import("./doctor.js");
147
+ const checks = await doctorChecks({ site: values.site, dataDir: values["data-dir"] });
148
+ for (const c of checks) console.log(`${c.ok ? "✓" : "✗"} ${c.text}`);
149
+ // Only a check an audit cannot run without fails the command; a missing AI key is advice.
150
+ if (checks.some((c) => c.hard && !c.ok)) process.exitCode = 1;
151
+ return;
152
+ }
134
153
  if (values.help || command !== "run" || (!values.config && !values.site && !values.against)) {
135
154
  console.log(helpText(command));
136
155
  process.exit(values.help ? 0 : 2);
@@ -161,6 +180,8 @@ async function main(args = process.argv.slice(2)) {
161
180
  against = await resolvePlan(values.against, { dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR, site: loaded.config.site });
162
181
  const plan = await readJson(against.file);
163
182
  if (!Array.isArray(plan?.events)) throw new Error(`${against.file} is not a plan: it needs an "events" list.`);
183
+ // loadConfig checked the config's own plan; the one --against names replaces it and gets the same check.
184
+ for (const w of validatePlan(plan, { file: against.file }).warnings) console.log(w);
164
185
  if (!loaded.config.site) {
165
186
  if (!plan.site) throw new Error(`${against.file} has no "site". Add --site <url>.`);
166
187
  loaded.config = { ...loaded.config, site: plan.site };
@@ -178,6 +199,17 @@ async function main(args = process.argv.slice(2)) {
178
199
  if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
179
200
  // run --out is a hidden alias kept for one minor version; --data-dir replaces it.
180
201
  if (values.out) console.log("Note: run --out will be removed in a later version. Runs are saved in aitd/<domain>/runs/<id>/; use --data-dir <dir> to keep them elsewhere.");
202
+ const baselineFile = values.baseline ?? loaded.baselineFile ?? null;
203
+ if (values["update-baseline"] && !baselineFile) throw new Error('--update-baseline needs --baseline <file> or "baseline" in the config.');
204
+ // A baseline that doesn't exist yet is fine when this run writes it.
205
+ const baseline = baselineFile
206
+ ? await readBaseline(baselineFile).catch((err) => {
207
+ if (values["update-baseline"] && err.code === "ENOENT") return null;
208
+ throw err;
209
+ })
210
+ : null;
211
+ // A baseline written for another site matches nothing of this one, so say so, but let the run go on.
212
+ if (baseline?.site && baseline.site !== loaded.config.site) console.log(`Warning: the baseline ${baselineFile} was written for ${baseline.site}, not ${loaded.config.site}. Its entries may not match this site.`);
181
213
  const result = await runAudit({
182
214
  ...loaded,
183
215
  journeys: values.journey,
@@ -185,6 +217,7 @@ async function main(args = process.argv.slice(2)) {
185
217
  events: (values.events ?? []).flatMap((e) => e.split(",")).map((e) => e.trim()).filter(Boolean),
186
218
  maxSteps,
187
219
  viewports: values.viewport,
220
+ concurrency: values.concurrency,
188
221
  providers: platforms.length ? platforms.flatMap((p) => p.split(",")) : undefined,
189
222
  scripted: values.scripted,
190
223
  ai: !values["no-ai"],
@@ -198,12 +231,27 @@ async function main(args = process.argv.slice(2)) {
198
231
  dataDir: values["data-dir"],
199
232
  saveSite: values["save-site"],
200
233
  against,
234
+ failOn,
235
+ baseline,
201
236
  log: (msg) => console.log(msg),
202
237
  });
203
238
 
204
- console.log(`\n${messages(result.lang).ui.counts(result.counts)}`);
205
- const rel = path.relative(process.cwd(), result.reportPath);
206
- console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
239
+ const m = messages(result.lang);
240
+ console.log(`\n${m.ui.counts(result.counts)}`);
241
+ if (result.baseline) console.log(`${result.baseline.accepted} accepted (baseline ${baselineFile}), ${result.baseline.partial ? `${result.baseline.partial} ${result.baseline.partial === 1 ? "problem" : "problems"} accepted in some places only (still counted), ` : ""}${result.baseline.stale.length} stale baseline ${result.baseline.stale.length === 1 ? "entry" : "entries"}`);
242
+ if (result.verdict) console.log(m.ui.cli.verdict(result.verdict.statusText, result.verdict.headline, result.verdict.score));
243
+ // The trend needs two saved runs; with one there is nothing to say yet.
244
+ if (result.trend?.runs >= 2) {
245
+ const { historySummaryLines } = await import("./history.js");
246
+ for (const line of historySummaryLines(result.trend, result.lang, result.stability ?? []).slice(0, 3)) console.log(line);
247
+ }
248
+ const shown = (p) => (path.relative(process.cwd(), p).startsWith("..") ? p : path.relative(process.cwd(), p));
249
+ console.log(`Report: ${shown(result.reportPath)}`);
250
+ console.log(`Summary: ${shown(result.summaryPath)} (junit.xml next to it)`);
251
+ if (values["update-baseline"]) {
252
+ const n = await writeBaseline(baselineFile, { site: loaded.config.site, findings: result.findings });
253
+ console.log(`Baseline ${baselineFile} now holds ${n} ${n === 1 ? "finding" : "findings"}. Later runs with --baseline fail only on new ones.`);
254
+ }
207
255
 
208
256
  if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
209
257
 
@@ -224,6 +272,39 @@ async function main(args = process.argv.slice(2)) {
224
272
  }
225
273
  }
226
274
 
275
+ // aitd history --site <url>: the trend of the site's saved runs as a small table, the text sparkline and the summary lines.
276
+ async function showHistory(values) {
277
+ if (!values.site) throw new Error("history needs --site <url>: aitd history --site https://www.example.com");
278
+ const limit = values.limit ? Number(values.limit) : 30;
279
+ if (!(Number.isInteger(limit) && limit > 0)) throw new Error("--limit must be a positive whole number.");
280
+ const lang = values.lang ?? "en";
281
+ const m = messages(lang);
282
+ const { domainOf } = await import("./workspace.js");
283
+ domainOf(values.site);
284
+ const { findingTimeline, historySummaryLines, journeyStability, loadHistory, sparkline, trend } = await import("./history.js");
285
+ const history = await loadHistory(values["data-dir"] ?? DEFAULT_DATA_DIR, values.site, { limit });
286
+ const t = trend(history);
287
+ const stability = journeyStability(history);
288
+ if (values.json) {
289
+ console.log(JSON.stringify({ site: values.site, runs: history.length, trend: t, timeline: findingTimeline(history), stability }, null, 2));
290
+ return;
291
+ }
292
+ if (!history.length) {
293
+ console.log(m.ui.history.noRuns);
294
+ return;
295
+ }
296
+ const cols = [m.ui.cli.runCol, m.severity.broken, m.severity.risk, m.severity.warning, m.ui.cli.scoreCol, m.ui.cli.completeCol];
297
+ const rows = t.series.map((p) => [p.id, p.broken, p.risk, p.warning, p.score, p.complete ? m.ui.md.yes : m.ui.md.no].map(String));
298
+ const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
299
+ const line = (cells) => cells.map((c, i) => (i ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(" ");
300
+ console.log(line(cols));
301
+ for (const r of rows) console.log(line(r));
302
+ console.log("");
303
+ const s = m.ui.history;
304
+ if (t.runs >= 2) console.log(s.sparkline(sparkline(t), s.direction[t.direction], t.runs, t.first.slice(0, 10), t.last.slice(0, 10)));
305
+ for (const l of historySummaryLines(t, lang, stability)) console.log(l);
306
+ }
307
+
227
308
  // The first run of a site with no plan: offer what it already tracks as its plan.
228
309
  async function offerLearnedPlan(result, site, values) {
229
310
  const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
package/src/consent.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // (accept all, reject all, analytics only), to show which tags each choice lets through.
3
3
  // Runs on its own with every audit when the page shows a consent banner.
4
4
  import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
5
+ import { runPool } from "./pool.js";
5
6
 
6
7
  export const CONSENT_STATES = ["accept", "reject", "analytics"];
7
8
  // Tags that are advertising, so an "analytics only" choice should keep them quiet.
@@ -110,6 +111,7 @@ async function runState(config, state, opts) {
110
111
  /**
111
112
  * Runs every consent scenario on the site's start page. Returns null when the page has no consent
112
113
  * banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
114
+ * opts.concurrency runs the other choices side by side, each in its own browser; default 1.
113
115
  * @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
114
116
  */
115
117
  export async function runConsentScenarios(config, opts = {}) {
@@ -117,8 +119,8 @@ export async function runConsentScenarios(config, opts = {}) {
117
119
  const first = await runState(config, "accept", opts);
118
120
  if (!first.via) return null;
119
121
  log(`\n▶ consent scenarios (${first.via})`);
120
- const results = [first];
121
- for (const state of CONSENT_STATES.slice(1)) results.push(await runState(config, state, opts));
122
+ // The first choice shows whether there is a banner to answer at all; the others don't depend on each other.
123
+ const results = [first, ...(await runPool(CONSENT_STATES.slice(1), opts.concurrency ?? 1, (state) => runState(config, state, opts)))];
122
124
  const states = {};
123
125
  for (const r of results) {
124
126
  states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
package/src/doctor.js ADDED
@@ -0,0 +1,68 @@
1
+ // `aitd doctor`: one line per thing an audit needs, so a broken setup is found before a run starts.
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { access, constants, stat } from "node:fs/promises";
4
+ import { lookup } from "node:dns/promises";
5
+ import path from "node:path";
6
+ import { aiStatus } from "./providers.js";
7
+ import { DEFAULT_DATA_DIR } from "./workspace.js";
8
+
9
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
+
11
+ // The same as minNodeMajor() in scripts/setup.js, which the published package doesn't ship.
12
+ const minNodeMajor = (range) => Number(/(\d+)/.exec(range ?? "")?.[1] ?? 0);
13
+
14
+ /** The nearest existing directory at or above dir, since the data dir is only created by the first run. */
15
+ function existingAncestor(dir, exists) {
16
+ let d = dir;
17
+ while (!exists(d) && path.dirname(d) !== d) d = path.dirname(d);
18
+ return d;
19
+ }
20
+
21
+ /**
22
+ * Runs the checks and returns [{ ok, hard, text }]. A failed hard check means audits can't run;
23
+ * a soft one is advice. Everything that touches the machine can be injected, so tests need no
24
+ * browser or network.
25
+ */
26
+ export async function doctorChecks({ site, dataDir = DEFAULT_DATA_DIR, nodeVersion = process.version, chromiumPath, env = process.env, exists = existsSync, writable = writableDir, resolve = lookup } = {}) {
27
+ const checks = [];
28
+ const range = pkg.engines?.node;
29
+ const nodeOk = Number(/^v?(\d+)/.exec(nodeVersion)?.[1] ?? 0) >= minNodeMajor(range);
30
+ checks.push({ ok: nodeOk, hard: true, text: nodeOk ? `Node ${nodeVersion} (needs ${range})` : `Node ${nodeVersion} is too old: this needs Node ${range}. Install a newer Node, for example with nvm: nvm install 22` });
31
+
32
+ const pw = pkg.dependencies.playwright;
33
+ chromiumPath ??= (await import("playwright")).chromium.executablePath();
34
+ const chromiumOk = Boolean(chromiumPath && exists(chromiumPath));
35
+ checks.push({ ok: chromiumOk, hard: true, text: chromiumOk ? `Playwright ${pw} with Chromium at ${chromiumPath}` : `Playwright ${pw} has no Chromium installed. Run: npx -y playwright@${pw} install chromium` });
36
+
37
+ // The same line `aitd` with no arguments prints. No key is not a failure: --no-ai and replay work without one.
38
+ const ai = aiStatus({}, env);
39
+ const aiOk = !ai.startsWith("AI: no key set");
40
+ checks.push({ ok: aiOk, hard: false, text: aiOk ? ai : `${ai}. Optional: --no-ai and replay work without a key` });
41
+
42
+ const dir = path.resolve(dataDir);
43
+ const probe = existingAncestor(dir, exists);
44
+ const dirOk = await writable(probe).then(() => true, () => false);
45
+ checks.push({ ok: dirOk, hard: true, text: dirOk ? `Data folder ${dir} is writable${probe === dir ? "" : " (it will be created)"}` : `Data folder ${dir} is not writable (checked ${probe}). Pick another with --data-dir <dir>` });
46
+
47
+ if (site !== undefined) checks.push(await siteCheck(site, resolve));
48
+ return checks;
49
+ }
50
+
51
+ /** Rejects unless d is a directory the process may write to: a data dir under a file can never be created, whatever the permissions say. */
52
+ async function writableDir(d) {
53
+ if (!(await stat(d)).isDirectory()) throw new Error(`${d} is not a directory`);
54
+ await access(d, constants.W_OK);
55
+ }
56
+
57
+ /** The site is an http(s) address whose host resolves. Nothing is fetched. */
58
+ async function siteCheck(site, resolve) {
59
+ let u;
60
+ try {
61
+ u = new URL(site);
62
+ } catch {
63
+ return { ok: false, hard: true, text: `"${site}" is not a full URL. Use the form https://www.example.com` };
64
+ }
65
+ if (!["http:", "https:"].includes(u.protocol)) return { ok: false, hard: true, text: `${site} is not an http or https address, so the browser can't open it` };
66
+ const found = await resolve(u.hostname).then(() => true, () => false);
67
+ return { ok: found, hard: true, text: found ? `${u.hostname} resolves` : `${u.hostname} does not resolve. Check the address, or your network and DNS` };
68
+ }