@fulldotdev/scan 0.1.0 → 0.3.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/README.md CHANGED
@@ -26,7 +26,7 @@ Options:
26
26
  | Flag | Meaning |
27
27
  | --- | --- |
28
28
  | `--out <dir>` | Output directory, default `./scan` |
29
- | `--quick` | Crawl only: no browser pass, no Lighthouse |
29
+ | `--quick` | The daily scan: crawl only, no browser pass, Lighthouse or external sources |
30
30
  | `--deep` | Deeper measurement: rendering observation and Lighthouse repeats |
31
31
  | `--lighthouse <mode>` | `templates` (default: homepage plus one rotating page per template), `all`, or `off` |
32
32
  | `--no-browser` | Skip the browser pass and Lighthouse |
@@ -48,9 +48,16 @@ Local targets (localhost, private addresses, any port) are allowed automatically
48
48
 
49
49
  `GOOGLE_API_KEY` (a Google Cloud key with the Chrome UX Report API and Safe Browsing API enabled) enables real-user vitals and the Safe Browsing check.
50
50
 
51
+ ## In a repository
52
+
53
+ Two rhythms: a daily crawl (`--quick`, seconds to minutes) and a weekly full scan (browser pass on every page, Lighthouse per template, external sources). The workflow in [`templates/github-workflow.yml`](templates/github-workflow.yml) does both, scans every pull request on its Netlify deploy preview, comments the result on the pull request, fails on critical findings and pushes scans of the live site to scan.full.dev. Copy it to `.github/workflows/scan.yml`, set `SITE_URL`, and add the secret `FULLSCAN_KEY`.
54
+
55
+ For an agent working on a site: run `fullscan http://localhost:3000`, read `scan/report.md`, change the site, and run again with `--previous scan/result.json` until nothing is left.
56
+
51
57
  ## Output
52
58
 
53
59
  - `result.json`: `scan` (options, coverage, status), `report` (overview, findings per topic, checks with outcome, coverage, changes), `findings` (every finding with all affected items), `pages` (one compact row per page: status, type, language, indexable, depth, word count, template, Lighthouse scores, finding counts), `assets` (every file and external destination with status, size and the pages that use it).
60
+ - `summary.md`: counts, changes and the most important problems, short enough for a pull request comment.
54
61
  - `report.md`: the same report as readable Markdown, made for people and language models.
55
62
  - `records.ndjson`: every raw observation (page extraction, rendered comparison, axe results, Lighthouse evidence, DNS, TLS, sitemaps and so on).
56
63
  - `artifacts/`: screenshots, retrieved sitemaps, robots.txt and agent files, with `index.json`.
package/SKILL.md ADDED
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: fullscan
3
+ description: Scan a website or a local dev server for problems with @fulldotdev/scan and read the result. Use when asked to check, audit or scan a site, or to verify a change did not introduce problems.
4
+ ---
5
+
6
+ # fullscan
7
+
8
+ Run it against a live URL or the local dev server:
9
+
10
+ ```sh
11
+ npx @fulldotdev/scan http://localhost:3000 --quick --out scan
12
+ npx @fulldotdev/scan https://example.com --out scan
13
+ ```
14
+
15
+ `--quick` crawls every page without a browser (fast, seconds). Without it the run also renders every page, runs axe-core, and runs Lighthouse on one page per template (minutes).
16
+
17
+ With an account on the platform, `--push` stores the result there as well:
18
+
19
+ ```sh
20
+ npx @fulldotdev/scan https://example.com --push --key $FULLSCAN_KEY
21
+ ```
22
+
23
+ It prints the site and run id it stored, and creates the site on the first push.
24
+
25
+ Read `scan/report.md` first. It lists the problems per topic with severity, confidence, problem, impact and evidence. For the full list of affected pages per finding read `findings[]` in `scan/result.json`; for per-page rows read `pages[]`; for files and external destinations read `assets[]`. `report.checks[]` tells which rules ran and which did not.
26
+
27
+ The tool reports problems and their impact only. Deciding what, if anything, to change is up to you and the person you work for. Run it again with `--previous scan/result.json` after a change to see what is new, resolved or unverified.
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ per template, and every problem found is written as JSON and Markdown.
16
16
 
17
17
  Options:
18
18
  --out <dir> Output directory (default: ./scan)
19
- --quick Crawl only: no browser pass, no Lighthouse
19
+ --quick The daily scan: crawl only, no browser, Lighthouse or external sources
20
20
  --deep Deeper measurement: rendering observation, Lighthouse repeats
21
21
  --lighthouse <mode> templates (default), all, or off
22
22
  --no-browser Skip the browser pass and Lighthouse
@@ -29,6 +29,8 @@ Options:
29
29
  --push Push the result to the platform after the scan
