diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36b6aec2..08447caa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- **A leaked `serve.token` is rotated on Windows, not just Unix.** After SBS-953, a world-readable token was replaced on Unix, but Windows still tightened the DACL and reused the same secret. The ACL is now inspected before tightening; if anyone other than the current user, SYSTEM, or Administrators can read the file, the token is replaced. Closes SBS-1043.
### Fixed
+- **Frontend tests now catch accessibility regressions automatically.** A shared axe assertion checks representative quota cards, mini charts, and update banners in the existing Frontend CI job. Color contrast remains outside jsdom coverage because it requires a rendered browser. Fixes #222.
- **`usage --all-accounts` now fetches every configured Codex and Claude account.** Account fetches run with bounded concurrency, preserve configured order, and report failures independently. Text and JSON identify each configured account while the default output remains unchanged. Fixes #274.
- **The detached Settings window now reopens where you left it.** Its saved size and position are restored and clamped on screen instead of being overwritten by a second frontend resize on every open. Closes #275.
- **`serve --refresh-interval` now controls response caching.** Successful usage and cost responses are cached separately by provider selection for the requested TTL. A zero interval disables caching, and provider failures are retried on the next request. Fixes #273.
diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json
index 302c2f82..b31d5832 100644
--- a/apps/desktop-tauri/package.json
+++ b/apps/desktop-tauri/package.json
@@ -34,6 +34,7 @@
"jsdom": "25.0.1",
"typescript": "5.6.3",
"vite": "6.4.3",
- "vitest": "3.2.6"
+ "vitest": "3.2.6",
+ "vitest-axe": "^0.1.0"
}
}
diff --git a/apps/desktop-tauri/pnpm-lock.yaml b/apps/desktop-tauri/pnpm-lock.yaml
index 649b7886..e870f3f3 100644
--- a/apps/desktop-tauri/pnpm-lock.yaml
+++ b/apps/desktop-tauri/pnpm-lock.yaml
@@ -48,6 +48,9 @@ importers:
vitest:
specifier: 3.2.6
version: 3.2.6(jsdom@25.0.1)
+ vitest-axe:
+ specifier: ^0.1.0
+ version: 0.1.0(vitest@3.2.6(jsdom@25.0.1))
packages:
@@ -688,6 +691,10 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
baseline-browser-mapping@2.10.43:
resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==}
engines: {node: '>=6.0.0'}
@@ -717,6 +724,10 @@ packages:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -926,6 +937,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -1174,6 +1188,11 @@ packages:
yaml:
optional: true
+ vitest-axe@0.1.0:
+ resolution: {integrity: sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==}
+ peerDependencies:
+ vitest: '>=0.16.0'
+
vitest@3.2.6:
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1771,6 +1790,8 @@ snapshots:
asynckit@0.4.0: {}
+ axe-core@4.13.0: {}
+
baseline-browser-mapping@2.10.43: {}
browserslist@4.28.6:
@@ -1803,6 +1824,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.6.2: {}
+
check-error@2.1.3: {}
color-convert@2.0.1:
@@ -2024,6 +2047,8 @@ snapshots:
json5@2.2.3: {}
+ lodash-es@4.18.1: {}
+
lodash@4.18.1: {}
loose-envify@1.4.0:
@@ -2245,6 +2270,16 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ vitest-axe@0.1.0(vitest@3.2.6(jsdom@25.0.1)):
+ dependencies:
+ aria-query: 5.3.2
+ axe-core: 4.13.0
+ chalk: 5.6.2
+ dom-accessibility-api: 0.5.16
+ lodash-es: 4.18.1
+ redent: 3.0.0
+ vitest: 3.2.6(jsdom@25.0.1)
+
vitest@3.2.6(jsdom@25.0.1):
dependencies:
'@types/chai': 5.2.3
diff --git a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx
index 177e3d9e..3590a831 100644
--- a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx
+++ b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SimpleBarChart, StackedBarChart } from "./MiniBarChart";
+import { expectNoAccessibilityViolations } from "../test/accessibility";
vi.mock("../hooks/useLocale", () => ({
useLocale: () => ({ t: () => "No data" }),
@@ -18,4 +19,18 @@ describe("MiniBarChart empty state", () => {
expect(screen.getByRole("img", { name: "No data" })).toBeTruthy();
});
+
+ it("has no detectable accessibility violations", async () => {
+ const { container } = render(
+ ,
+ );
+
+ await expectNoAccessibilityViolations(container);
+ });
});
diff --git a/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx b/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx
index 944b4c4b..b136e7d5 100644
--- a/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx
+++ b/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PlanStatusCard from "./PlanStatusCard";
import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../types/bridge";
+import { expectNoAccessibilityViolations } from "../test/accessibility";
vi.mock("../hooks/useLocale", () => ({
useLocale: () => ({
@@ -67,6 +68,19 @@ function provider(
}
describe("PlanStatusCard", () => {
+ it("has no detectable accessibility violations", async () => {
+ const { container } = render(
+ ,
+ );
+
+ await expectNoAccessibilityViolations(container);
+ });
+
it("includes displayed quota windows in the card button name", () => {
render(
({
useLocale: () => ({ t: (key: string) => key }),
@@ -81,4 +82,12 @@ describe("UpdateBanner accessibility", () => {
);
expect(screen.getByRole("alert").textContent).toContain("network failed");
});
+
+ it("has no detectable accessibility violations", async () => {
+ const { container } = render(
+ ,
+ );
+
+ await expectNoAccessibilityViolations(container);
+ });
});
diff --git a/apps/desktop-tauri/src/test/accessibility.ts b/apps/desktop-tauri/src/test/accessibility.ts
new file mode 100644
index 00000000..7e609ec6
--- /dev/null
+++ b/apps/desktop-tauri/src/test/accessibility.ts
@@ -0,0 +1,17 @@
+import { axe } from "vitest-axe";
+import type { AxeMatchers } from "vitest-axe";
+import { expect } from "vitest";
+
+export async function expectNoAccessibilityViolations(
+ container: Element,
+): Promise {
+ const results = await axe(container, {
+ rules: {
+ // jsdom does not implement the canvas API axe uses for contrast.
+ "color-contrast": { enabled: false },
+ },
+ });
+ (
+ expect(results) as unknown as ReturnType & AxeMatchers
+ ).toHaveNoViolations();
+}
diff --git a/apps/desktop-tauri/src/test/setup.ts b/apps/desktop-tauri/src/test/setup.ts
index f149f27a..50b4fcf4 100644
--- a/apps/desktop-tauri/src/test/setup.ts
+++ b/apps/desktop-tauri/src/test/setup.ts
@@ -1 +1,6 @@
import "@testing-library/jest-dom/vitest";
+import "vitest-axe/extend-expect";
+import * as axeMatchers from "vitest-axe/matchers";
+import { expect } from "vitest";
+
+expect.extend(axeMatchers);