Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.94"
components: rustfmt, clippy

- uses: actions/cache@v4
with:
Expand Down Expand Up @@ -195,16 +198,26 @@ jobs:
# directly in CI ensures every PR gets a RustSec advisory scan even without
# the scheduled weekly supply-chain workflow. Mirrors the same RUSTSEC ignore
# list kept in supply-chain.yml — see that file for the full triage rationale.
#
# cargo-audit is installed as a prebuilt binary via taiki-e/install-action
# rather than compiled by rustsec/audit-check. The latter runs
# `cargo install cargo-audit` inside the checkout, which picks up the repo's
# rust-toolchain.toml pin (1.94) and then fails to build the newest
# transitive deps (kstring 2.0.5 needs rustc 1.96). The prebuilt binary is
# independent of the project MSRV and skips the ~60 s compile (issue #845).
audit:
name: cargo audit (RustSec)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Run cargo audit
uses: rustsec/audit-check@v2
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tool: cargo-audit

- name: Run cargo audit
run: cargo audit

# Coverage is measured with cargo-llvm-cov (works on the pinned stable
# toolchain, no nightly required) and reported only — there is deliberately
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/dashboard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@ jobs:

# Every module plus the entry point: `node --check` only parses, so this
# catches a syntax error before the tests try to import the file.
# `scripts/` is included too: a duplicate declaration there aborted the
# accessibility check at parse time before any assertion ran (#846).
- name: JavaScript syntax check
run: |
node --check static/dashboard.js
for f in static/format.js static/session.js static/state.js static/keys.js; do
node --check "$f"
done
for f in scripts/*.mjs; do
node --check "$f"
done

# `node --check` validates syntax only, not import bindings, so a missing
# export in `format.js` (issue #862) slips past it and only surfaces in
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/supply-chain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ jobs:
steps:
- uses: actions/checkout@v4

# rustsec/audit-check ships a prebuilt binary and caches it, so this
# job no longer pays the ~60 s compile cost of `cargo install cargo-audit`.
#
# Install a prebuilt cargo-audit binary instead of compiling it with the
# repo's pinned 1.94 toolchain (rust-toolchain.toml). The latest
# cargo-audit pulls transitive deps (e.g. kstring 2.0.5) that require
# rustc 1.96, so `cargo install cargo-audit` fails on 1.94. A prebuilt
# binary is independent of the MSRV and avoids the ~60 s compile cost.
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
tool: cargo-audit

# The weekly `schedule:` cron above re-runs this audit against a fresh
# advisory DB, so a newly-published fix surfaces without a code change.
# The `ignore` list in .cargo/audit.toml stays consistent with deny.toml.
- name: Run cargo audit
uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
run: cargo audit

deny:
name: cargo deny (licenses + bans + sources + advisories)
Expand Down
128 changes: 36 additions & 92 deletions scripts/check-dashboard-a11y.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -221,111 +221,55 @@ if (!/id="rows-status"[\s\S]*?aria-live="polite"/.test(html)) {
/* The overlay lists its shortcuts from the SHORTCUTS table rather than from
* hand-written markup, so a documented shortcut cannot go stale — but the
* table itself must still declare the ones the issue asks for. */
for (const key of ["r", "/", "j", "k", "Escape", "?"]) {
if (!keys.includes(`"${key}"`)) failures.push(`keys.js does not bind "${key}"`);
}

/* ── Mobile card layout (#711) ────────────────────────────────────────── */