30
30
  --key <key> API key for --push (default: FULLSCAN_KEY)
31
31
  --platform <url> Platform to push to (default: https://scan.full.dev)
32
+ --run <id> Fill this queued run of the platform with the result
33
+ --scheduled Store the result as a run of the nightly schedule
32
34
  --json Print the result JSON to stdout instead of progress
33
35
  --quiet No progress output
34
36
  -h, --help Show this help
@@ -53,6 +55,8 @@ const { values, positionals } = parseArgs({
53
55
  push: { type: "boolean", default: false },
54
56
  key: { type: "string" },
55
57
  platform: { type: "string", default: "https://scan.full.dev" },
58
+ run: { type: "string" },
59
+ scheduled: { type: "boolean", default: false },
56
60
  json: { type: "boolean", default: false },
57
61
  quiet: { type: "boolean", default: false },
58
62
  help: { type: "boolean", short: "h", default: false },
@@ -76,11 +80,45 @@ const options = {
76
80
  ? "off"
77
81
  : (values.lighthouse ??
78
82
  "templates"),
79
- external: values.external,
83
+ external: values.external && !values.quick,
80
84
  allowLocal: local,
81
85
  ...(values.pages ? { maxPages: Number(values.pages) } : {}),
82
86
  };
87
+ const key = values.key ?? process.env.FULLSCAN_KEY;
88
+ const platform = values.platform.replace(/\/+$/, "");
89
+ // One call to the platform's typed API. Returns the result or throws.
90
+ async function callPlatform(path, operation, args) {
91
+ const response = await fetch(`${platform}/api/${path}`, {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ Authorization: `Bearer ${key}`,
96
+ },
97
+ body: JSON.stringify({ operation, args }),
98
+ });
99
+ const payload = (await response.json().catch(() => null));
100
+ if (!response.ok)
101
+ throw new Error(payload?.error?.message ?? `${response.status} ${response.statusText}`);
102
+ return payload?.result;
103
+ }
83
104
  let previous;
105
+ let previousRotation;
106
+ // With a key and no file, the platform tells the scan what the site looked
107
+ // like last time, so changes are tracked on a runner just as well.
108
+ if (!values.previous && key) {
109
+ try {
110
+ const result = await callPlatform("query", "runs.previous", { url });
111
+ if (result) {
112
+ const { rotation: stored, ...rest } = result;
113
+ previous = rest;
114
+ if (typeof stored === "number")
115
+ previousRotation = stored;
116
+ }
117
+ }
118
+ catch (error) {
119
+ process.stderr.write(`Could not read the previous scan: ${error instanceof Error ? error.message : String(error)}\n`);
120
+ }
121
+ }
84
122
  if (values.previous) {
85
123
  const file = JSON.parse(await readFile(values.previous, "utf8"));
86
124
  previous = {
@@ -102,10 +140,12 @@ if (values.previous) {
102
140
  }
103
141
  const rotation = values.rotation
104
142
  ? Number(values.rotation)
105
- : previous
106
- ? Number(JSON.parse(await readFile(values.previous, "utf8")).scan?.rotation ??
107
- -1) + 1
108
- : 0;
143
+ : previousRotation !== undefined
144
+ ? previousRotation
145
+ : values.previous
146
+ ? Number(JSON.parse(await readFile(values.previous, "utf8")).scan?.rotation ??
147
+ -1) + 1
148
+ : 0;
109
149
  const quiet = values.quiet || values.json;
110
150
  const log = (line) => {
111
151
  if (!quiet)
@@ -154,6 +194,7 @@ const out = values.out;
154
194
  await mkdir(join(out, "artifacts"), { recursive: true });
155
195
  await writeFile(join(out, "result.json"), JSON.stringify(result, null, 2));
156
196
  await writeFile(join(out, "report.md"), formatReport(report) + "\n");
197
+ await writeFile(join(out, "summary.md"), summary() + "\n");
157
198
  await writeFile(join(out, "records.ndjson"), records.map((r) => JSON.stringify(r)).join("\n") + "\n");
158
199
  const index = {};
159
200
  for (const artifact of state.artifacts.values()) {
@@ -166,41 +207,100 @@ for (const artifact of state.artifacts.values()) {
166
207
  };
167
208
  }
168
209
  await writeFile(join(out, "artifacts", "index.json"), JSON.stringify(index, null, 2));
169
- // The push carries the result only: records and artifacts stay local.
210
+ // The push carries the result; the observations and the screenshots follow
211
+ // it in their own calls, so the run on the platform is as complete as one
212
+ // the platform ran itself.
170
213
  let pushed;
171
214
  if (values.push) {
172
- const key = values.key ?? process.env.FULLSCAN_KEY;
173
215
  if (!key) {
174
216
  process.stderr.write("A key is required to push: pass --key or set FULLSCAN_KEY.\n");
175
217
  process.exit(1);
176
218
  }
177
- const platform = values.platform.replace(/\/+$/, "");
178
- const response = await fetch(`${platform}/api/command`, {
179
- method: "POST",
180
- headers: {
181
- "Content-Type": "application/json",
182
- Authorization: `Bearer ${key}`,
183
- },
184
- body: JSON.stringify({
185
- operation: "runs.push",
186
- args: {
187
- url: scan.url,
188
- scan,
189
- report: result.report,
190
- findings,
191
- pages,
192
- assets,
193
- },
194
- }),
195
- });
196
- const payload = (await response.json().catch(() => null));
197
- if (!response.ok || !payload?.result) {
198
- process.stderr.write(`Push failed: ${payload?.error?.message ?? `${response.status} ${response.statusText}`}\n`);
219
+ try {
220
+ pushed = await callPlatform("command", "runs.push", {
221
+ url: scan.url,
222
+ scan,
223
+ report: result.report,
224
+ findings,
225
+ pages,
226
+ assets,
227
+ ...(values.run ? { runId: values.run } : {}),
228
+ ...(values.scheduled ? { scheduled: true } : {}),
229
+ });
230
+ }
231
+ catch (error) {
232
+ process.stderr.write(`Push failed: ${error instanceof Error ? error.message : String(error)}\n`);
233
+ process.exit(1);
234
+ }
235
+ if (!pushed) {
236
+ process.stderr.write("Push failed: the platform stored no run.\n");
199
237
  process.exit(1);
200
238
  }
201
- pushed = payload.result;
202
239
  if (values.json)
203
240
  process.stderr.write(`Pushed to ${platform}: site ${pushed.siteId}, run ${pushed.runId}\n`);
241
+ await pushRecords(pushed.runId);
242
+ await pushScreenshots(pushed.runId);
243
+ }
244
+ // Observations go up in batches that stay under 3 MB of JSON, because the
245
+ // platform's request limit is a few megabytes. A batch that fails costs the
246
+ // page detail of this run, never the run itself.
247
+ async function pushRecords(runId) {
248
+ const limit = 3 * 1024 * 1024;
249
+ const batches = [[]];
250
+ let size = 0;
251
+ for (const record of records) {
252
+ const bytes = Buffer.byteLength(JSON.stringify(record));
253
+ if (size + bytes > limit && batches[batches.length - 1].length) {
254
+ batches.push([]);
255
+ size = 0;
256
+ }
257
+ batches[batches.length - 1].push(record);
258
+ size += bytes;
259
+ }
260
+ for (const [index, batch] of batches.entries()) {
261
+ const final = index === batches.length - 1;
262
+ try {
263
+ await callPlatform("command", "runs.pushRecords", {
264
+ runId,
265
+ records: batch,
266
+ final,
267
+ });
268
+ }
269
+ catch (error) {
270
+ process.stderr.write(`Could not push ${batch.length} observations: ${error instanceof Error ? error.message : String(error)}\n`);
271
+ }
272
+ }
273
+ }
274
+ // Screenshots are raw bytes, so they go to their own endpoint instead of
275
+ // swelling the JSON push by a third.
276
+ async function pushScreenshots(runId) {
277
+ for (const artifact of state.artifacts.values()) {
278
+ if (artifact.kind !== "screenshot")
279
+ continue;
280
+ try {
281
+ const query = new URLSearchParams({
282
+ runId,
283
+ kind: "screenshot",
284
+ key: artifact.key,
285
+ });
286
+ const response = await fetch(`${platform}/api/artifacts/upload?${query.toString()}`, {
287
+ method: "POST",
288
+ headers: {
289
+ "Content-Type": artifact.contentType,
290
+ Authorization: `Bearer ${key}`,
291
+ },
292
+ body: new Uint8Array(artifact.body),
293
+ });
294
+ if (!response.ok) {
295
+ const payload = (await response.json().catch(() => null));
296
+ throw new Error(payload?.error?.message ??
297
+ `${response.status} ${response.statusText}`);
298
+ }
299
+ }
300
+ catch (error) {
301
+ process.stderr.write(`Could not upload the screenshot of ${artifact.key}: ${error instanceof Error ? error.message : String(error)}\n`);
302
+ }
303
+ }
204
304
  }
205
305
  if (values.json)
206
306
  process.stdout.write(JSON.stringify(result) + "\n");
@@ -222,6 +322,30 @@ if (values["fail-on"]) {
222
322
  : c.critical > 0 || c.warning > 0;
223
323
  process.exit(failed ? 1 : 0);
224
324
  }
325
+ // A short version of the report for a pull request comment or a job summary.
326
+ function summary() {
327
+ const c = report.counts;
328
+ const top = report.findings
329
+ .filter((f) => f.severity !== "hint")
330
+ .slice(0, 10)
331
+ .map((f) => `- **${f.title}** (${f.severity}, ${f.count} on ${f.pages} pages). ${f.impact}`);
332
+ const changes = report.changes
333
+ ? [
334
+ "",
335
+ `Since the previous scan: ${report.changes.newFindings.length} new, ${(report.changes.resolvedFindings ?? []).length} resolved, ${(report.changes.worsenedFindings ?? []).length} worse.`,
336
+ ...report.changes.newFindings
337
+ .slice(0, 10)
338
+ .map((f) => `- New: **${f.title}** (${f.severity}, ${f.count})`),
339
+ ]
340
+ : [];
341
+ return [
342
+ `### Scan of ${new URL(scan.url).hostname}`,
343
+ "",
344
+ `${report.pages.observed} pages, **${c.critical} critical**, **${c.warning} warnings**, ${c.hint ?? 0} hints. Status ${scan.status}.`,
345
+ ...changes,
346
+ ...(top.length ? ["", "Most important problems:", ...top] : []),
347
+ ].join("\n");
348
+ }
225
349
  function extension(contentType) {
226
350
  if (/png/.test(contentType))
227
351
  return ".png";
@@ -24,6 +24,8 @@ export async function openBrowserSession() {
24
24
  ];
25
25
  const executablePath = env("CHROME_PATH") || localPaths.find(existsSync) || undefined;
26
26
  const args = [
27
+ // Extra flags for environments that need them (a serverless Chrome).
28
+ ...JSON.parse(env("CHROME_ARGS") || "[]"),
27
29
  "--no-sandbox",
28
30
  "--disable-dev-shm-usage",
29
31
  `--proxy-server=http://127.0.0.1:${proxy.port}`,
@@ -80,8 +82,7 @@ const axeTags = [
80
82
  export async function collectBrowser(scan, url, http, session) {
81
83
  await resolvePublic(new URL(url).hostname);
82
84
  const robots = await http.getRobots(url);
83
- if (!robots.allowed ||
84
- robots.parser?.isAllowed(url, "FulldevScan") === false)
85
+ if (!robots.allowed || robots.parser?.isAllowed(url, "FulldevScan") === false)
85
86
  return {
86
87
  observations: [
87
88
  {
@@ -93,6 +94,9 @@ export async function collectBrowser(scan, url, http, session) {
93
94
  };
94
95
  const { proxy, browser, chromeVersion } = session;
95
96
  const blockedBefore = proxy.blocked.length;
97
+ // Hosts this page asked for, so blocked requests of a tab rendering at
98
+ // the same time are not counted here.
99
+ const requestedHosts = new Set();
96
100
  const result = { observations: [], artifacts: [], candidates: [] };
97
101
  const errors = [];
98
102
  let page;
@@ -102,8 +106,9 @@ export async function collectBrowser(scan, url, http, session) {
102
106
  stage: "budget",
103
107
  error: `Browser job exceeded ${budgetSeconds} seconds`,
104
108
  });
105
- // Closing the browser aborts the page; the worker reopens a session.
106
- void browser.close();
109
+ // Closing the tab aborts this page and leaves its neighbours alone; a
110
+ // tab that will not close takes the browser with it.
111
+ void (page ? page.close().catch(() => browser.close()) : browser.close());
107
112
  }, budgetSeconds * 1000);
108
113
  watchdog.unref();
109
114
  try {
@@ -134,6 +139,12 @@ export async function collectBrowser(scan, url, http, session) {
134
139
  const cdp = await page.createCDPSession();
135
140
  await cdp.send("Network.enable");
136
141
  cdp.on("Network.requestWillBeSent", (e) => {
142
+ try {
143
+ requestedHosts.add(new URL(e.request?.url ?? "").hostname);
144
+ }
145
+ catch {
146
+ // Not a URL with a host (data:, blob:).
147
+ }
137
148
  if (staticTypes.test(e.type) && /^https?:/.test(e.request?.url ?? ""))
138
149
  staticResources.add(e.request.url);
139
150
  });
@@ -622,7 +633,9 @@ export async function collectBrowser(scan, url, http, session) {
622
633
  data: {
623
634
  status: errors.length ? "partial" : "completed",
624
635
  errors,
625
- blockedNetwork: proxy.blocked.slice(blockedBefore),
636
+ blockedNetwork: proxy.blocked
637
+ .slice(blockedBefore)
638
+ .filter((entry) => [...requestedHosts].some((host) => entry.target.includes(host))),
626
639
  },
627
640
  });
628
641
  return result;
@@ -7,6 +7,8 @@ export async function createEgressProxy() {
7
7
  const sockets = new Set();
8
8
  const remember = (socket) => {
9
9
  sockets.add(socket);
10
+ // Several checks share a kept-alive socket; each adds its own listeners.
11
+ socket.setMaxListeners(50);
10
12
  socket.on("close", () => sockets.delete(socket));
11
13
  socket.setTimeout(30000, () => socket.destroy());
12
14
  };
@@ -1,4 +1,4 @@
1
- import { ScanState } from "./state.js";
1
+ import { ScanState, type ScanSnapshot } from "./state.js";
2
2
  import { type JobKind, type Options, type Scan } from "./types.js";
3
3
  import { type PreviousScan, type Report, type StoredFinding } from "../report/build.js";
4
4
  export type ScanEvent = {
@@ -26,6 +26,10 @@ export interface RunInput {
26
26
  rotation?: number;
27
27
  previous?: PreviousScan;
28
28
  budgetMs?: number;
29
+ resume?: ScanSnapshot;
30
+ pauseAtBudget?: boolean;
31
+ checkpointEveryMs?: number;
32
+ onCheckpoint?: (snapshot: ScanSnapshot) => Promise<void>;
29
33
  signal?: AbortSignal;
30
34
  onEvent?: (event: ScanEvent) => void;
31
35
  }
@@ -36,3 +40,6 @@ export interface RunOutput {
36
40
  state: ScanState;
37
41
  }
38
42
  export declare function runScan(input: RunInput): Promise<RunOutput>;
43
+ export declare function runScanStep(input: RunInput): Promise<RunOutput | {
44
+ paused: ScanSnapshot;
45
+ }>;
@@ -16,11 +16,21 @@ const crawlKinds = [
16
16
  ];
17
17
  const maxJobMs = 300000;
18
18
  export async function runScan(input) {
19
- const options = optionsSchema.parse(input.options ?? {});
19
+ const result = await runScanStep({ ...input, pauseAtBudget: false });
20
+ if ("paused" in result)
21
+ throw new Error("Scan paused unexpectedly");
22
+ return result;
23
+ }
24
+ // One stretch of a scan: runs until it is done or, with `pauseAtBudget`,
25
+ // until the budget runs out, and then hands back a snapshot.
26
+ export async function runScanStep(input) {
27
+ const options = input.resume
28
+ ? input.resume.scan.options
29
+ : optionsSchema.parse(input.options ?? {});
20
30
  setLocalTargetsAllowed(options.allowLocal);
21
- const url = normalizeUrl(input.url);
31
+ const url = input.resume ? input.resume.scan.url : normalizeUrl(input.url);
22
32
  await resolvePublic(new URL(url).hostname);
23
- const scan = {
33
+ const scan = input.resume?.scan ?? {
24
34
  id: randomUUID(),
25
35
  url,
26
36
  status: "running",
@@ -29,20 +39,33 @@ export async function runScan(input) {
29
39
  rotation: input.rotation ?? 0,
30
40
  coverage: { omittedCandidates: 0 },
31
41
  };
32
- const state = new ScanState(scan);
42
+ const state = input.resume
43
+ ? ScanState.restore(input.resume)
44
+ : new ScanState(scan);
45
+ let phase = input.resume?.phase ?? "crawl";
33
46
  const emit = (event) => input.onEvent?.(event);
34
47
  emit({ type: "start", scan });
35
48
  const session = new HttpSession();
36
49
  const http = new HttpClient(options, undefined, undefined, session);
37
50
  const deadline = input.budgetMs ? Date.now() + input.budgetMs : Infinity;
38
51
  let browser;
52
+ let opening;
53
+ // One Chrome for the scan. Jobs that ask at the same moment share the
54
+ // launch instead of starting two browsers.
39
55
  const browserSession = async () => {
40
- const { openBrowserSession } = await import("./browser.js");
41
- if (browser && !browser.connected) {
42
- await browser.close();
43
- browser = undefined;
44
- }
45
- return (browser ??= await openBrowserSession());
56
+ if (browser?.connected)
57
+ return browser;
58
+ opening ??= (async () => {
59
+ const { openBrowserSession } = await import("./browser.js");
60
+ await browser?.close();
61
+ browser = await openBrowserSession();
62
+ opening = undefined;
63
+ return browser;
64
+ })().catch((error) => {
65
+ opening = undefined;
66
+ throw error;
67
+ });
68
+ return opening;
46
69
  };
47
70
  const run = async (job) => {
48
71
  switch (job.kind) {
@@ -107,7 +130,8 @@ export async function runScan(input) {
107
130
  job.error = message;
108
131
  job.status = job.attempts < limit ? "queued" : "failed";
109
132
  job.durationMs = Math.round(performance.now() - started);
110
- if (job.status === "failed" && ["browser", "lighthouse"].includes(job.kind))
133
+ if (job.status === "failed" &&
134
+ ["browser", "lighthouse"].includes(job.kind))
111
135
  state.record({
112
136
  observations: [
113
137
  {
@@ -133,50 +157,98 @@ export async function runScan(input) {
133
157
  pending: state.pending(kinds),
134
158
  });
135
159
  }
136
- // Chromium and Lighthouse leave memory behind; a fresh browser for the
137
- // next page is cheaper than the process growing without bound.
138
- if (["browser", "lighthouse"].includes(job.kind) &&
139
- browser &&
140
- process.memoryUsage().rss > 1500 * 1024 * 1024) {
141
- await browser.close();
142
- browser = undefined;
143
- }
144
160
  };
161
+ // How many jobs of a kind run at once. Link and file checks mostly wait on
162
+ // the network; two pages render side by side as tabs of one Chrome. Pages
163
+ // of the scanned site are fetched one at a time so the site sets the pace,
164
+ // and Lighthouse runs alone because a busy CPU worsens its scores.
165
+ const concurrency = {
166
+ resource: 6,
167
+ browser: 2,
168
+ };
169
+ const browserKinds = ["browser", "lighthouse"];
170
+ let lastCheckpoint = Date.now();
171
+ // True when the budget ran out and the scan should pause here.
145
172
  const drain = async (kinds) => {
146
- let job;
147
- while ((job = state.next(kinds))) {
148
- if (Date.now() > deadline || input.signal?.aborted) {
173
+ const inFlight = new Map();
174
+ const running = (list) => [...inFlight.keys()].filter((j) => list.includes(j.kind)).length;
175
+ let recycle = false;
176
+ for (;;) {
177
+ const stopping = Date.now() > deadline || !!input.signal?.aborted;
178
+ let job;
179
+ while (!stopping &&
180
+ (job = state.next(kinds.filter((kind) => running([kind]) < (concurrency[kind] ?? 1) &&
181
+ // No new tab while Chrome waits to be replaced.
182
+ !(recycle && browserKinds.includes(kind)))))) {
183
+ const current = job;
184
+ current.status = "running";
185
+ inFlight.set(current, execute(current, kinds).finally(() => inFlight.delete(current)));
186
+ }
187
+ if (!inFlight.size) {
188
+ if (!stopping || !state.pending(kinds))
189
+ break;
190
+ if (input.pauseAtBudget)
191
+ return true;
149
192
  state.cancelQueued();
150
193
  scan.coverage.expired = true;
151
194
  break;
152
195
  }
153
- await execute(job, kinds);
196
+ await Promise.race(inFlight.values());
197
+ // Chromium and Lighthouse leave memory behind; a fresh browser is
198
+ // cheaper than the process growing without bound. It is replaced once
199
+ // no tab is open.
200
+ if (browser && process.memoryUsage().rss > 1200 * 1024 * 1024)
201
+ recycle = true;
202
+ if (recycle && !running(browserKinds)) {
203
+ await browser?.close();
204
+ browser = undefined;
205
+ recycle = false;
206
+ }
207
+ if (input.onCheckpoint &&
208
+ input.checkpointEveryMs &&
209
+ Date.now() - lastCheckpoint > input.checkpointEveryMs) {
210
+ lastCheckpoint = Date.now();
211
+ await input.onCheckpoint(state.snapshot(phase));
212
+ }
154
213
  }
214
+ return false;
155
215
  };
156
216
  try {
157
- emit({ type: "phase", phase: "crawl" });
158
- state.enqueue("discover", "site");
159
- await drain(crawlKinds);
160
- emit({ type: "phase", phase: "lighthouse" });
161
- const pages = state
162
- .records(["page"])
163
- .filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
164
- .sort((a, b) => a.key.localeCompare(b.key));
165
- const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
166
- ? options
167
- : { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
168
- scan.coverage.templates = templates;
169
- scan.coverage.lighthouseEligible = pages.length;
170
- scan.coverage.lighthouseSelected = selected;
171
- scan.coverage.lighthouseSelection =
172
- options.lighthouse === "all"
173
- ? "Every eligible HTML page"
174
- : options.lighthouse === "templates"
175
- ? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
176
- : "Lighthouse disabled";
177
- for (const url of selected)
178
- state.enqueue("lighthouse", url);
179
- await drain(["lighthouse"]);
217
+ if (phase === "crawl") {
218
+ emit({ type: "phase", phase: "crawl" });
219
+ if (!input.resume)
220
+ state.enqueue("discover", "site");
221
+ if (await drain(crawlKinds))
222
+ return { paused: state.snapshot("crawl") };
223
+ }
224
+ if (phase !== "analysis")
225
+ emit({ type: "phase", phase: "lighthouse" });
226
+ if (phase === "crawl") {
227
+ const pages = state
228
+ .records(["page"])
229
+ .filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
230
+ .sort((a, b) => a.key.localeCompare(b.key));
231
+ const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
232
+ ? options
233
+ : { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
234
+ scan.coverage.templates = templates;
235
+ scan.coverage.lighthouseEligible = pages.length;
236
+ scan.coverage.lighthouseSelected = selected;
237
+ scan.coverage.lighthouseSelection =
238
+ options.lighthouse === "all"
239
+ ? "Every eligible HTML page"
240
+ : options.lighthouse === "templates"
241
+ ? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
242
+ : "Lighthouse disabled";
243
+ for (const url of selected)
244
+ state.enqueue("lighthouse", url);
245
+ phase = "lighthouse";
246
+ }
247
+ if (phase === "lighthouse") {
248
+ if (await drain(["lighthouse"]))
249
+ return { paused: state.snapshot("lighthouse") };
250
+ phase = "analysis";
251
+ }
180
252
  }
181
253
  finally {
182
254
  await browser?.close();
@@ -4,6 +4,17 @@ export interface StoredArtifact extends Artifact {
4
4
  sha256: string;
5
5
  bytes: number;
6
6
  }
7
+ export interface ScanSnapshot {
8
+ version: 1;
9
+ phase: "crawl" | "lighthouse" | "analysis";
10
+ scan: Scan;
11
+ observations: Observation[];
12
+ urls: UrlRow[];
13
+ jobs: Job[];
14
+ artifacts: (Omit<StoredArtifact, "body"> & {
15
+ body: string;
16
+ })[];
17
+ }
7
18
  export declare class ScanState {
8
19
  readonly scan: Scan;
9
20
  readonly observations: Map<string, Observation>;
@@ -12,6 +23,8 @@ export declare class ScanState {
12
23
  readonly jobs: Job[];
13
24
  private readonly jobIndex;
14
25
  constructor(scan: Scan);
26
+ snapshot(phase: ScanSnapshot["phase"]): ScanSnapshot;
27
+ static restore(snapshot: ScanSnapshot): ScanState;
15
28
  enqueue(kind: JobKind, name: string): void;
16
29
  next(kinds: JobKind[]): Job | undefined;
17
30
  pending(kinds: JobKind[]): number;
@@ -16,6 +16,40 @@ export class ScanState {
16
16
  constructor(scan) {
17
17
  this.scan = scan;
18
18
  }
19
+ snapshot(phase) {
20
+ return {
21
+ version: 1,
22
+ phase,
23
+ scan: this.scan,
24
+ observations: [...this.observations.values()],
25
+ urls: [...this.urls.values()],
26
+ jobs: this.jobs,
27
+ artifacts: [...this.artifacts.values()].map((a) => ({
28
+ ...a,
29
+ body: a.body.toString("base64"),
30
+ })),
31
+ };
32
+ }
33
+ static restore(snapshot) {
34
+ const state = new ScanState(snapshot.scan);
35
+ for (const o of snapshot.observations)
36
+ state.observations.set(key(o.kind, o.key), o);
37
+ for (const u of snapshot.urls)
38
+ state.urls.set(key(u.kind, u.url), u);
39
+ for (const job of snapshot.jobs) {
40
+ // A job that was running when the process stopped starts over.
41
+ if (job.status === "running")
42
+ job.status = "queued";
43
+ state.jobs.push(job);
44
+ state.jobIndex.set(key(job.kind, job.key), job);
45
+ }
46
+ for (const a of snapshot.artifacts)
47
+ state.artifacts.set(key(a.kind, a.key), {
48
+ ...a,
49
+ body: Buffer.from(a.body, "base64"),
50
+ });
51
+ return state;
52
+ }
19
53
  enqueue(kind, name) {
20
54
  const id = key(kind, name);
21
55
  if (this.jobIndex.has(id))
package/dist/engine.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { runScan, type RunInput, type RunOutput, type ScanEvent } from "./engine/run.js";
1
+ export { runScan, runScanStep, type RunInput, type RunOutput, type ScanEvent, } from "./engine/run.js";
2
+ export type { ScanSnapshot } from "./engine/state.js";
package/dist/engine.js CHANGED
@@ -1 +1 @@
1
- export { runScan } from "./engine/run.js";
1
+ export { runScan, runScanStep, } from "./engine/run.js";
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@fulldotdev/scan",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Whole-site website scanner: crawls every page, renders it, runs Lighthouse and axe, and reports every problem it finds.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "repository": "github:fulldotdev/scan",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "github:fulldotdev/scan"
10
+ },
8
11
  "homepage": "https://scan.full.dev",
9
12
  "bin": {
10
13
  "fullscan": "./bin/fullscan.js"
@@ -17,18 +20,14 @@
17
20
  },
18
21
  "types": "./dist/index.d.ts",
19
22
  "files": [
23
+ "SKILL.md",
20
24
  "bin",
21
- "dist"
25
+ "dist",
26
+ "templates"
22
27
  ],
23
28
  "engines": {
24
29
  "node": ">=22"
25
30
  },
26
- "scripts": {
27
- "build": "tsc -p tsconfig.build.json",
28
- "typecheck": "tsc --noEmit",
29
- "test": "tsx --test tests/*.test.ts",
30
- "scan": "tsx src/cli.ts"
31
- },
32
31
  "dependencies": {
33
32
  "axe-core": "^4.13.0",
34
33
  "cheerio": "^1.2.0",
@@ -48,5 +47,11 @@
48
47
  "@types/node": "^24.13.4",
49
48
  "tsx": "^4.23.13",
50
49
  "typescript": "^7.0.2"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "typecheck": "tsc --noEmit",
54
+ "test": "tsx --test tests/*.test.ts",
55
+ "scan": "tsx src/cli.ts"
51
56
  }
52
- }
57
+ }
@@ -0,0 +1,83 @@
1
+ # Scan: copy to .github/workflows/scan.yml and add the repository secret
2
+ # FULLSCAN_KEY (an API key from the account page on scan.full.dev).
3
+ # Set SITE_URL to the live site.
4
+ name: Scan
5
+
6
+ on:
7
+ pull_request:
8
+ schedule:
9
+ - cron: "30 2 * * *" # daily crawl
10
+ - cron: "30 3 * * 0" # weekly full scan: browser pass, Lighthouse, external sources
11
+ workflow_dispatch:
12
+
13
+ env:
14
+ SITE_URL: https://example.com
15
+
16
+ permissions:
17
+ contents: read
18
+ pull-requests: write
19
+ statuses: read
20
+
21
+ concurrency:
22
+ group: scan-${{ github.ref }}
23
+ cancel-in-progress: true
24
+
25
+ jobs:
26
+ scan:
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 120
29
+ steps:
30
+ - uses: actions/setup-node@v6
31
+ with:
32
+ node-version: 24
33
+
34
+ # A pull request is scanned on its Netlify deploy preview, which Netlify
35
+ # reports as a commit status once the preview is ready.
36
+ - name: Find the URL to scan
37
+ id: target
38
+ env:
39
+ GH_TOKEN: ${{ github.token }}
40
+ run: |
41
+ if [ "${{ github.event_name }}" != "pull_request" ]; then
42
+ echo "url=$SITE_URL" >> "$GITHUB_OUTPUT"; exit 0
43
+ fi
44
+ for i in $(seq 1 60); do
45
+ url=$(gh api "repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/status" \
46
+ -q '.statuses[] | select(.context | test("^netlify/.+/deploy-preview$")) | select(.state == "success") | .target_url' | head -1)
47
+ [ -n "$url" ] && break
48
+ sleep 10
49
+ done
50
+ [ -n "$url" ] || { echo "No deploy preview found"; exit 1; }
51
+ echo "url=$url" >> "$GITHUB_OUTPUT"
52
+
53
+ - name: Scan
54
+ env:
55
+ FULLSCAN_KEY: ${{ secrets.FULLSCAN_KEY }}
56
+ run: |
57
+ mode=""
58
+ # Daily runs and pull requests crawl only; the weekly run is the full scan.
59
+ if [ "${{ github.event.schedule }}" = "30 2 * * *" ]; then mode="--quick"; fi
60
+ push=""
61
+ # Only scans of the live site go to the platform's history.
62
+ if [ "${{ github.event_name }}" != "pull_request" ] && [ -n "$FULLSCAN_KEY" ]; then push="--push"; fi
63
+ npx --yes @fulldotdev/scan "${{ steps.target.outputs.url }}" $mode $push --fail-on critical
64
+ - name: Summary
65
+ if: always()
66
+ run: cat scan/summary.md >> "$GITHUB_STEP_SUMMARY"
67
+
68
+ - name: Comment on the pull request
69
+ if: always() && github.event_name == 'pull_request'
70
+ env:
71
+ GH_TOKEN: ${{ github.token }}
72
+ run: |
73
+ gh pr comment ${{ github.event.pull_request.number }} -R ${{ github.repository }} --edit-last --body-file scan/summary.md \
74
+ || gh pr comment ${{ github.event.pull_request.number }} -R ${{ github.repository }} --body-file scan/summary.md
75
+
76
+ - uses: actions/upload-artifact@v4
77
+ if: always()
78
+ with:
79
+ name: scan
80
+ path: |
81
+ scan/report.md
82
+ scan/result.json
83
+ scan/summary.md