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
86 changes: 86 additions & 0 deletions .github/workflows/a11y-helioslab.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: a11y-helioslab

# HeliosLab WCAG 2.1 AA gate. Calls the proposed reusable workflow at
# OmniRoute-3rd/.github/workflows/reusable-a11y.yml once that lands; until
# then, the steps here are inlined.
#
# Triggers: every push to main, every PR. Skips runs for changes that don't
# touch src/, e2e/, or this workflow file.

on:
push:
branches: [main]
paths:
- "src/**"
- "e2e/**"
- ".github/workflows/a11y-helioslab.yml"
- "tests/**"
- "scripts/**"
- "package.json"
- "bun.lock"
- "playwright.config.ts"
- "bunfig.toml"
- "axe-config.ts"
pull_request:
branches: [main]
paths:
- "src/**"
- "e2e/**"
- ".github/workflows/a11y-helioslab.yml"
- "tests/**"
- "scripts/**"
- "package.json"
- "bun.lock"
- "playwright.config.ts"
- "bunfig.toml"
- "axe-config.ts"
workflow_dispatch:
Comment thread
kilo-code-bot[bot] marked this conversation as resolved.

permissions:
contents: read

jobs:
axe:
name: axe-core WCAG 2.1 AA
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # v4.2.2
with:
persist-credentials: false

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.1.0
with:
bun-version: 1.2.0

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium

- name: Run a11y unit tests
run: bun test tests/unit/a11y.test.ts tests/unit/a11y/

- name: Check i18n keys
run: bun run scripts/check-i18n-keys.mjs
continue-on-error: false

- name: Check hardcoded strings
run: bun run scripts/check-hardcoded-strings.mjs
continue-on-error: false

- name: Run axe a11y e2e tests
env:
HELIOSLAB_RENDERER_URL: http://localhost:5173
run: node node_modules/playwright/cli.js test e2e/a11y/ --reporter=github

- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3
with:
name: playwright-report-helioslab
path: playwright-report/
retention-days: 7
50 changes: 50 additions & 0 deletions axe-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* axe-core shared configuration for the HeliosLab a11y test suite.
*
* Tag set covers WCAG 2.0 A/AA + WCAG 2.1 A/AA — the legal baseline for
* Phenotype web/desktop surfaces. Excluded rules:
* - `bypass`: Monaco's complex DOM (sidebar / editor / tabs split) can't
* always provide a "skip" link the rule expects.
* - `region`: A desktop app window is not a web page — landmark regions
* don't apply in the same way.
* - `color-contrast`: Monaco's syntax-highlight tokens have non-text
* contrast; this is verified by a separate monaco-theme audit.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*/
export const AXE_TAGS = [
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
] as const;

export const AXE_DISABLED_RULES = [
"bypass",
"region",
"color-contrast",
] as const;
Comment thread
kilo-code-bot[bot] marked this conversation as resolved.

export const AXE_OPTIONS = {
rules: AXE_DISABLED_RULES.reduce<Record<string, { enabled: boolean }>>(
(acc, id) => {
acc[id] = { enabled: false };
return acc;
},
{},
),
runOnly: {
type: "tag",
values: AXE_TAGS as unknown as string[],
},
resultTypes: ["violations"] as const,
} as const;

export type AxeImpact = "minor" | "moderate" | "serious" | "critical";

/** Filter helper — kept in one place so all spec files agree. */
export function blockingViolations(
results: { violations: Array<{ impact?: string | null; id: string; nodes: unknown[] }> },
) {
return results.violations.filter(
(v) => v.impact === "critical" || v.impact === "serious",
);
}
29 changes: 26 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup-dom.ts"]
90 changes: 90 additions & 0 deletions e2e/a11y/screen-reader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* e2e/a11y/screen-reader.spec.ts — assert the structural ARIA contract that
* screen-reader users depend on, independent of the actual screen-reader
* binary. We do NOT attempt to drive NVDA / VoiceOver from CI; instead we
* verify the DOM the screen reader sees.
*
* Covers (from spec AT3):
* - File tree exposes role="tree" with role="treeitem" children
* - Tab bar exposes role="tablist" with role="tab" children
* - Settings dialog exposes role="dialog" + aria-modal="true"
* - Live regions exist with correct politeness settings
*/
import { test, expect } from "@playwright/test";

const BASE_URL = process.env.HELIOSLAB_RENDERER_URL ?? "http://localhost:5173";

test.describe("Screen-reader contract", () => {
test.beforeEach(async ({ page }) => {
await page.goto(`${BASE_URL}/`);
await page.waitForSelector("#workbench-container", { timeout: 10_000 });
});

test("file tree has role=tree with treeitem children", async ({ page }) => {
const tree = page.locator('[role="tree"]').first();
await expect(tree).toHaveCount(1);

const items = await tree.locator('[role="treeitem"]').count();
expect(items).toBeGreaterThan(0);

// Each treeitem must have aria-expanded (or be a leaf without children).
const firstItem = tree.locator('[role="treeitem"]').first();
const hasExpanded = await firstItem.evaluate(
(el) => el.hasAttribute("aria-expanded") || el.getAttribute("aria-level") !== null,
);
expect(hasExpanded).toBe(true);
});

test("tab bar has role=tablist with tab children", async ({ page }) => {
const tablist = page.locator('[role="tablist"]').first();
if ((await tablist.count()) === 0) {
// Empty workspace — no tabs to assert against.
test.skip();
return;
}
const tabs = tablist.locator('[role="tab"]');
expect(await tabs.count()).toBeGreaterThan(0);

// The currently active tab must have aria-selected="true".
const selectedCount = await tablist
.locator('[role="tab"][aria-selected="true"]')
.count();
expect(selectedCount).toBe(1);
});

test("live regions exist with correct aria-live values", async ({ page }) => {
const polite = page.locator("#sr-live");
const alert = page.locator("#sr-alert");

await expect(polite).toHaveAttribute("aria-live", "polite");
await expect(polite).toHaveAttribute("aria-atomic", "true");
await expect(alert).toHaveAttribute("aria-live", "assertive");
await expect(alert).toHaveAttribute("role", "alert");
});

test("Monaco editor exposes accessibilitySupport via aria-label", async ({ page }) => {
// Monaco adds aria-label="Code editor" (or similar) when
// accessibilitySupport: 'on' is set in `src/config/editor.ts`.
const monaco = page.locator(".monaco-editor").first();
const ariaLabel = await monaco.getAttribute("aria-label");
expect(ariaLabel).toBeTruthy();
expect((ariaLabel ?? "").toLowerCase()).toContain("editor");
});

test("buttons without text content expose aria-label", async ({ page }) => {
// All button elements in the workbench should either have text,
// aria-label, or aria-labelledby. Walk the DOM and assert.
const unlabeled = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll("button"));
return buttons
.filter((b) => {
const text = (b.textContent ?? "").trim();
const label = b.getAttribute("aria-label");
const labelledBy = b.getAttribute("aria-labelledby");
return !text && !label && !labelledBy;
})
.map((b) => b.outerHTML.slice(0, 100));
});
expect(unlabeled).toEqual([]);
});
});
Loading
Loading