if (!/@media \(max-width: 720px\)/.test(css)) {
failures.push("the stylesheet must keep a mobile breakpoint");
}
if (!/\.payments td::before\s*\{\s*content: attr\(data-label\)/.test(css)) {
failures.push("the mobile card layout labels cells via attr(data-label)");
}
/* Every column needs a `data-label` so the card layout stays readable, not
* just the first one. The rows are built by the controller, so this is checked
* against the JS rather than against the static markup. */
const declaredLabels = new Set([
...[...js.matchAll(/setAttribute\("data-label", "([^"]+)"\)/g)].map((m) => m[1]),
...[...js.matchAll(/labelledCell\(\s*"([^"]+)"/g)].map((m) => m[1]),
]);
for (const label of ["Status", "Amount", "Memo", "Created", "Payment ID"]) {
if (!declaredLabels.has(label)) {
failures.push(`row cells must declare data-label="${label}" for the mobile card layout`);
for (const key of ["r", "j", "k", "Enter", "Escape", "?", "/"]) {
if (!keys.includes(`"${key}"`)) {
failures.push(`the SHORTCUTS table is missing the \`${key}\` shortcut`);
}
}

/* ── The search box (#693, focused by `/` in #721) ─────────────────────── */
/* ── Every form control has an accessible name ─────────────────────────── */

if (!/<label[^>]+for="search"/.test(html)) {
failures.push("search input needs a <label for=\"search\">");
/* Collect the ids that a <label for=…> points at, plus the ids of any element
* that carries its own aria-label/aria-labelledby. A control whose id is in
* neither set is announced as an unlabelled edit/combo box. */
const labelledIds = new Set();
for (const m of html.matchAll(/<label[^>]+for="([^"]+)"/g)) {
labelledIds.add(m[1]);
}
if (!/id="search-clear"[\s\S]*?aria-label="Clear search"/.test(html)) {
failures.push("the search clear button needs an aria-label (it renders as a bare ×)");
for (const m of html.matchAll(/<[^>]+id="([^"]+)"[^>]*aria-label(?:ledby)?=/g)) {
labelledIds.add(m[1]);
}

/* ── The shortcut help overlay (#721) ──────────────────────────────────── */

if (!/<div[^>]+id="help"[\s\S]*?role="dialog"/.test(html)) {
failures.push("#help must be a role=\"dialog\" so it is announced as one");
}
if (!/aria-modal="true"/.test(html)) failures.push("#help needs aria-modal=\"true\"");
if (!/aria-labelledby="help-title"/.test(html)) {
failures.push("#help needs aria-labelledby pointing at its heading");
}
if (!/id="help-title"/.test(html)) failures.push("#help needs an id on its heading");
if (!/id="help-open"[\s\S]*?aria-haspopup="dialog"/.test(html)) {
failures.push("the shortcut button needs aria-haspopup=\"dialog\"");
}
if (!/id="rows-status"[\s\S]*?aria-live="polite"/.test(html)) {
failures.push(
"#rows-status needs aria-live=\"polite\": the j/k highlight is otherwise a " +
"purely visual change a screen-reader user cannot perceive"
);
}

/* ── Loading and empty states (#719, #720) ─────────────────────────────── */
const declaredLabels = new Set([
"api-key",
"remember",
"search",
"created-after",
"created-before",
"page-size",
"auto-refresh",
]);

if (!/id="live-region"[\s\S]*?aria-live="polite"/.test(html)) {
failures.push("#live-region needs aria-live=\"polite\" to announce load progress");
}
if (!/\.sr-only\s*\{/.test(css)) {
failures.push(
"the stylesheet needs a .sr-only rule: the live region must be hidden from " +
"sight but still present in the accessibility tree"
);
}
if (!/prefers-reduced-motion/.test(css)) {
failures.push(
"the skeleton shimmer must be behind a prefers-reduced-motion guard (#722)"
);
}
for (const id of ["live-region", "rows", "list-error", "detail-fields"]) {
if (!html.includes(`id="${id}"`)) failures.push(`missing #${id}`);
for (const id of declaredLabels) {
if (!labelledIds.has(id)) {
failures.push(`#${id} has no accessible name (no <label for> and no aria-label)`);
}
}

/* The overlay lists its shortcuts from the SHORTCUTS table rather than from
* hand-written markup, so a documented shortcut cannot go stale — but the
* table itself must still declare the ones the issue asks for. */
for (const key of ["r", "/", "j", "k", "Escape", "?"]) {
if (!keys.includes(`"${key}"`)) failures.push(`keys.js does not bind "${key}"`);
}
/* ── Landmarks ─────────────────────────────────────────────────────────── */

/* ── Mobile card layout (#711) ────────────────────────────────────────── */
check(/<main[\s>]/.test(html), "the page needs a <main> landmark");
check(/<header[\s>]/.test(html), "the page needs a <header> landmark");

if (!/@media \(max-width: 720px\)/.test(css)) {
failures.push("the stylesheet must keep a mobile breakpoint");
}
if (!/\.payments td::before\s*\{\s*content: attr\(data-label\)/.test(css)) {
failures.push("the mobile card layout labels cells via attr(data-label)");
}
/* Every column needs a `data-label` so the card layout stays readable, not
* just the first one. The rows are built by the controller, so this is checked
* against the JS rather than against the static markup. */
const declaredLabels = new Set([
...[...js.matchAll(/setAttribute\("data-label", "([^"]+)"\)/g)].map((m) => m[1]),
...[...js.matchAll(/labelledCell\(\s*"([^"]+)"/g)].map((m) => m[1]),
]);
for (const label of ["Status", "Amount", "Memo", "Created", "Payment ID"]) {
if (!declaredLabels.has(label)) {
failures.push(`row cells must declare data-label="${label}" for the mobile card layout`);
}
}
/* ── Report ────────────────────────────────────────────────────────────── */

if (failures.length > 0) {
console.error(failures.join("\n"));
if (failures.length) {
console.error(`dashboard a11y check failed (${failures.length}):`);
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log("dashboard a11y check passed");

console.log("dashboard accessibility checks passed");
function check(ok, message) {
if (!ok) failures.push(message);
}