diff --git a/CHANGELOG.md b/CHANGELOG.md
index df30067..0074011 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,78 @@ move: the JSON report's `schemaVersion` and the baseline file's. Both are bumped
a field is removed, renamed, or changes meaning — new fields may appear without one, so
consumers must ignore what they do not recognise.
+## Unreleased
+
+### Fixed
+
+- **`audit --browser` audited nothing at all unless it was given a directory.** The
+ runner takes the build directory so it can serve the pages over loopback; passing
+ nothing is how a caller says these pages came off a running site and already have
+ somewhere to be fetched from. The audit command passed the directory *the user typed* —
+ which under auto-detection is nothing, because auto-detection is the thing that works it
+ out. So `eaa-kit audit --browser`, the form the README leads with, skipped the server and
+ navigated Chromium to `/home/you/site/dist/index.html`: a filesystem path, not a URL.
+ Every page came back `Cannot navigate to invalid URL`.
+
+ The collection stage now reports the directory it settled on, and both commands pass that
+ along. A real crawl still passes nothing, because serving a crawled page back out of a
+ copy on disk would audit the markup with the server that produced it cut out of the
+ picture. `--browser` with an explicit directory was never affected, which is why the
+ suite did not see it: every browser test named one.
+
+- **A run where every page failed reported "No violations".** The count in that sentence was
+ of pages attempted, not pages audited, so a run that could read none of them opened its
+ summary with a clean result in green — the exact fail-open the rest of the tool is built
+ to refuse — with the error line beneath it. It now says nothing was audited, and where
+ only some pages failed the counts are over the pages that were actually read, so
+ "no violations across 2 pages" cannot describe a run that opened one.
+
+- **A baseline stopped suppressing a barrier when anything else on the page changed**, and
+ `diff` reported that same untouched barrier as **fixed**. The identity of a violating
+ element hashed the element's whole outer markup, and for the document-level rules —
+ `html-has-lang`, `document-title`, and every other rule that fails against `` — the
+ element's outer markup is the entire page. Adding one paragraph gave them a new identity,
+ and all three consumers of that identity believed it: the build went red on barriers a
+ team had accepted, `diff` announced a missing `
` as fixed while the page still had
+ none, and SARIF churned its fingerprints so code scanning closed an alert and opened an
+ identical one on every edit.
+
+ The hash now covers the element's opening tag and not its descendants. Where two elements
+ share an opening tag, axe-core's selector already tells them apart — it qualifies an
+ ambiguous match with `:nth-child(…)` — so nothing that could be distinguished before
+ stops being distinguishable, and a barrier that is genuinely fixed still changes identity.
+ This moves both file contracts; see **Changed**.
+
+- **A browser run reported criteria as unevaluable and advised `--browser`.** The coverage
+ view consulted the table of rules jsdom is structurally blind to without asking which
+ engine had run, so a report produced in real Chromium said colour contrast and target
+ size were beyond it, and closed by suggesting the flag that run had been given. It now
+ believes a browser run's own results: a rule reaches no verdict there only if that run
+ said so.
+
+- **A filter that excluded every page was reported as an empty build directory**, together
+ with framework advice naming another directory to audit — a fix for a path that was never
+ wrong. `--include`/`--exclude` matching nothing now says so, and echoes the patterns it
+ was given.
+
+- One unreachable page was counted in the singular and conjugated in the plural: "1 page
+ could not be reached, and were not audited".
+
+### Changed
+
+- **The baseline file's `schemaVersion` moves to 2** and the JSON report's to 2, because
+ `fingerprint` changed meaning in both. Neither is read across the boundary: a baseline
+ written by 0.4.0 records identities under the old rule, and matching them against the new
+ one would suppress nothing while looking as though it had, so it is refused with the
+ command that rewrites it. `diff` likewise refuses to compare a 0.4.0 report against a
+ newer one — that comparison is precisely the one that reports every document-level
+ barrier as both new and fixed. SARIF's partial fingerprint key moves to `eaaKit/v2` for
+ the same reason, which is what tells code scanning these are a new scheme rather than
+ defects that moved.
+
+ Re-record a baseline with `eaa-kit baseline`, and read the new file before committing it:
+ it lists what this run found, which is not necessarily what the old one accepted.
+
## 0.4.0 — 2026-09-01
### Added
diff --git a/docs/baseline.md b/docs/baseline.md
index a916349..e9bf61c 100644
--- a/docs/baseline.md
+++ b/docs/baseline.md
@@ -50,13 +50,13 @@ to become one.
```jsonc
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
"createdOn": "2026-08-27",
"entries": [
{
"page": "index.html",
"ruleId": "image-alt",
- "fingerprint": "f0e17d2582e9a5b3", // rule + selector + markup, not the path
+ "fingerprint": "f0e17d2582e9a5b3", // rule + selector + opening tag, not the path
"selector": "img", // the rest is for whoever reads the file
"help": "Images must have alternative text",
"impact": "critical",
@@ -70,8 +70,16 @@ to become one.
The fingerprint deliberately excludes the page path — it is the same one SARIF uses — so
moving a page does not invalidate the entry's identity, though the `page` field does have
-to match. Entries are sorted, so the file diffs cleanly and two people regenerating it get
-the same result.
+to match. It also excludes anything nested inside the failing element, so an entry for a
+document-level rule such as `html-has-lang` survives an edit elsewhere on the page.
+Entries are sorted, so the file diffs cleanly and two people regenerating it get the same
+result.
+
+A baseline written by an earlier version is refused rather than read: `schemaVersion` 1
+recorded fingerprints under the old rule, and matching them against the new one would
+suppress nothing while looking as though it had. Record it again with `eaa-kit baseline`,
+then read the new file before committing it — it lists what this run found, which is not
+necessarily what the old one accepted.
## In code scanning
diff --git a/docs/reports.md b/docs/reports.md
index d24da2c..4f720c3 100644
--- a/docs/reports.md
+++ b/docs/reports.md
@@ -10,7 +10,7 @@ shareable ones.
### Compatibility
-- `schemaVersion` is an integer, currently `1`.
+- `schemaVersion` is an integer, currently `2`.
- It is bumped only when a field is **removed, renamed, or changes meaning**.
- New fields may be added without a bump, so **consumers must ignore fields they do not
recognise**.
@@ -33,7 +33,7 @@ A complete generated document is checked in at
```jsonc
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
"tool": {
"name": "eaa-kit",
"version": "0.1.0",
@@ -182,18 +182,25 @@ at its limit. A tool deciding whether a clean report means anything should read
fetched; an errored one was fetched and then could not be audited. They are different
failures with different fixes, and summing them would name neither.
-`completeness` was added after `schemaVersion` 1 and does not move it: new fields may
+`completeness` was added without moving `schemaVersion`: new fields may
appear without a bump, and consumers must ignore what they do not recognise. A consumer
-written against version 1 that has never seen this field should treat its absence as
-unknown rather than as a complete run.
+written against an earlier version that has never seen this field should treat its absence
+as unknown rather than as a complete run.
### `fingerprint`
Each node carries the same identity the baseline file records and SARIF sends as a partial
-fingerprint: a hash of the rule, the selector and the element's own markup, and deliberately
-not of the page it was found on. Two consumers already depended on it agreeing — SARIF, so
-that moving a page does not close one code-scanning alert and open an identical one, and
-the baseline, so an accepted violation stays accepted when the surrounding page changes.
+fingerprint: a hash of the rule, the selector and the element's own opening tag, and
+deliberately not of the page it was found on. Two consumers already depended on it
+agreeing — SARIF, so that moving a page does not close one code-scanning alert and open an
+identical one, and the baseline, so an accepted violation stays accepted when the
+surrounding page changes.
+
+The tag, not the element's whole markup. axe-core reports the failing element's outerHTML,
+which for `` — the element every document-level rule fails against — is the entire
+page. Hashing that made `html-has-lang` and `document-title` change identity on any edit
+to the page they were on, which is precisely what these two consumers exist to survive.
+Where two elements share an opening tag, axe-core's selector already tells them apart.
It is emitted so a third does not have to reimplement the hash and risk disagreeing with
them. `eaa-kit diff` matches on it.
@@ -213,7 +220,7 @@ the site — which is the same mistake as adding `inapplicable` to `passes`, and
`notEvaluated` criteria a `--browser` run would decide. The remainder need a person
whatever engine runs.
-Added after `schemaVersion` 1 and does not move it.
+Added without moving `schemaVersion`.
## Comparing two runs
@@ -285,7 +292,7 @@ Real results from the fixture run above, abbreviated:
}
}
],
- "partialFingerprints": { "eaaKit/v1": "4c2a13ab4c8c0365" }
+ "partialFingerprints": { "eaaKit/v2": "b6128eff391a4be6" }
},
{
"ruleId": "image-alt",
@@ -300,7 +307,7 @@ Real results from the fixture run above, abbreviated:
}
}
],
- "partialFingerprints": { "eaaKit/v1": "f0e17d2582e9a5b3" }
+ "partialFingerprints": { "eaaKit/v2": "f0e17d2582e9a5b3" }
}
],
"invocations": [{ "executionSuccessful": true, "toolExecutionNotifications": [] }],
diff --git a/examples/baseline.json b/examples/baseline.json
index 2703287..4db6532 100644
--- a/examples/baseline.json
+++ b/examples/baseline.json
@@ -1,11 +1,11 @@
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
"createdOn": "2026-09-01",
"entries": [
{
"page": "index.html",
"ruleId": "html-has-lang",
- "fingerprint": "4c2a13ab4c8c0365",
+ "fingerprint": "b6128eff391a4be6",
"selector": "html",
"help": " element must have a lang attribute",
"impact": "serious",
@@ -23,7 +23,7 @@
{
"page": "index.html",
"ruleId": "link-name",
- "fingerprint": "e444708c0ffb67e0",
+ "fingerprint": "3943063f55791bff",
"selector": "a",
"help": "Links must have discernible text",
"impact": "serious",
diff --git a/examples/report.json b/examples/report.json
index 2e4b709..8ff69d1 100644
--- a/examples/report.json
+++ b/examples/report.json
@@ -1,5 +1,5 @@
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
"tool": {
"name": "eaa-kit",
"version": "0.4.0",
@@ -1239,7 +1239,7 @@
"h1"
],
"failureSummary": "Fix all of the following:\n Axe encountered an error; test the page for this type of problem manually",
- "fingerprint": "b0b4dd68d2510979"
+ "fingerprint": "7ffc01343cb0cb30"
}
],
"reason": "engine-limitation",
@@ -1347,7 +1347,7 @@
"h1"
],
"failureSummary": "Fix all of the following:\n Axe encountered an error; test the page for this type of problem manually",
- "fingerprint": "4113902b5dad38cd"
+ "fingerprint": "7ffc01343cb0cb30"
}
],
"reason": "engine-limitation",
@@ -1455,7 +1455,7 @@
"p"
],
"failureSummary": "Fix all of the following:\n Axe encountered an error; test the page for this type of problem manually",
- "fingerprint": "3c34e40dd1b3284f"
+ "fingerprint": "14375e3de91b7aa0"
}
],
"reason": "engine-limitation",
@@ -1562,7 +1562,7 @@
"html"
],
"failureSummary": "Fix any of the following:\n The element does not have a lang attribute",
- "fingerprint": "4c2a13ab4c8c0365"
+ "fingerprint": "b6128eff391a4be6"
}
]
},
@@ -1590,7 +1590,7 @@
"a"
],
"failureSummary": "Fix all of the following:\n Element is in tab order and does not have accessible text\n\nFix any of the following:\n Element does not have text that is visible to screen readers\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute",
- "fingerprint": "e444708c0ffb67e0"
+ "fingerprint": "3943063f55791bff"
}
]
}
@@ -1606,7 +1606,7 @@
"html"
],
"failureSummary": "Fix any of the following:\n No valid skip link found\n Page does not have a heading\n Page does not have a landmark region",
- "fingerprint": "f65f3f04548c6741"
+ "fingerprint": "39d78859512e8246"
}
],
"reason": "needs-review",
@@ -1622,7 +1622,7 @@
"p"
],
"failureSummary": "Fix all of the following:\n Axe encountered an error; test the page for this type of problem manually",
- "fingerprint": "e2edcc8d05a5ddea"
+ "fingerprint": "14375e3de91b7aa0"
}
],
"reason": "engine-limitation",
@@ -1659,7 +1659,7 @@
"a"
],
"failureSummary": null,
- "fingerprint": "861d5c841eb71dae"
+ "fingerprint": "530d99c32a585135"
}
],
"reason": "engine-limitation",
@@ -1741,7 +1741,7 @@
"p"
],
"failureSummary": "Fix all of the following:\n Axe encountered an error; test the page for this type of problem manually",
- "fingerprint": "e300b0a374169cf7"
+ "fingerprint": "14375e3de91b7aa0"
}
],
"reason": "engine-limitation",
diff --git a/examples/report.sarif b/examples/report.sarif
index 00cf47e..189d0ec 100644
--- a/examples/report.sarif
+++ b/examples/report.sarif
@@ -1117,7 +1117,7 @@
}
],
"partialFingerprints": {
- "eaaKit/v1": "4c2a13ab4c8c0365"
+ "eaaKit/v2": "b6128eff391a4be6"
}
},
{
@@ -1138,7 +1138,7 @@
}
],
"partialFingerprints": {
- "eaaKit/v1": "f0e17d2582e9a5b3"
+ "eaaKit/v2": "f0e17d2582e9a5b3"
}
},
{
@@ -1159,7 +1159,7 @@
}
],
"partialFingerprints": {
- "eaaKit/v1": "e444708c0ffb67e0"
+ "eaaKit/v2": "3943063f55791bff"
}
}
],
diff --git a/src/audit/baseline.ts b/src/audit/baseline.ts
index 7e7aa09..f208857 100644
--- a/src/audit/baseline.ts
+++ b/src/audit/baseline.ts
@@ -28,8 +28,18 @@ import { type Finding, type FindingNode, findingElements, type PageAudit } from
* anything. Nothing here is permanent unless somebody keeps deciding it is.
*/
-/** Bumped only when an existing field is removed, renamed, or changes meaning. */
-export const BASELINE_SCHEMA_VERSION = 1
+/**
+ * Bumped only when an existing field is removed, renamed, or changes meaning.
+ *
+ * 2: `fingerprint` changed meaning. It used to hash the failing element's whole
+ * outerHTML, which for the document-level rules is the entire page — so every
+ * entry for `html-has-lang` or `document-title` stopped matching the moment
+ * anybody edited the page it was on, and the build went red on barriers that
+ * had been accepted. Entries written under 1 cannot be matched against 2, and
+ * silently accepting the file would suppress nothing while looking as though it
+ * had, so it is refused with the command that rewrites it.
+ */
+export const BASELINE_SCHEMA_VERSION = 2
/** Default filename, used by the CLI when no path is given. */
export const DEFAULT_BASELINE_FILE = 'eaa-baseline.json'
@@ -261,7 +271,10 @@ export async function readBaseline(file: string, cwd = process.cwd()): Promise {
+ const found = await glob([...DEFAULT_INCLUDE], {
+ cwd: path.resolve(dir),
+ ignore: [...DEFAULT_EXCLUDE],
+ onlyFiles: true,
+ dot: false,
+ })
+ return found.length > 0
+}
+
async function assertDirectory(root: string, original: string): Promise {
let stats: Awaited>
try {
diff --git a/src/audit/coverage.ts b/src/audit/coverage.ts
index dfe58c8..f871d49 100644
--- a/src/audit/coverage.ts
+++ b/src/audit/coverage.ts
@@ -196,6 +196,16 @@ export function buildCoverage(
const byCriterion = rulesByCriterion(tags)
const decided = decidedRules(audits)
const blinded = blindedRules(audits)
+ // ENGINE_BLIND_RULES is a fact about jsdom, not about auditing. Consulting it
+ // for a run that used real Chromium made the browser report contradict
+ // itself: colour contrast and target size came back as "this engine could not
+ // evaluate it", and the summary advised re-running with --browser — the flag
+ // that run was already using. A browser sees layout and CSS, so the only
+ // rules it reached no verdict on are the ones its own results said so about,
+ // which `blinded` already holds.
+ // An empty run keeps the table: there is no browser result to argue it away.
+ const jsdomBlindApplies =
+ audits.length === 0 || audits.some((audit) => audit.engine !== 'browser')
const criteria: CriterionCoverage[] = WCAG22_AA_CRITERIA.map((criterion) => {
const rules = byCriterion.get(criterion.number) ?? []
@@ -212,7 +222,7 @@ export function buildCoverage(
// and matched nothing — different facts with different remedies, so they
// are not folded together.
const engineBlind = rules.filter(
- (rule) => blinded.has(rule) || ENGINE_BLIND_RULES[rule] !== undefined,
+ (rule) => blinded.has(rule) || (jsdomBlindApplies && ENGINE_BLIND_RULES[rule] !== undefined),
)
// One blind rule is enough. Requiring all of them was wrong on the one
diff --git a/src/audit/diff.ts b/src/audit/diff.ts
index 997e7c9..9e8ecfb 100644
--- a/src/audit/diff.ts
+++ b/src/audit/diff.ts
@@ -28,7 +28,7 @@ import { type ImpactLevel, impactRank, isImpactLevel } from './impact.ts'
*/
/** Reports older than this cannot be compared. */
-export const SUPPORTED_REPORT_SCHEMA = 1
+export const SUPPORTED_REPORT_SCHEMA = 2
export class DiffError extends Error {
override readonly name = 'DiffError'
@@ -268,7 +268,8 @@ export async function readReport(file: string, cwd = process.cwd()): Promise` is the element and for a container is the element and every
+ * descendant it has. Hashing all of that made the identity of a container
+ * depend on its contents — and the container that matters here is ``,
+ * which every document-level rule fails against: `html-has-lang`,
+ * `document-title`, `landmark-one-main`, `page-has-heading-one`.
+ *
+ * Their outerHTML is the whole page. So adding one paragraph anywhere changed
+ * the fingerprint of every document-level violation on that page, and all three
+ * consumers believed it:
+ *
+ * - the baseline stopped suppressing barriers it had accepted, and the build
+ * went red on a page whose only change was a typo fix;
+ * - `diff` reported the same untouched barrier as both new and **fixed**, which
+ * is the one thing it exists to refuse to do;
+ * - SARIF churned its partialFingerprints, so code scanning closed an alert and
+ * opened an identical one on every edit.
+ *
+ * The identity of an element is its own tag and attributes. Where two elements
+ * share those, axe-core's selector already tells them apart — it qualifies
+ * ambiguous matches with `:nth-child(…)` — so nothing that was distinguishable
+ * before stops being distinguishable now.
+ *
+ * Anything that is not an element — the empty string a rule with no attached
+ * node carries — is returned unchanged, so those keep the identity they had.
+ */
+export function openingTag(html: string): string {
+ const trimmed = html.trim()
+ if (!trimmed.startsWith('<')) return trimmed
+
+ // Attribute values may contain `>`, so the scan tracks quoting rather than
+ // taking the first one it sees: `` is one tag, not two.
+ let quote: '"' | "'" | undefined
+ for (let i = 1; i < trimmed.length; i += 1) {
+ const char = trimmed[i]
+ if (quote !== undefined) {
+ if (char === quote) quote = undefined
+ continue
+ }
+ if (char === '"' || char === "'") {
+ quote = char
+ continue
+ }
+ if (char === '>') return trimmed.slice(0, i + 1)
+ }
+
+ // No closing `>` at all: markup this malformed has no tag to take, so it is
+ // hashed as it came rather than silently becoming something else.
+ return trimmed
}
diff --git a/src/audit/report/console.ts b/src/audit/report/console.ts
index e8317f0..5b001a1 100644
--- a/src/audit/report/console.ts
+++ b/src/audit/report/console.ts
@@ -313,23 +313,38 @@ function summary(audits: readonly PageAudit[], ctx: Context): string[] {
)
const reviewCount = countRules(audits, 'needs-review')
const pages = count(audits.length, 'page')
+ // A page that errored produced no findings because nothing read it, so it is
+ // not one of the pages a "no violations" sentence can be counted over.
+ const audited = audits.length - errored.length
const lines = [line(ctx, 'Summary', ctx.c.bold), ...completenessLines(ctx)]
- if (ruleCount === 0) {
+ if (ruleCount === 0 && audited === 0 && errored.length > 0) {
+ // Every page this run was given failed. "No violations" here would be a
+ // pass handed back for markup nothing ever opened, so the count is not
+ // printed as a verdict at all and the error line below carries the result.
+ // A run with no pages is a different thing and keeps its own wording: there
+ // was nothing to fail.
+ lines.push(line(ctx, ' Nothing was audited: no page could be read.', ctx.c.red))
+ } else if (ruleCount === 0) {
// Qualified rather than plain when the run did not see the whole site: "no
// violations" over a fraction of the pages is not the sentence it looks
// like, and the completeness lines above have just said which fraction.
- const clean =
- ctx.completeness && !ctx.completeness.complete
- ? ` No violations across the ${pages} that were audited.`
- : ` No violations across ${pages}.`
+ // The count is of pages actually audited, not of pages attempted: saying
+ // "no violations across 2 pages" when one of them errored claims a verdict
+ // on a page nothing looked at.
+ const incomplete = errored.length > 0 || (ctx.completeness && !ctx.completeness.complete)
+ const clean = incomplete
+ ? ` No violations across the ${count(audited, 'page')} that were audited.`
+ : ` No violations across ${pages}.`
lines.push(line(ctx, clean, ctx.c.green))
} else {
lines.push(
render(ctx, [
{
- text: ` ${count(ruleCount, 'violation')} on ${withViolations.length} of ${pages}`,
+ // Out of the pages audited, not the pages attempted: "on 1 of 2
+ // pages" reads as one clean page when the other one errored.
+ text: ` ${count(ruleCount, 'violation')} on ${withViolations.length} of ${count(audited, 'page')}`,
paint: ctx.c.red,
},
{ text: ` (${count(elementCount, 'element')})`, paint: ctx.c.dim },
@@ -441,10 +456,11 @@ function completenessLines(ctx: Context): string[] {
if (completeness.unreachable.length > 0) {
const noun = plural(completeness.unreachable.length, 'page')
+ const verb = completeness.unreachable.length === 1 ? 'was' : 'were'
lines.push(
line(
ctx,
- ` ${completeness.unreachable.length} ${noun} could not be reached, and were not audited`,
+ ` ${completeness.unreachable.length} ${noun} could not be reached, and ${verb} not audited`,
ctx.c.yellow,
),
)
diff --git a/src/audit/report/json.ts b/src/audit/report/json.ts
index 8884f63..3f81ee5 100644
--- a/src/audit/report/json.ts
+++ b/src/audit/report/json.ts
@@ -11,8 +11,15 @@ import type { Finding, IncompleteFinding, PageAudit } from '../runners/jsdom.ts'
* Bumped only when an existing field is removed, renamed, or changes meaning.
* Adding a new field does not bump it, so consumers must ignore fields they do
* not know. See the JSON report contract in the README.
+ *
+ * 2: `fingerprint` changed meaning. It used to hash the failing element's whole
+ * outerHTML, which for `` is the entire page, so every document-level
+ * violation changed identity whenever anybody edited the page it was on.
+ * `diff` matches on this field, and comparing a report written under 1 against
+ * one written under 2 would report every such barrier as both new and fixed —
+ * the reading the version check exists to refuse.
*/
-export const SCHEMA_VERSION = 1
+export const SCHEMA_VERSION = 2
export type ReportEngine = 'jsdom' | 'browser'
diff --git a/src/audit/report/sarif.ts b/src/audit/report/sarif.ts
index b8f2fb8..73ae7dc 100644
--- a/src/audit/report/sarif.ts
+++ b/src/audit/report/sarif.ts
@@ -210,7 +210,11 @@ function toResults(
* combines the fingerprint with the location itself.
*/
function fingerprint(ruleId: string, selector: string, html: string): Record {
- return { 'eaaKit/v1': elementFingerprint(ruleId, selector, html) }
+ // v2: the fingerprint stopped hashing the element's descendants, so a
+ // document-level alert survives an edit elsewhere on the page instead of
+ // being closed and reopened. The version in the key is what tells code
+ // scanning these are a different scheme rather than moved defects.
+ return { 'eaaKit/v2': elementFingerprint(ruleId, selector, html) }
}
/** Every rule the run knows about, so the catalogue is complete in GitHub. */
diff --git a/src/cli/audit.ts b/src/cli/audit.ts
index 1f7401d..0a9d358 100644
--- a/src/cli/audit.ts
+++ b/src/cli/audit.ts
@@ -78,7 +78,7 @@ export async function runAuditCommand(
): Promise {
const resolved = await resolvePages(dir, options)
if (!resolved) return { audits: [], exitCode: 2 }
- const { pages, origin, label, cleanup, completeness: collection } = resolved
+ const { pages, origin, label, cleanup, directory, completeness: collection } = resolved
// try/finally rather than a call before each return: auto-detection may have
// started the project's server, and leaving it running would hold the process
// open after the report is written.
@@ -97,9 +97,16 @@ export async function runAuditCommand(
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(options.browser ? { browser: true } : {}),
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
- // No directory when the pages were crawled: they are audited at the URL
+ // The directory the pages were actually read from, not the one the
+ // caller typed: under auto-detection nobody typed one, and passing
+ // undefined told the browser runner these pages had been crawled. It
+ // then skipped the loopback server and navigated Chromium to a bare
+ // filesystem path, which is not a URL — so `eaa-kit audit --browser`
+ // with no directory argument failed every page it was given.
+ //
+ // Still undefined for a real crawl: those pages are audited at the URL
// they came from, not served back out of a copy on disk.
- ...(options.url === undefined && dir !== undefined ? { directory: dir } : {}),
+ ...(directory === undefined ? {} : { directory }),
})
if (!audits) return { audits: [], exitCode: 2 }
diff --git a/src/cli/baseline.ts b/src/cli/baseline.ts
index a879641..dcb6288 100644
--- a/src/cli/baseline.ts
+++ b/src/cli/baseline.ts
@@ -51,7 +51,7 @@ export async function runBaselineCommand(
const resolved = await resolvePages(path.resolve(cwd, dir), { ...options, label: dir })
if (!resolved) return { entries: 0, exitCode: 2 }
- const { pages, origin, label } = resolved
+ const { pages, origin, label, directory } = resolved
note(`Auditing ${count(pages.length, 'page')} in ${label}…`)
@@ -62,7 +62,9 @@ export async function runBaselineCommand(
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(options.browser ? { browser: true } : {}),
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
- ...(options.url === undefined ? { directory: path.resolve(cwd, dir) } : {}),
+ // Where the pages were read from, as the collection stage resolved it.
+ // Undefined for a crawl, whose pages are audited at their own URL.
+ ...(directory === undefined ? {} : { directory }),
})
if (!audits) return { entries: 0, exitCode: 2 }
diff --git a/src/cli/pages.ts b/src/cli/pages.ts
index d29c831..92dc233 100644
--- a/src/cli/pages.ts
+++ b/src/cli/pages.ts
@@ -4,6 +4,7 @@ import {
type CollectedPage,
collectPages,
emptyDirectoryHint,
+ holdsHtml,
} from '../audit/collect.ts'
import type { Collection, Unmeasured } from '../audit/completeness.ts'
import { count } from '../text.ts'
@@ -52,6 +53,18 @@ export interface ResolvePagesOptions extends CrawlCommandOptions {
export interface ResolvedPages {
pages: CollectedPage[]
+ /**
+ * Directory the pages were read off disk from, absolute or as the caller
+ * gave it. Undefined for a crawl, whose pages have a server of their own.
+ *
+ * Carried rather than left to the caller's own argument, because under
+ * auto-detection there is no argument: the caller passed undefined and this
+ * stage worked the directory out. A caller that reached for its own `dir`
+ * got undefined there and treated a build on disk as though it had been
+ * crawled — which is exactly what the browser runner uses to decide whether
+ * to serve the pages over loopback or navigate to them directly.
+ */
+ directory?: string
/** Stops anything auto-detection started, once the report is written. */
cleanup?: () => Promise
/**
@@ -129,6 +142,21 @@ export async function resolvePages(
}
if (pages.length === 0) {
+ // A build directory full of HTML that the globs excluded is not the same
+ // mistake as one with no HTML in it, and the framework advice for the
+ // second is actively wrong for the first: it names another directory to
+ // audit when the directory was never the problem. So the filters are
+ // checked before that advice is offered.
+ const filtered =
+ (options.include !== undefined || options.exclude !== undefined) &&
+ (await holdsHtml(directory as string))
+ if (filtered) {
+ warn(`No page in ${shown} matched the filters, so nothing was audited.`)
+ if (options.include !== undefined) note(` --include ${options.include.join(' ')}`)
+ if (options.exclude !== undefined) note(` --exclude ${options.exclude.join(' ')}`)
+ note(' Patterns are relative to the audited directory, with POSIX separators.')
+ return undefined
+ }
warn(await emptyDirectoryHint(shown, cwd))
return undefined
}
@@ -144,6 +172,7 @@ export async function resolvePages(
return {
pages,
+ directory: directory as string,
label: shown,
completeness: {
discovery: 'directory',
diff --git a/src/statement/findings.ts b/src/statement/findings.ts
index fc1c2c3..0f159ec 100644
--- a/src/statement/findings.ts
+++ b/src/statement/findings.ts
@@ -10,7 +10,7 @@ import { StatementError } from './error.ts'
* axe-core and the statement path deliberately does not. A test asserts the two
* stay in step.
*/
-export const SUPPORTED_REPORT_SCHEMA = 1
+export const SUPPORTED_REPORT_SCHEMA = 2
/**
* Only the fields the statement reads. Everything else in the report — node
diff --git a/tests/astro/integration.test.ts b/tests/astro/integration.test.ts
index e430afc..ceda2fb 100644
--- a/tests/astro/integration.test.ts
+++ b/tests/astro/integration.test.ts
@@ -9,6 +9,7 @@ import eaaKit, {
type AstroIntegrationLike,
type AstroLoggerLike,
} from '../../src/astro/index.ts'
+import { SCHEMA_VERSION } from '../../src/audit/report/json.ts'
/**
* The structural types in src/astro are a stand-in for Astro's own, so that the
@@ -192,7 +193,7 @@ describe('auditing the build', () => {
await run(eaaKit({ format: 'json', output }), dir, recorder().logger).catch(() => undefined)
const doc = JSON.parse(await readFile(output, 'utf8'))
- expect(doc.schemaVersion).toBe(1)
+ expect(doc.schemaVersion).toBe(SCHEMA_VERSION)
expect(doc.summary.violations).toBeGreaterThan(0)
})
diff --git a/tests/audit/baseline.test.ts b/tests/audit/baseline.test.ts
index f4b5f03..04448b0 100644
--- a/tests/audit/baseline.test.ts
+++ b/tests/audit/baseline.test.ts
@@ -12,7 +12,6 @@ import {
serialiseBaseline,
writeBaseline,
} from '../../src/audit/baseline.ts'
-import { elementFingerprint } from '../../src/audit/fingerprint.ts'
import type { Finding, PageAudit } from '../../src/audit/result.ts'
const TODAY = new Date('2026-08-27T10:00:00.000Z')
@@ -265,7 +264,7 @@ describe('applyBaseline', () => {
})
it('accepts nothing from an empty baseline', () => {
- const empty: Baseline = { schemaVersion: 1, createdOn: '', entries: [] }
+ const empty: Baseline = { schemaVersion: BASELINE_SCHEMA_VERSION, createdOn: '', entries: [] }
const outcome = applyBaseline(audits, empty, { today: TODAY })
@@ -301,7 +300,9 @@ describe('the file', () => {
it('declares its schema version', () => {
expect(baselineFor([]).schemaVersion).toBe(BASELINE_SCHEMA_VERSION)
- expect(serialiseBaseline(baselineFor([]))).toContain('"schemaVersion": 1')
+ expect(serialiseBaseline(baselineFor([]))).toContain(
+ `"schemaVersion": ${BASELINE_SCHEMA_VERSION}`,
+ )
})
it('says how to make one when it is not there', async () => {
@@ -338,25 +339,3 @@ describe('the file', () => {
await expect(readBaseline('b.json', dir)).rejects.toThrow(/schemaVersion 99/)
})
})
-
-describe('elementFingerprint', () => {
- it('is stable for the same element', () => {
- expect(elementFingerprint('image-alt', 'img', '
')).toBe(
- elementFingerprint('image-alt', 'img', '
'),
- )
- })
-
- it('changes when the rule, the selector or the markup changes', () => {
- const base = elementFingerprint('image-alt', 'img', '
')
-
- expect(elementFingerprint('link-name', 'img', '
')).not.toBe(base)
- expect(elementFingerprint('image-alt', 'img.x', '
')).not.toBe(base)
- expect(elementFingerprint('image-alt', 'img', '
')).not.toBe(base)
- })
-
- it('does not depend on the page, so a moved page keeps its baseline', () => {
- // The same promise SARIF relies on: moving a file must not close one alert
- // and open an identical one.
- expect(elementFingerprint('image-alt', 'img', '
')).toHaveLength(16)
- })
-})
diff --git a/tests/audit/coverage.test.ts b/tests/audit/coverage.test.ts
index 7a7f06b..282c0af 100644
--- a/tests/audit/coverage.test.ts
+++ b/tests/audit/coverage.test.ts
@@ -133,6 +133,73 @@ describe('buildCoverage', () => {
expect(contradicted.map((criterion) => criterion.number)).toEqual([])
})
+ it("does not hold jsdom's blind spots against a run that used a browser", () => {
+ // ENGINE_BLIND_RULES describes jsdom, which has no layout. Applying it to a
+ // browser run made the report contradict itself and then give advice it had
+ // already taken: colour contrast came back as "this engine could not
+ // evaluate it" from a run in real Chromium, under a summary telling the
+ // reader to re-run with --browser.
+ // What a browser run looks like: it can see layout, so it force-reports
+ // nothing as unevaluable and its incomplete list carries no
+ // engine-limitation at all.
+ const browserRun = audits.map((audit) => ({
+ ...audit,
+ engine: 'browser' as const,
+ incomplete: audit.incomplete.filter((finding) => finding.reason !== 'engine-limitation'),
+ }))
+
+ const browserCoverage = buildCoverage(browserRun)
+ const contrast = browserCoverage.criteria.find((criterion) => criterion.number === '1.4.3')
+
+ expect(contrast?.status).not.toBe('not-evaluated')
+ expect(contrast?.browserWouldAnswer).toBe(false)
+ expect(browserCoverage.browserWouldAnswer).toBe(0)
+ })
+
+ it('still holds them against a run that used jsdom', () => {
+ // The other direction: the advice is worth giving to the engine it applies
+ // to, and switching it off everywhere would have been the easier fix and
+ // the wrong one.
+ expect(coverage.browserWouldAnswer).toBeGreaterThan(0)
+ })
+
+ it('believes a browser run that says a rule reached no verdict', () => {
+ // A browser can still fail to decide a rule — axe-core reports it as
+ // incomplete — and that is a fact about the run rather than about jsdom, so
+ // it survives.
+ const [first, ...rest] = audits
+ if (first === undefined) throw new Error('fixture produced no audits')
+ const browserRun = [
+ {
+ ...first,
+ engine: 'browser' as const,
+ passes: first.passes.filter((outcome) => outcome.ruleId !== 'color-contrast'),
+ violations: first.violations.filter((finding) => finding.ruleId !== 'color-contrast'),
+ incomplete: [
+ {
+ ruleId: 'color-contrast',
+ help: 'Elements must meet minimum colour contrast ratio thresholds',
+ helpUrl: '',
+ impact: 'serious' as const,
+ successCriteria: ['1.4.3'],
+ enClauses: [],
+ tags: ['wcag2aa', 'wcag143'],
+ reason: 'engine-limitation' as const,
+ reasonDetail: 'could not resolve a background colour',
+ nodes: [],
+ },
+ ],
+ },
+ ...rest.map((audit) => ({ ...audit, engine: 'browser' as const })),
+ ]
+
+ const contrast = buildCoverage(browserRun).criteria.find(
+ (criterion) => criterion.number === '1.4.3',
+ )
+
+ expect(contrast?.status).toBe('not-evaluated')
+ })
+
it('never claims a browser would answer a criterion no rule covers', () => {
for (const criterion of coverage.criteria) {
if (criterion.status === 'no-automated-rule') {
diff --git a/tests/audit/diff.test.ts b/tests/audit/diff.test.ts
index ac95375..b31015b 100644
--- a/tests/audit/diff.test.ts
+++ b/tests/audit/diff.test.ts
@@ -25,7 +25,7 @@ interface PageSpec {
/** A JSON report with only the fields a diff reads. */
function report(pages: PageSpec[], overrides: Record = {}): unknown {
return {
- schemaVersion: 1,
+ schemaVersion: SCHEMA_VERSION,
generatedAt: '2026-08-20T18:00:00.000Z',
target: { source: './dist' },
completeness: { complete: true, audited: pages.filter((p) => !p.error).length },
@@ -149,7 +149,7 @@ describe('diffReports', () => {
// A rule can fail with nothing attached. Getting this wrong would make such
// failures invisible to a diff while still visible to the baseline.
const withNoNodes = {
- schemaVersion: 1,
+ schemaVersion: SCHEMA_VERSION,
generatedAt: '2026-08-20T18:00:00.000Z',
pages: [{ path: 'index.html', violations: [{ ruleId: 'html-has-lang', nodes: [] }] }],
}
@@ -183,9 +183,9 @@ describe('diffReports', () => {
describe('readReport', () => {
it('refuses a schema version it was not written against', async () => {
- await expect(parsed(report([{ path: 'index.html' }], { schemaVersion: 2 }))).rejects.toThrow(
- DiffError,
- )
+ await expect(
+ parsed(report([{ path: 'index.html' }], { schemaVersion: SCHEMA_VERSION + 1 })),
+ ).rejects.toThrow(DiffError)
})
it('refuses a file that is not a report', async () => {
diff --git a/tests/audit/fingerprint.test.ts b/tests/audit/fingerprint.test.ts
new file mode 100644
index 0000000..82092ca
--- /dev/null
+++ b/tests/audit/fingerprint.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest'
+import { elementFingerprint, openingTag } from '../../src/audit/fingerprint.ts'
+
+/**
+ * The identity of a violating element, and the one property everything built on
+ * it needs: it must not move when the page around the element does.
+ *
+ * The baseline, `diff` and SARIF all key off this. When it was the element's
+ * whole outerHTML, the document-level rules — whose element is `` and
+ * whose outerHTML is therefore the entire page — changed identity on every
+ * edit, and all three consumers reported nonsense as a result.
+ */
+
+describe('openingTag', () => {
+ it('takes the element and leaves its descendants out', () => {
+ expect(openingTag('
')).toBe('')
+ })
+
+ it('keeps a leaf element whole, because it has no descendants to drop', () => {
+ expect(openingTag('
')).toBe('
')
+ })
+
+ it('does not stop at a > inside an attribute value', () => {
+ expect(openingTag(`go`)).toBe(``)
+ expect(openingTag(`go`)).toBe(``)
+ })
+
+ it('leaves markup with no closing bracket as it found it', () => {
+ // Nothing to take, and inventing a tag here would give two different
+ // fragments the same identity.
+ expect(openingTag('
{
+ // The empty string is the identity a rule that failed with no attached node
+ // carries, and the baseline writes an entry against exactly that.
+ expect(openingTag('')).toBe('')
+ expect(openingTag(' ')).toBe('')
+ expect(openingTag('bare text')).toBe('bare text')
+ })
+})
+
+describe('elementFingerprint', () => {
+ it('is stable for the same element', () => {
+ expect(elementFingerprint('image-alt', 'img', '
')).toBe(
+ elementFingerprint('image-alt', 'img', '
'),
+ )
+ })
+
+ it('changes when the rule, the selector or the element itself changes', () => {
+ const base = elementFingerprint('image-alt', 'img', '
')
+
+ expect(elementFingerprint('link-name', 'img', '
')).not.toBe(base)
+ expect(elementFingerprint('image-alt', 'img.x', '
')).not.toBe(base)
+ expect(elementFingerprint('image-alt', 'img', '
')).not.toBe(base)
+ })
+
+ it('does not move when the page inside a document-level element changes', () => {
+ // The regression this exists for. `html-has-lang` fails against ``,
+ // so its outerHTML is the whole document: hashing that made adding one
+ // paragraph anywhere look like a different barrier, which the baseline
+ // stopped suppressing and `diff` reported as both new and fixed.
+ const before = elementFingerprint(
+ 'html-has-lang',
+ 'html',
+ 'Kontakt
',
+ )
+ const after = elementFingerprint(
+ 'html-has-lang',
+ 'html',
+ 'Kontakt
New.
',
+ )
+
+ expect(after).toBe(before)
+ })
+
+ it('still moves when the document element itself is fixed', () => {
+ // The other half: a barrier that was actually addressed has to change
+ // identity, or `diff` could never report anything as fixed.
+ const broken = elementFingerprint('html-has-lang', 'html', '')
+ const fixed = elementFingerprint(
+ 'html-has-lang',
+ 'html',
+ '',
+ )
+
+ expect(fixed).not.toBe(broken)
+ })
+
+ it('tells two containers apart by the selector axe-core gives them', () => {
+ // Dropping the descendants costs nothing here: axe-core qualifies an
+ // ambiguous match with :nth-child(…), so the two links stay distinct.
+ const first = elementFingerprint('link-name', 'a:nth-child(1)', '
')
+ const second = elementFingerprint('link-name', 'a:nth-child(2)', '
')
+
+ expect(first).not.toBe(second)
+ })
+})
diff --git a/tests/audit/report/console.test.ts b/tests/audit/report/console.test.ts
index 94bd639..1004332 100644
--- a/tests/audit/report/console.test.ts
+++ b/tests/audit/report/console.test.ts
@@ -300,6 +300,44 @@ describe('the completeness lines', () => {
expect(report).toContain('2 pages could not be reached')
})
+ it('counts one unreachable page in the singular', () => {
+ const report = summaryOf({ unreachable: [{ location: '/a', reason: '404' }] })
+
+ expect(report).toContain('1 page could not be reached, and was not audited')
+ })
+
+ it('does not count an errored page among the pages it found nothing on', () => {
+ // "No violations across 2 pages" over a run where one page could not be
+ // read hands back a verdict on markup nothing opened. The count is of what
+ // was audited; the error line beneath it says what was not.
+ const pages = [clean(), { ...clean(), relativePath: 'broken.html', error: 'boom' }]
+
+ const report = formatConsoleReport(pages, {
+ color: false,
+ width: 80,
+ completeness: runCompleteness(pages, completeCollection('directory', 2)),
+ })
+
+ expect(report).toContain('No violations across the 1 page that were audited.')
+ expect(report).not.toContain('No violations across 2 pages')
+ })
+
+ it('refuses to call a run clean when every page failed', () => {
+ // The shape `audit --browser` produced when it navigated to a filesystem
+ // path instead of a URL: every page errored, nothing was read, and the
+ // summary opened with "No violations" in green.
+ const pages = [
+ { ...clean(), relativePath: 'a.html', error: 'boom' },
+ { ...clean(), relativePath: 'b.html', error: 'boom' },
+ ]
+
+ const report = formatConsoleReport(pages, { color: false, width: 80 })
+
+ expect(report).toContain('Nothing was audited: no page could be read.')
+ expect(report).not.toContain('No violations')
+ expect(report).toContain('2 pages could not be audited')
+ })
+
it('leaves the errored count to the summary rather than double-reporting it', () => {
const pages = [clean(), { ...clean(), relativePath: 'broken.html', error: 'boom' }]
const report = formatConsoleReport(pages, {
diff --git a/tests/audit/report/json.test.ts b/tests/audit/report/json.test.ts
index 164b2da..12c1976 100644
--- a/tests/audit/report/json.test.ts
+++ b/tests/audit/report/json.test.ts
@@ -48,7 +48,6 @@ beforeAll(async () => {
describe('document envelope', () => {
it('declares a schema version', () => {
expect(report.schemaVersion).toBe(SCHEMA_VERSION)
- expect(report.schemaVersion).toBe(1)
})
it('names the tool, its version and the axe-core it wrapped', () => {
@@ -260,7 +259,7 @@ describe('stability of the contract', () => {
const serialised = serialiseJsonReport(report)
expect(serialised.endsWith('}\n')).toBe(true)
- expect(serialised).toContain('\n "schemaVersion": 1')
+ expect(serialised).toContain(`\n "schemaVersion": ${SCHEMA_VERSION}`)
expect(JSON.parse(serialised)).toEqual(report)
})
@@ -305,7 +304,7 @@ describe('the target block', () => {
expect(report.target).toMatchObject({ source: 'http://localhost:3000', kind: 'url' })
})
- it('keeps schemaVersion 1, because no v1 report could have looked different', () => {
+ it('does not move the version for a nullable directory, an added-field change', () => {
// Adding fields does not move the version. `directory` becoming nullable
// only shows up for crawls, which did not exist under v1 at all, so every
// report shape a v1 consumer could already receive is unchanged.
@@ -317,7 +316,7 @@ describe('the target block', () => {
now: NOW,
})
- expect(report.schemaVersion).toBe(1)
+ expect(report.schemaVersion).toBe(SCHEMA_VERSION)
})
})
@@ -361,6 +360,5 @@ describe('the completeness block', () => {
it('does not move the schema version, being an added field', () => {
expect(report.schemaVersion).toBe(SCHEMA_VERSION)
- expect(SCHEMA_VERSION).toBe(1)
})
})
diff --git a/tests/audit/report/sarif.test.ts b/tests/audit/report/sarif.test.ts
index e97f94c..e70f9b7 100644
--- a/tests/audit/report/sarif.test.ts
+++ b/tests/audit/report/sarif.test.ts
@@ -199,7 +199,7 @@ describe('results', () => {
it('fingerprints results so alerts survive a file move', () => {
for (const result of log.runs[0]?.results ?? []) {
- expect(result.partialFingerprints['eaaKit/v1']).toMatch(/^[0-9a-f]{16}$/)
+ expect(result.partialFingerprints['eaaKit/v2']).toMatch(/^[0-9a-f]{16}$/)
}
})
diff --git a/tests/audit/runners/playwright.test.ts b/tests/audit/runners/playwright.test.ts
index b4fdec1..e95773c 100644
--- a/tests/audit/runners/playwright.test.ts
+++ b/tests/audit/runners/playwright.test.ts
@@ -142,3 +142,44 @@ describe('runBrowserAudit', () => {
expect(audit?.violations).toEqual([])
}, 120_000)
})
+
+describe('audit --browser, end to end', () => {
+ /**
+ * The headline invocation: no directory argument, so auto-detection finds the
+ * build. It used to hand the browser runner no directory at all, which is the
+ * runner's signal that these pages came off a running site and already have
+ * somewhere to be fetched from — so it skipped the loopback server and
+ * navigated Chromium to `/tmp/…/index.html`, a filesystem path and not a URL.
+ * Every page errored, and the summary opened with "No violations".
+ */
+ it('audits the build it found rather than failing every page', async () => {
+ const { runAuditCommand } = await import('../../../src/cli/audit.ts')
+ const project = await mkdtemp(path.join(tmpdir(), 'eaa-kit-browser-cli-'))
+ await writeFile(path.join(project, 'package.json'), '{"name":"site","private":true}', 'utf8')
+ await mkdir(path.join(project, 'dist'), { recursive: true })
+ await mkdir(path.join(project, 'dist', 'assets'), { recursive: true })
+ await writeFile(path.join(project, 'dist', 'assets', 'site.css'), STYLES, 'utf8')
+ await writeFile(path.join(project, 'dist', 'index.html'), PAGE, 'utf8')
+
+ const writes = { stdout: process.stdout.write, stderr: process.stderr.write }
+ process.stdout.write = (() => true) as typeof process.stdout.write
+ process.stderr.write = (() => true) as typeof process.stderr.write
+ try {
+ const { audits } = await runAuditCommand(undefined, {
+ cwd: project,
+ browser: true,
+ noBuild: true,
+ })
+
+ expect(audits).toHaveLength(1)
+ expect(audits[0]?.error).toBeUndefined()
+ // Served over loopback, so the stylesheet loaded and the rule that needs
+ // it reached a verdict — the whole point of passing the directory along.
+ expect(audits[0]?.violations.map((finding) => finding.ruleId)).toContain('color-contrast')
+ } finally {
+ process.stdout.write = writes.stdout
+ process.stderr.write = writes.stderr
+ await rm(project, { recursive: true, force: true })
+ }
+ }, 180_000)
+})
diff --git a/tests/cli/audit.test.ts b/tests/cli/audit.test.ts
index 4dc3031..e9253c1 100644
--- a/tests/cli/audit.test.ts
+++ b/tests/cli/audit.test.ts
@@ -6,6 +6,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IMPACT_LEVELS, type ImpactLevel } from '../../src/audit/impact.ts'
+import { SCHEMA_VERSION } from '../../src/audit/report/json.ts'
import { runAuditCommand } from '../../src/cli/audit.ts'
const SITE = fileURLToPath(new URL('../fixtures/site', import.meta.url))
@@ -99,7 +100,7 @@ describe('--format and --output', () => {
await runAuditCommand(IMPACTS, { include: ['critical.html'], format: 'json' })
const document = JSON.parse(stdout.join(''))
- expect(document.schemaVersion).toBe(1)
+ expect(document.schemaVersion).toBe(SCHEMA_VERSION)
expect(document.pages[0].path).toBe('critical.html')
expect(stdout.join('')).not.toContain('eaa-kit audit')
}, 60_000)
@@ -115,7 +116,7 @@ describe('--format and --output', () => {
})
const written = JSON.parse(await readFile(target, 'utf8'))
- expect(written.schemaVersion).toBe(1)
+ expect(written.schemaVersion).toBe(SCHEMA_VERSION)
expect(written.summary.violations).toBe(1)
expect(stdout.join('')).toBe('')
expect(stderr.join('')).toContain('Report written to')
@@ -229,6 +230,19 @@ describe('runAuditCommand', () => {
expect(stderr.join('')).toContain('could not be audited')
}, 60_000)
+ it('says the filters excluded everything, not that the build is empty', async () => {
+ // The directory is full of HTML; --include is what left nothing. Saying it
+ // "holds no HTML files" is false, and the advice that follows that
+ // sentence names another directory to audit — sending somebody to fix a
+ // path that was never wrong.
+ const { exitCode } = await runAuditCommand(SITE, { include: ['nope/**'] })
+
+ expect(exitCode).toBe(2)
+ expect(stderr.join('')).toContain('matched the filters')
+ expect(stderr.join('')).toContain('--include nope/**')
+ expect(stderr.join('')).not.toContain('holds no HTML')
+ })
+
it('exits 2 when the directory holds no HTML', async () => {
const empty = await mkdtemp(path.join(tmpdir(), 'eaa-kit-cli-'))
await writeFile(path.join(empty, 'app.js'), 'export {}', 'utf8')
diff --git a/tests/cli/baseline.test.ts b/tests/cli/baseline.test.ts
index d319cf2..434d51f 100644
--- a/tests/cli/baseline.test.ts
+++ b/tests/cli/baseline.test.ts
@@ -3,7 +3,8 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { readBaseline } from '../../src/audit/baseline.ts'
+import { BASELINE_SCHEMA_VERSION, readBaseline } from '../../src/audit/baseline.ts'
+import { SCHEMA_VERSION } from '../../src/audit/report/json.ts'
import { runAuditCommand } from '../../src/cli/audit.ts'
import { runBaselineCommand } from '../../src/cli/baseline.ts'
@@ -56,7 +57,9 @@ describe('runBaselineCommand', () => {
expect(exitCode).toBe(0)
expect(entries).toBe(3)
- expect(await readBaseline('eaa-baseline.json', dir)).toMatchObject({ schemaVersion: 1 })
+ expect(await readBaseline('eaa-baseline.json', dir)).toMatchObject({
+ schemaVersion: BASELINE_SCHEMA_VERSION,
+ })
})
it('says the file is a list of barriers, not a way to switch the tool off', async () => {
@@ -106,13 +109,27 @@ describe('runBaselineCommand', () => {
expect(stderr.join('')).toContain('Build directory not found')
})
- it('exits 2 when there is no HTML to audit', async () => {
+ it('exits 2 when the filters leave nothing to audit, and says so', async () => {
+ // The build is full of HTML; --include is what left none of it. Reporting
+ // that the directory "holds no HTML" would be false, and the framework
+ // advice that follows it names a different directory to try — a fix for a
+ // path that was never wrong.
const dir = await site()
const { exitCode } = await runBaselineCommand('dist', { cwd: dir, include: ['**/*.xhtml'] })
expect(exitCode).toBe(2)
- expect(stderr.join('')).toContain('holds no HTML')
+ expect(stderr.join('')).toContain('matched the filters')
+ expect(stderr.join('')).not.toContain('holds no HTML')
+ })
+
+ it('exits 2 when the directory really does hold no HTML', async () => {
+ const dir = await site()
+ await writeFile(path.join(dir, 'empty.txt'), '', 'utf8')
+
+ const { exitCode } = await runBaselineCommand('.', { cwd: dir, include: ['nothing/**'] })
+
+ expect(exitCode).toBe(2)
})
})
@@ -227,7 +244,7 @@ describe('audit --baseline', () => {
})
expect(JSON.parse(await readFile(path.join(dir, 'reports/a11y.json'), 'utf8'))).toMatchObject({
- schemaVersion: 1,
+ schemaVersion: SCHEMA_VERSION,
})
})
diff --git a/tests/cli/diff.test.ts b/tests/cli/diff.test.ts
index 55cdbd7..1bb6612 100644
--- a/tests/cli/diff.test.ts
+++ b/tests/cli/diff.test.ts
@@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SCHEMA_VERSION } from '../../src/audit/report/json.ts'
import { runDiffCommand } from '../../src/cli/diff.ts'
const dirs: string[] = []
@@ -42,7 +43,7 @@ async function reports(
dirs.push(dir)
const document = (violations: Violation[], pages: string[]): unknown => ({
- schemaVersion: 1,
+ schemaVersion: SCHEMA_VERSION,
generatedAt: '2026-08-20T18:00:00.000Z',
target: { source: './dist' },
completeness: { complete: true, audited: pages.length },
diff --git a/tests/cli/pages.test.ts b/tests/cli/pages.test.ts
new file mode 100644
index 0000000..0f4ada6
--- /dev/null
+++ b/tests/cli/pages.test.ts
@@ -0,0 +1,80 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { resolvePages } from '../../src/cli/pages.ts'
+
+/**
+ * Where the pages come from, and what the caller is told about it.
+ *
+ * The directory this stage settles on is not always the one the caller passed:
+ * `eaa-kit audit` with no argument passes nothing and lets auto-detection work
+ * it out. A caller that reached for its own argument instead got undefined, and
+ * the browser runner reads exactly that to decide whether these pages live on
+ * disk and need serving over loopback or came off a site of their own.
+ */
+
+let project: string
+
+beforeEach(async () => {
+ project = await mkdtemp(path.join(tmpdir(), 'eaa-kit-pages-'))
+ await writeFile(path.join(project, 'package.json'), '{"name":"site","private":true}', 'utf8')
+ await mkdir(path.join(project, 'dist'), { recursive: true })
+ await writeFile(
+ path.join(project, 'dist', 'index.html'),
+ 'HomeHome
',
+ 'utf8',
+ )
+ // Progress goes to stderr and is not what these cases are about.
+ vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+})
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await rm(project, { recursive: true, force: true })
+})
+
+describe('resolvePages', () => {
+ it('reports the directory it worked out when nobody named one', async () => {
+ // The regression: `audit --browser` with no directory argument navigated
+ // Chromium to a bare filesystem path — which is not a URL — because this
+ // was the only thing that knew where the build was, and it did not say.
+ // Every page came back errored, under a summary reading "No violations".
+ const resolved = await resolvePages(undefined, { cwd: project, noBuild: true })
+
+ expect(resolved?.pages).toHaveLength(1)
+ expect(resolved?.directory).toBe(path.join(project, 'dist'))
+ })
+
+ it('reports the directory it was given', async () => {
+ const dir = path.join(project, 'dist')
+
+ const resolved = await resolvePages(dir, { cwd: project })
+
+ expect(resolved?.directory).toBe(dir)
+ })
+
+ it('reports no directory for a crawl, whose pages are not read off disk', async () => {
+ // Serving a crawled page back out of a copy on disk would audit the markup
+ // with the server that produced it cut out of the picture, so the browser
+ // runner has to be able to tell the two apart.
+ const { createServer } = await import('node:http')
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { 'content-type': 'text/html' })
+ response.end(
+ 'Aa',
+ )
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const { port } = server.address() as { port: number }
+
+ try {
+ const resolved = await resolvePages(undefined, { url: `http://127.0.0.1:${port}/` })
+
+ expect(resolved?.pages.length).toBeGreaterThan(0)
+ expect(resolved?.directory).toBeUndefined()
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+ })
+})
diff --git a/tests/cli/statement.test.ts b/tests/cli/statement.test.ts
index 39bffc1..92a0074 100644
--- a/tests/cli/statement.test.ts
+++ b/tests/cli/statement.test.ts
@@ -4,6 +4,7 @@ import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runStatementCommand } from '../../src/cli/statement.ts'
import type { Country } from '../../src/config/define.ts'
+import { SUPPORTED_REPORT_SCHEMA } from '../../src/statement/findings.ts'
/**
* Stands in for a country added to COUNTRIES before its template is written.
@@ -179,7 +180,7 @@ describe('runStatementCommand --audit', () => {
await writeFile(
path.join(dir, 'clean.json'),
JSON.stringify({
- schemaVersion: 1,
+ schemaVersion: SUPPORTED_REPORT_SCHEMA,
generatedAt: '2026-08-21T09:30:00.000Z',
summary: { pages: 1, needsReview: 0, notEvaluated: 0 },
rules: {},
diff --git a/tests/fixtures/statement/audit.json b/tests/fixtures/statement/audit.json
index 0aea18c..b327dea 100644
--- a/tests/fixtures/statement/audit.json
+++ b/tests/fixtures/statement/audit.json
@@ -1,5 +1,5 @@
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
"tool": {
"name": "eaa-kit",
"version": "0.1.0",
diff --git a/tests/statement/findings.test.ts b/tests/statement/findings.test.ts
index 9976741..0be8cd5 100644
--- a/tests/statement/findings.test.ts
+++ b/tests/statement/findings.test.ts
@@ -18,7 +18,7 @@ async function fixture(): Promise> {
/** The smallest document the reader accepts, to build invalid ones from. */
function report(overrides: Record = {}): Record {
return {
- schemaVersion: 1,
+ schemaVersion: SUPPORTED_REPORT_SCHEMA,
generatedAt: '2026-08-21T09:30:00.000Z',
summary: { pages: 1, needsReview: 0, notEvaluated: 0 },
rules: {
@@ -192,8 +192,12 @@ describe('summariseAuditReport', () => {
})
it('rejects a schema version it does not know how to read', () => {
- expect(() => summariseAuditReport(report({ schemaVersion: 2 }))).toThrow(
- /schemaVersion 2; this version of eaa-kit reads 1/,
+ expect(() =>
+ summariseAuditReport(report({ schemaVersion: SUPPORTED_REPORT_SCHEMA + 1 })),
+ ).toThrow(
+ new RegExp(
+ `schemaVersion ${SUPPORTED_REPORT_SCHEMA + 1}; this version of eaa-kit reads ${SUPPORTED_REPORT_SCHEMA}`,
+ ),
)
})