@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 +8 -1
- package/SKILL.md +27 -0
- package/dist/cli.js +155 -31
- package/dist/engine/browser.js +18 -5
- package/dist/engine/proxy.js +2 -0
- package/dist/engine/run.d.ts +8 -1
- package/dist/engine/run.js +118 -46
- package/dist/engine/state.d.ts +13 -0
- package/dist/engine/state.js +34 -0
- package/dist/engine.d.ts +2 -1
- package/dist/engine.js +1 -1
- package/package.json +15 -10
- package/templates/github-workflow.yml +83 -0
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` |
|
|
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
|
|
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
|
-
:
|
|
106
|
-
?
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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";
|
package/dist/engine/browser.js
CHANGED
|
@@ -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
|
|
106
|
-
|
|
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
|
|
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;
|
package/dist/engine/proxy.js
CHANGED
|
@@ -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
|
};
|
package/dist/engine/run.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/engine/run.js
CHANGED
|
@@ -16,11 +16,21 @@ const crawlKinds = [
|
|
|
16
16
|
];
|
|
17
17
|
const maxJobMs = 300000;
|
|
18
18
|
export async function runScan(input) {
|
|
19
|
-
const
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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" &&
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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();
|
package/dist/engine/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/engine/state.js
CHANGED
|
@@ -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.
|
|
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":
|
|
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
|