@fulldotdev/scan 0.1.0 → 0.2.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 +27 -2
- package/dist/engine/browser.js +2 -0
- package/dist/engine/run.d.ts +8 -1
- package/dist/engine/run.js +66 -29
- 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
|
|
@@ -76,7 +76,7 @@ const options = {
|
|
|
76
76
|
? "off"
|
|
77
77
|
: (values.lighthouse ??
|
|
78
78
|
"templates"),
|
|
79
|
-
external: values.external,
|
|
79
|
+
external: values.external && !values.quick,
|
|
80
80
|
allowLocal: local,
|
|
81
81
|
...(values.pages ? { maxPages: Number(values.pages) } : {}),
|
|
82
82
|
};
|
|
@@ -154,6 +154,7 @@ const out = values.out;
|
|
|
154
154
|
await mkdir(join(out, "artifacts"), { recursive: true });
|
|
155
155
|
await writeFile(join(out, "result.json"), JSON.stringify(result, null, 2));
|
|
156
156
|
await writeFile(join(out, "report.md"), formatReport(report) + "\n");
|
|
157
|
+
await writeFile(join(out, "summary.md"), summary() + "\n");
|
|
157
158
|
await writeFile(join(out, "records.ndjson"), records.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
158
159
|
const index = {};
|
|
159
160
|
for (const artifact of state.artifacts.values()) {
|
|
@@ -222,6 +223,30 @@ if (values["fail-on"]) {
|
|
|
222
223
|
: c.critical > 0 || c.warning > 0;
|
|
223
224
|
process.exit(failed ? 1 : 0);
|
|
224
225
|
}
|
|
226
|
+
// A short version of the report for a pull request comment or a job summary.
|
|
227
|
+
function summary() {
|
|
228
|
+
const c = report.counts;
|
|
229
|
+
const top = report.findings
|
|
230
|
+
.filter((f) => f.severity !== "hint")
|
|
231
|
+
.slice(0, 10)
|
|
232
|
+
.map((f) => `- **${f.title}** (${f.severity}, ${f.count} on ${f.pages} pages). ${f.impact}`);
|
|
233
|
+
const changes = report.changes
|
|
234
|
+
? [
|
|
235
|
+
"",
|
|
236
|
+
`Since the previous scan: ${report.changes.newFindings.length} new, ${(report.changes.resolvedFindings ?? []).length} resolved, ${(report.changes.worsenedFindings ?? []).length} worse.`,
|
|
237
|
+
...report.changes.newFindings
|
|
238
|
+
.slice(0, 10)
|
|
239
|
+
.map((f) => `- New: **${f.title}** (${f.severity}, ${f.count})`),
|
|
240
|
+
]
|
|
241
|
+
: [];
|
|
242
|
+
return [
|
|
243
|
+
`### Scan of ${new URL(scan.url).hostname}`,
|
|
244
|
+
"",
|
|
245
|
+
`${report.pages.observed} pages, **${c.critical} critical**, **${c.warning} warnings**, ${c.hint ?? 0} hints. Status ${scan.status}.`,
|
|
246
|
+
...changes,
|
|
247
|
+
...(top.length ? ["", "Most important problems:", ...top] : []),
|
|
248
|
+
].join("\n");
|
|
249
|
+
}
|
|
225
250
|
function extension(contentType) {
|
|
226
251
|
if (/png/.test(contentType))
|
|
227
252
|
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}`,
|
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,7 +39,10 @@ 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();
|
|
@@ -107,7 +120,8 @@ export async function runScan(input) {
|
|
|
107
120
|
job.error = message;
|
|
108
121
|
job.status = job.attempts < limit ? "queued" : "failed";
|
|
109
122
|
job.durationMs = Math.round(performance.now() - started);
|
|
110
|
-
if (job.status === "failed" &&
|
|
123
|
+
if (job.status === "failed" &&
|
|
124
|
+
["browser", "lighthouse"].includes(job.kind))
|
|
111
125
|
state.record({
|
|
112
126
|
observations: [
|
|
113
127
|
{
|
|
@@ -137,46 +151,69 @@ export async function runScan(input) {
|
|
|
137
151
|
// next page is cheaper than the process growing without bound.
|
|
138
152
|
if (["browser", "lighthouse"].includes(job.kind) &&
|
|
139
153
|
browser &&
|
|
140
|
-
process.memoryUsage().rss >
|
|
154
|
+
process.memoryUsage().rss > 1200 * 1024 * 1024) {
|
|
141
155
|
await browser.close();
|
|
142
156
|
browser = undefined;
|
|
143
157
|
}
|
|
144
158
|
};
|
|
159
|
+
let lastCheckpoint = Date.now();
|
|
160
|
+
// True when the budget ran out and the scan should pause here.
|
|
145
161
|
const drain = async (kinds) => {
|
|
146
162
|
let job;
|
|
147
163
|
while ((job = state.next(kinds))) {
|
|
148
164
|
if (Date.now() > deadline || input.signal?.aborted) {
|
|
165
|
+
if (input.pauseAtBudget)
|
|
166
|
+
return true;
|
|
149
167
|
state.cancelQueued();
|
|
150
168
|
scan.coverage.expired = true;
|
|
151
169
|
break;
|
|
152
170
|
}
|
|
153
171
|
await execute(job, kinds);
|
|
172
|
+
if (input.onCheckpoint &&
|
|
173
|
+
input.checkpointEveryMs &&
|
|
174
|
+
Date.now() - lastCheckpoint > input.checkpointEveryMs) {
|
|
175
|
+
lastCheckpoint = Date.now();
|
|
176
|
+
await input.onCheckpoint(state.snapshot(phase));
|
|
177
|
+
}
|
|
154
178
|
}
|
|
179
|
+
return false;
|
|
155
180
|
};
|
|
156
181
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
if (phase === "crawl") {
|
|
183
|
+
emit({ type: "phase", phase: "crawl" });
|
|
184
|
+
if (!input.resume)
|
|
185
|
+
state.enqueue("discover", "site");
|
|
186
|
+
if (await drain(crawlKinds))
|
|
187
|
+
return { paused: state.snapshot("crawl") };
|
|
188
|
+
}
|
|
189
|
+
if (phase !== "analysis")
|
|
190
|
+
emit({ type: "phase", phase: "lighthouse" });
|
|
191
|
+
if (phase === "crawl") {
|
|
192
|
+
const pages = state
|
|
193
|
+
.records(["page"])
|
|
194
|
+
.filter((p) => p.data?.http?.outcome === "ok" && p.data?.html !== false)
|
|
195
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
196
|
+
const { selected, templates } = selectLighthousePages(pages, options.browser && !scan.coverage.expired
|
|
197
|
+
? options
|
|
198
|
+
: { lighthouse: "off", lighthousePerTemplate: 1 }, scan.rotation, scan.url);
|
|
199
|
+
scan.coverage.templates = templates;
|
|
200
|
+
scan.coverage.lighthouseEligible = pages.length;
|
|
201
|
+
scan.coverage.lighthouseSelected = selected;
|
|
202
|
+
scan.coverage.lighthouseSelection =
|
|
203
|
+
options.lighthouse === "all"
|
|
204
|
+
? "Every eligible HTML page"
|
|
205
|
+
: options.lighthouse === "templates"
|
|
206
|
+
? `Homepage plus ${options.lighthousePerTemplate} rotating page per template (${templates} templates)`
|
|
207
|
+
: "Lighthouse disabled";
|
|
208
|
+
for (const url of selected)
|
|
209
|
+
state.enqueue("lighthouse", url);
|
|
210
|
+
phase = "lighthouse";
|
|
211
|
+
}
|
|
212
|
+
if (phase === "lighthouse") {
|
|
213
|
+
if (await drain(["lighthouse"]))
|
|
214
|
+
return { paused: state.snapshot("lighthouse") };
|
|
215
|
+
phase = "analysis";
|
|
216
|
+
}
|
|
180
217
|
}
|
|
181
218
|
finally {
|
|
182
219
|
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.2.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
|