Skip to content

Commit 3c1b2cd

Browse files
test: add component a11y unit tests (jest-axe) (#13613)
* test: add component a11y unit tests (jest-axe) Cover 14 components in two waves. 16 tests fail by design - each encodes a known gap from a11y-action-plan (phases 0-3) and flips to a regression lock once the component fix lands. 23 tests pass as regression locks for already-correct semantics. * ci: split a11y unit tests into separate workflow Main jest CI excludes *.a11y.test.* so known-gap failures don't block PRs; new a11y-unit-tests.yml runs them as informational check. * fix(a11y): resolve component gaps flagged by a11y unit tests Fixes all 16 failing jest-axe gap tests: - TableHead defaults scope=col - alert display area gets aria-live region - app header becomes <header> landmark; bell button labeled - icon wrapper decorative by default (aria-hidden), ariaLabel opt-in - Input drops wrapping <label> (name pollution) and hides placeholder span - password toggle back in tab order with aria-label/aria-pressed - CheckBoxDiv exposes checkbox role + state - accordion trigger renders native Radix button - dialogs focus container on open instead of suppressing autofocus - full-screen modal exposes dialog role/aria-modal/ariaLabel - flow list card focusable with keyboard activation Also wraps test focus calls in act() and mocks the icon loader so the a11y suite runs without React warnings. * fix: skip Puppeteer Chrome download in remaining Dockerfiles frontend and base images still ran bare npm install; puppeteer (via accessibility-checker, test-only) tried to fetch Chrome and broke the Docker image build in CI. * test: extend a11y unit test coverage (jest-axe) Adds axe + semantics tests for untested primitives (button, alert, textarea, radio-group, dropdown-menu, tooltip, popover) and two known-gap tests that fail by design until fixes land: - copyFieldAreaComponent copy action is a bare div onClick (plan 2.4) - DialogContentWithouFixed renders an empty fragment (plan 1.6) * fix: make webhook copy field a real button (a11y) Copy action was a bare div onClick - no role, name, or keyboard support (a11y-action-plan 2.4). Drops the no-longer-needed DialogContentWithouFixed known-gap test. * feat: update a11y unit tests and improve accessibility semantics in components * feat: enable pull request trigger for a11y scan workflow
1 parent 9dbde72 commit 3c1b2cd

42 files changed

Lines changed: 1315 additions & 44 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/a11y-scan.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ on:
55
# branch, since workflow_dispatch only registers once the file is on main.
66
push:
77
branches: [feat/a11y]
8+
# TEMP: remove before merging — run on PRs while validating the workflow.
9+
pull_request:
810
schedule:
911
# Run nightly at 02:00 UTC, after the nightly build window
1012
- cron: "0 2 * * *"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Run Frontend A11y Unit Tests
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "src/frontend/**"
7+
- ".github/workflows/a11y-unit-tests.yml"
8+
- "Makefile.frontend"
9+
workflow_call:
10+
inputs:
11+
ref:
12+
description: "(Optional) ref to checkout"
13+
required: false
14+
type: string
15+
workflow_dispatch:
16+
inputs:
17+
ref:
18+
description: "(Optional) ref to checkout"
19+
required: false
20+
type: string
21+
22+
env:
23+
NODE_VERSION: "22"
24+
25+
jobs:
26+
a11y-unit-tests:
27+
name: Frontend A11y Unit Tests (jest-axe)
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
checks: write
32+
steps:
33+
- name: Checkout Repository
34+
uses: actions/checkout@v6
35+
with:
36+
ref: ${{ inputs.ref || github.ref }}
37+
38+
- name: Setup Node.js Environment
39+
uses: actions/setup-node@v6
40+
with:
41+
node-version: ${{ env.NODE_VERSION }}
42+
cache: "npm"
43+
cache-dependency-path: ./src/frontend/package-lock.json
44+
45+
# These are regression locks: every a11y unit test passes as of the
46+
# component fixes in feat/a11y-unit-tests, so a failure here means a
47+
# real a11y regression and blocks the PR.
48+
- name: Run Frontend A11y Unit Tests
49+
run: make test_frontend_a11y_ci
50+
51+
- name: Publish A11y Test Results
52+
uses: mikepenz/action-junit-report@v5
53+
if: always()
54+
with:
55+
report_paths: "src/frontend/test-results/junit.xml"
56+
check_name: "Frontend A11y Unit Test Results"
57+
fail_on_failure: true
58+
require_tests: true

Makefile.frontend

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,16 @@ test_frontend_verbose: frontend_deps_check ## run frontend tests with verbose ou
102102
@cd $(FRONTEND_DIR) && npx jest --verbose
103103

104104
# Run frontend tests in CI mode (no watch, with coverage)
105+
# Excludes *.a11y.test.* files - those run in their own blocking
106+
# workflow (a11y-unit-tests.yml) with a dedicated junit check.
105107
test_frontend_ci: frontend_deps_check ## run frontend tests in CI mode
106108
@echo "Running frontend tests in CI mode..."
107-
@cd $(FRONTEND_DIR) && CI=true npx jest --ci --coverage --watchAll=false
109+
@cd $(FRONTEND_DIR) && CI=true npx jest --ci --coverage --watchAll=false --testPathIgnorePatterns="/node_modules/" "test-utils.tsx" "\.a11y\.test\."
110+
111+
# Run only the a11y component tests (jest-axe)
112+
test_frontend_a11y_ci: frontend_deps_check ## run frontend a11y unit tests in CI mode
113+
@echo "Running frontend a11y unit tests in CI mode..."
114+
@cd $(FRONTEND_DIR) && CI=true npx jest --ci --watchAll=false --testPathPatterns="\.a11y\.test\."
108115

109116
# Clean test cache and run tests
110117
test_frontend_clean: frontend_deps_check ## clean test cache and run tests

a11y-test-usage.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,71 @@ RUN_A11Y=true RUN_A11Y_ASSERT=true npx playwright test <file> --retries=0
142142
```
143143

144144
5. Map findings back to IBM Equal Access `Level 1` scope in the audit docs.
145+
146+
---
147+
148+
## Component-level a11y unit tests (jest-axe)
149+
150+
Second tier next to the IBM page scans. Runs on every PR in its own workflow
151+
(`.github/workflows/a11y-unit-tests.yml`, `make test_frontend_a11y_ci`). All
152+
tests pass as of the fixes in this branch and act as regression locks: a
153+
failure blocks the PR. The main Jest workflow excludes `*.a11y.test.*` files.
154+
155+
> **jsdom limitations — not a compliance signal.** jest-axe runs in jsdom,
156+
> which has no layout engine: color contrast, focus-visible styling, and
157+
> anything canvas-based (ReactFlow nodes/edges) are **not** covered here and
158+
> stay with the IBM Playwright scans. A green jest-axe run means the tested
159+
> DOM semantics are correct, not that the component is WCAG-compliant.
160+
161+
- Matcher: `toHaveNoViolations` is registered globally in [src/frontend/src/setupTests.ts](/Users/viktoravelino/projects/langflow/src/frontend/src/setupTests.ts)
162+
- Shared axe instance: [src/frontend/src/utils/a11y-test.ts](/Users/viktoravelino/projects/langflow/src/frontend/src/utils/a11y-test.ts) (`color-contrast` disabled — jsdom has no layout; contrast stays with the IBM checker)
163+
- File convention: `<component>.a11y.test.tsx` inside the component's `__tests__/` folder
164+
- New known gaps should be encoded as regular tests asserting the semantics the component *should* have, and land **together with the component fix** — the workflow blocks on failures, so a red test means a regression (or a fix that hasn't landed yet)
165+
166+
Pattern:
167+
168+
```tsx
169+
import { render, screen } from "@testing-library/react";
170+
import { axe } from "@/utils/a11y-test";
171+
172+
it("has no axe violations", async () => {
173+
const { container } = render(<MyComponent aria-label="Thing" />);
174+
expect(await axe(container)).toHaveNoViolations();
175+
});
176+
```
177+
178+
Run them:
179+
180+
```bash
181+
cd src/frontend
182+
npx jest a11y.test # all component a11y tests
183+
npx jest checkbox.a11y # one component
184+
```
185+
186+
### Candidate components
187+
188+
Wave 1 — shared primitives with named gaps in [a11y-action-plan.md](/Users/viktoravelino/projects/langflow/a11y-action-plan.md):
189+
190+
| Component | Plan item | What to assert | Status |
191+
|-----------|-----------|----------------|--------|
192+
| `components/ui/checkbox.tsx` (`Checkbox`, `CheckBoxDiv`) | 1.3 | checkbox role, `aria-checked`, keyboard toggle | Done — `checkbox.a11y.test.tsx` (passing — regression lock) |
193+
| `components/ui/accordion.tsx` (`AccordionTrigger`) | 1.4 | trigger is focusable button with expanded state | Done — `accordion.a11y.test.tsx` (passing — regression lock) |
194+
| `components/ui/input.tsx` | 1.2 | no duplicate label when externally labeled | Done — `input.a11y.test.tsx` (passing — regression lock) |
195+
| `components/ui/dialog.tsx` | 0.4 / 1.7 | focus lands inside on open; single accessible dialog name | Done — `dialog.a11y.test.tsx` (passing — regression lock) |
196+
| `parameterRenderComponent/components/inputComponent` | 1.1 | password toggle tab-focusable, `aria-label` + `aria-pressed` | Done — `inputComponent.a11y.test.tsx` (passing — regression lock) |
197+
| `modals/baseModal` (`type="full-screen"`) | 1.5 | `role="dialog"`, `aria-modal`, labeled title | Done — `baseModal.a11y.test.tsx` (passing — regression lock) |
198+
| `components/common/genericIconComponent` | 0.3 | decorative default `aria-hidden`, opt-in `aria-label` | Done — `genericIconComponent.a11y.test.tsx` (uses `jest.unmock`; passing — regression lock) |
199+
200+
Wave 2 — naming, structure, feedback:
201+
202+
| Component | Plan item | What to assert | Status |
203+
|-----------|-----------|----------------|--------|
204+
| `core/appHeaderComponent` | 2.3 / 3.3 | `<header>` landmark; bell button accessible name + unread state | Done — `appHeader.a11y.test.tsx` (passing — regression lock) |
205+
| `pages/MainPage/components/list` (flow cards) | 2.2 | card is focusable link/button, Enter activates | Done — `list.a11y.test.tsx` (passing — regression lock) |
206+
| `alerts/displayArea` | 4.2 | `aria-live` region announces alerts | Done — `displayArea.a11y.test.tsx` (passing — regression lock) |
207+
| `components/ui/switch.tsx` | 4.1.2 | switch role + checked state | Done — `switch.a11y.test.tsx` (passing regression locks) |
208+
| `components/ui/select.tsx` / `dropdown-menu.tsx` | 4.1.2 | combobox/menu trigger has accessible name (IBM scan flagged unnamed comboboxes) | Done — `select.a11y.test.tsx` (passing locks; call-site naming stays with IBM scans) |
209+
| `components/ui/table.tsx` | 3.5 | header cells with `scope="col"`, caption/label | Done — `table.a11y.test.tsx` (passing — regression lock) |
210+
| `components/ui/tabs.tsx` | 4.1.2 | tablist/tab roles, arrow-key navigation | Done — `tabs.a11y.test.tsx` (passing regression locks) |
211+
212+
Not testable in jsdom (stay with IBM page scans): ReactFlow canvas, node handles/edges, contrast tokens, focus-visible CSS.

docker/build_and_push_base.Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ COPY src/frontend /tmp/src/frontend
6868
WORKDIR /tmp/src/frontend
6969
# Increase memory and disable concurrent builds to avoid esbuild crashes on emulated architectures
7070
# Force esbuild to use JS implementation on emulated architectures to avoid native binary crashes
71-
RUN npm install \
71+
# PUPPETEER_SKIP_DOWNLOAD: puppeteer (via accessibility-checker, test-only)
72+
# must not download Chrome here - the build env can't fetch it and the
73+
# production image never runs it.
74+
RUN PUPPETEER_SKIP_DOWNLOAD=true npm install \
7275
&& ESBUILD_BINARY_PATH="" NODE_OPTIONS="--max-old-space-size=4096" JOBS=1 npm run build \
7376
&& cp -r build /app/src/backend/base/langflow/frontend \
7477
&& rm -rf /tmp/src/frontend

docker/frontend/build_and_push_frontend.Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
FROM --platform=$BUILDPLATFORM node:lts-bookworm-slim AS builder-base
1010
COPY src/frontend /frontend
1111

12-
RUN cd /frontend && npm install && npm run build
12+
# PUPPETEER_SKIP_DOWNLOAD: puppeteer (via accessibility-checker, test-only)
13+
# must not download Chrome here - the build env can't fetch it and the
14+
# production image never runs it.
15+
RUN cd /frontend && PUPPETEER_SKIP_DOWNLOAD=true npm install && npm run build
1316

1417
################################
1518
# RUNTIME
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import useAlertStore from "@/stores/alertStore";
3+
4+
// ErrorAlert pulls in react-markdown (ESM-only in jest); the alert bodies
5+
// are not under test here — only the display area wrapper semantics.
6+
jest.mock("@/alerts/error", () => ({
7+
__esModule: true,
8+
default: ({ title }: { title: string }) => <div>{title}</div>,
9+
}));
10+
jest.mock("@/alerts/notice", () => ({
11+
__esModule: true,
12+
default: ({ title }: { title: string }) => <div>{title}</div>,
13+
}));
14+
15+
import AlertDisplayArea from "../index";
16+
17+
describe("AlertDisplayArea accessibility", () => {
18+
beforeEach(() => {
19+
act(() => {
20+
useAlertStore.setState({ tempNotificationList: [] });
21+
});
22+
});
23+
24+
it("should_render_alert_content", () => {
25+
render(<AlertDisplayArea />);
26+
27+
act(() => {
28+
useAlertStore.getState().setSuccessData({ title: "Flow saved" });
29+
});
30+
31+
expect(screen.getByText("Flow saved")).toBeInTheDocument();
32+
});
33+
34+
// Known gap (a11y-action-plan 4.2): the alert display area has no
35+
// aria-live region, so success/error/notice messages are never announced
36+
// to screen readers. Fails until the fix lands.
37+
it("should_announce_alerts_via_live_region", () => {
38+
const { container } = render(<AlertDisplayArea />);
39+
40+
const wrapper = container.firstElementChild;
41+
expect(wrapper).not.toBeNull();
42+
expect(wrapper).toHaveAttribute("aria-live");
43+
});
44+
});

src/frontend/src/alerts/displayArea/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export default function AlertDisplayArea() {
1515
};
1616

1717
return (
18-
<div className="flex flex-col-reverse" style={{ zIndex: 999 }}>
18+
<div
19+
aria-live="polite"
20+
role="status"
21+
className="flex flex-col-reverse"
22+
style={{ zIndex: 999 }}
23+
>
1924
{tempNotificationList.map((alert) => (
2025
<div key={alert.id}>
2126
{alert.type === "error" ? (
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// The global mock from jest.setup.js replaces this component with null;
2+
// unmock it here so the real implementation is under test.
3+
jest.unmock("@/components/common/genericIconComponent");
4+
5+
// Icons load through lazy dynamic imports that never settle in jsdom;
6+
// resolve to a simple svg component so the loaded state is reachable.
7+
// Must be an object-style component (forwardRef): real icons are
8+
// lazy/forwardRef objects, and a plain function would be treated as a
9+
// setState updater by setTargetIcon.
10+
jest.mock("@/utils/styleUtils", () => {
11+
const { forwardRef } = jest.requireActual("react");
12+
return {
13+
getCachedIcon: () => null,
14+
getNodeIcon: () =>
15+
Promise.resolve(
16+
forwardRef(
17+
(
18+
{ isDark: _isDark, ...props }: Record<string, unknown>,
19+
ref: unknown,
20+
) => <svg ref={ref as React.Ref<SVGSVGElement>} {...props} />,
21+
),
22+
),
23+
};
24+
});
25+
26+
import { render, screen } from "@testing-library/react";
27+
28+
const ForwardedIconComponent = jest.requireActual(
29+
"@/components/common/genericIconComponent",
30+
).default;
31+
32+
// The component loads its icon asynchronously; await the loaded icon so the
33+
// state updates happen inside the test (avoids act() warnings).
34+
const renderIconAndWait = async () => {
35+
const view = render(<ForwardedIconComponent name="Check" />);
36+
await screen.findByTestId("icon-Check");
37+
return view;
38+
};
39+
40+
describe("ForwardedIconComponent accessibility", () => {
41+
it("should_render_icon_wrapper", async () => {
42+
const { container } = await renderIconAndWait();
43+
44+
expect(container.firstElementChild).not.toBeNull();
45+
});
46+
47+
// a11y-action-plan 0.3: icons are decorative by default and must be
48+
// hidden from assistive technology unless an ariaLabel is provided.
49+
it("should_hide_decorative_icons_from_AT_by_default", async () => {
50+
await renderIconAndWait();
51+
52+
expect(screen.getByTestId("icon-Check")).toHaveAttribute(
53+
"aria-hidden",
54+
"true",
55+
);
56+
});
57+
58+
it("should_expose_icon_when_labeled", async () => {
59+
render(<ForwardedIconComponent name="Check" ariaLabel="Success" />);
60+
const icon = await screen.findByTestId("icon-Check");
61+
62+
expect(icon).toHaveAttribute("role", "img");
63+
expect(icon).toHaveAttribute("aria-label", "Success");
64+
expect(icon).not.toHaveAttribute("aria-hidden");
65+
});
66+
});

src/frontend/src/components/common/genericIconComponent/index.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export const ForwardedIconComponent = memo(
3232
id = "",
3333
skipFallback = false,
3434
dataTestId = "",
35+
ariaHidden,
36+
ariaLabel,
37+
title,
3538
}: IconComponentProps,
3639
ref,
3740
) => {
@@ -92,10 +95,25 @@ export const ForwardedIconComponent = memo(
9295
setIconError(true);
9396
}, []);
9497

98+
// Icons are decorative by default: hidden from assistive technology
99+
// unless an explicit ariaLabel (or ariaHidden=false) is provided.
100+
const labelled = Boolean(ariaLabel);
101+
const a11yProps = labelled
102+
? {
103+
role: "img",
104+
"aria-label": ariaLabel,
105+
...(title && { title }),
106+
}
107+
: {
108+
"aria-hidden": ariaHidden ?? true,
109+
...(title && { title }),
110+
};
111+
95112
if (!TargetIcon || iconError) {
96113
// Return a placeholder div or null depending on settings
97114
return skipFallback ? null : (
98115
<div
116+
{...a11yProps}
99117
className={cn(className, "flex items-center justify-center")}
100118
data-testid={
101119
dataTestId
@@ -109,11 +127,14 @@ export const ForwardedIconComponent = memo(
109127
}
110128

111129
const fallback = showFallback ? (
112-
<div className={cn(className, "flex items-center justify-center")}>
130+
<div
131+
{...a11yProps}
132+
className={cn(className, "flex items-center justify-center")}
133+
>
113134
<Skeleton className="h-4 w-4" />
114135
</div>
115136
) : (
116-
<div className={className}></div>
137+
<div {...a11yProps} className={className}></div>
117138
);
118139

119140
// Check if TargetIcon is a valid React component (function, class, or lazy component)
@@ -143,6 +164,7 @@ export const ForwardedIconComponent = memo(
143164
// - type: wrapped components (memo wrapping forwardRef))
144165

145166
const baseProps = {
167+
...a11yProps,
146168
className,
147169
style,
148170
"data-testid": dataTestId

0 commit comments

Comments
 (0)