From f741079c5831868740ec4560cc60e3a9ba2a7ea2 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Thu, 22 Oct 2026 12:55:14 +0200 Subject: [PATCH 01/10] feat(a11y): add axe testing --- README.md | 6 +++ e2e/accessibility.spec.ts | 84 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 24 +++++++++++ package.json | 1 + 4 files changed, 115 insertions(+) create mode 100644 e2e/accessibility.spec.ts diff --git a/README.md b/README.md index 8cbda0ad..d0ae7c16 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ Read more about the platform in the [documentation](https://docs.stackable.tech/ More modules will be added over time. +## Accessibility + +Stackable Cockpit targets [BITV 2.0](https://www.gesetze-im-internet.de/bitv_2_0/), which is based on [WCAG 2.1](https://www.w3.org/TR/WCAG21/) level AA. +As the project is still experimental, it may not fully meet these requirements yet. +We treat every accessibility violation as a bug, so please [open an issue](https://github.com/stackabletech/cockpit/issues) if you find one. + ## Quick Start The required Node.js version is pinned in [`.node-version`](./.node-version). diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts new file mode 100644 index 00000000..f3a0db57 --- /dev/null +++ b/e2e/accessibility.spec.ts @@ -0,0 +1,84 @@ +import AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; +import { test, expect } from './support/fixtures.js'; +import { hasGarageCredentials, requireGarageCredentials } from './support/garage.js'; +import { setTabSql, waitForHydration, waitForQueryComplete } from './support/helpers'; +import { connectAndOpenPrefix, connectToStorage } from './storage/helpers.js'; + +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +const THEMES = ['light', 'dark'] as const; + +async function expectNoViolations(page: Page) { + const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze(); + const summary = violations.flatMap((v) => + v.nodes.map((n) => `${v.id} [${v.impact}] ${n.target.join(' ')}: ${n.failureSummary}`) + ); + expect(summary).toEqual([]); +} + +for (const theme of THEMES) { + test.describe(`Accessibility (axe, ${theme} theme)`, () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript((t) => localStorage.setItem('theme', t), theme); + }); + + test.describe('logged out', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('login page', async ({ page }) => { + await page.goto('/auth/login'); + await waitForHydration(page); + await expectNoViolations(page); + }); + }); + + test('dashboard', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + await expectNoViolations(page); + }); + + test('Trino editor with query results', async ({ page }) => { + await setTabSql(page, 'SELECT id, name FROM users'); + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await waitForQueryComplete(page); + await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible(); + await expectNoViolations(page); + }); + + test('storage connect form', async ({ page }) => { + await page.goto('/storage'); + await waitForHydration(page); + await expectNoViolations(page); + }); + + test('storage connection settings', async ({ page }) => { + await page.goto('/settings/connections'); + await waitForHydration(page); + await expectNoViolations(page); + }); + + test.describe('with storage', () => { + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('storage overview when connected', async ({ page }) => { + await connectToStorage(page, requireGarageCredentials()); + await expectNoViolations(page); + }); + + test('bucket object list', async ({ page }) => { + await connectAndOpenPrefix(page, requireGarageCredentials()); + await expectNoViolations(page); + }); + }); + }); +} diff --git a/package-lock.json b/package-lock.json index 6832b6a1..d69e3e84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "undici": "^7.24.4" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@cyclonedx/cyclonedx-npm": "^6.0.0", "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", @@ -502,6 +503,19 @@ "node": ">=18.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -7156,6 +7170,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", diff --git a/package.json b/package.json index 0e08978b..f77f39fb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "db:studio": "drizzle-kit studio" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@cyclonedx/cyclonedx-npm": "^6.0.0", "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", From aa76c6f77f22f474f2bd03fd029f24476c60201e Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Thu, 22 Oct 2026 13:01:55 +0200 Subject: [PATCH 02/10] fix(a11y): restore list item semantics in storage lists `role="none"` on `
  • ` elements inside plain lists stripped their listitem semantics, so axe reported links as invalid direct children of the list (aria-required-children). Affects the pinned locations and bucket lists in the sidebar and the active/history lists in the operations panel. --- src/lib/components/storage/explorer/OperationsButton.svelte | 4 ++-- src/lib/components/storage/sidebar/BucketList.svelte | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/storage/explorer/OperationsButton.svelte b/src/lib/components/storage/explorer/OperationsButton.svelte index 5fbc02fd..11278f66 100644 --- a/src/lib/components/storage/explorer/OperationsButton.svelte +++ b/src/lib/components/storage/explorer/OperationsButton.svelte @@ -244,7 +244,7 @@ {#if hasSizes && totalSize > 0} -

    +

    {m.storage_move_confirm_total_size({ size: formatFileSize(totalSize) })}

    {/if} diff --git a/src/lib/components/storage/modals/PreviewModal.svelte b/src/lib/components/storage/modals/PreviewModal.svelte index 1aef7bab..20d10bd8 100644 --- a/src/lib/components/storage/modals/PreviewModal.svelte +++ b/src/lib/components/storage/modals/PreviewModal.svelte @@ -891,7 +891,7 @@ class="relative flex-1 px-4 py-2.5 text-sm font-medium transition-colors {parquetTab === 'metadata' ? 'bg-base-100 text-primary' - : 'text-base-content/60 hover:bg-base-100/50 hover:text-base-content'}" + : 'text-muted hover:bg-base-100/50 hover:text-base-content'}" role="tab" aria-selected={parquetTab === 'metadata'} onclick={() => (parquetTab = 'metadata')} @@ -905,7 +905,7 @@ class="relative flex-1 px-4 py-2.5 text-sm font-medium transition-colors {parquetTab === 'data' ? 'bg-base-100 text-primary' - : 'text-base-content/60 hover:bg-base-100/50 hover:text-base-content'}" + : 'text-muted hover:bg-base-100/50 hover:text-base-content'}" role="tab" aria-selected={parquetTab === 'data'} onclick={() => (parquetTab = 'data')} @@ -933,7 +933,7 @@ {:else if preview.kind === 'parquet'} @@ -955,7 +955,7 @@

    {m.storage_preview_parquet_blocked_title()}

    -

    +

    {m.storage_preview_parquet_blocked_desc()}

    @@ -994,7 +994,7 @@ data-testid="editor-readonly-notice" >