Skip to content
Draft
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
84 changes: 81 additions & 3 deletions .ai/skills/create-new-block/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,86 @@ Use `block__element--modifier` naming for classes added inside a block. The bloc

The block root class (`my-block`) is set by `loadBlock` from the first class on the element. All additional classes added by `init` should follow BEM from there.

## Known open questions
## Testing

These were unresolved at the time this skill was written and should be verified before implementing:
Every block has two kinds of tests.

1. What is the testing convention for blocks — are there unit tests, and if so what does the test file look like?
### Unit tests

Unit tests live in `test/blocks/<name>.test.js` and run in a real browser via `@web/test-runner`. They mount the block element directly and call `init(el)`, then assert on the resulting DOM. See existing tests for the pattern — `test/blocks/card.test.js` is a good reference.

### Accessibility tests

axe-core WCAG 2.2 AA scans run against every block and template via Playwright. **A new block is not done until it has both of these files** — a background check (`test/a11y/coverage.spec.js`) fails CI if a block under `blocks/` has no matching spec file, so don't skip this step when scaffolding.

1. Create `test/a11y/fixtures/<name>.html`. The fixture is a minimal HTML page that loads the block's CSS with `<link>` and initializes it with `<script type="module">`. Use a `data:` URI image placeholder so fixture images never 404.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My-block fixture</title>
<link rel="stylesheet" href="/styles/styles.css">
<link rel="stylesheet" href="/blocks/my-block/my-block.css">
</head>
<body>
<main id="main-content">
<div class="my-block"><!-- authored content here --></div>
</main>
<script type="module">
import init from '/blocks/my-block/my-block.js';
document.querySelectorAll('.my-block').forEach(init);
</script>
</body>
</html>
```

2. Create `test/a11y/blocks/<name>.spec.js` — one file per block, no shared registry to edit. Copy [`test/a11y/blocks/card.spec.js`](../../../test/a11y/blocks/card.spec.js) as a starting template:

```js
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '../axe-test.js';
import { gotoBlock, formatViolations } from '../block-a11y.js';

const block = {
name: 'my-block',
path: '/test/a11y/fixtures/my-block.html',
readySelector: '.my-block-inner', // element that appears after init completes
};

test(`${block.name} block in light/default mode has no WCAG 2.2 AA violations`, async ({ page, makeAxeBuilder }) => {
await gotoBlock(page, block);

const results = await makeAxeBuilder()
.disableRules(block.disableRules ?? [])
.analyze();

expect(results.violations, formatViolations(results.violations)).toHaveLength(0);
});

test(`${block.name} block in dark mode has no WCAG 2.2 AA violations`, async ({ page }, testInfo) => {
await page.emulateMedia({ colorScheme: 'dark' });
await gotoBlock(page, block);

const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();

await testInfo.attach('accessibility-scan-results', {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});

expect(results.violations, formatViolations(results.violations)).toHaveLength(0);
});
```

`readySelector` is a CSS selector for any element created by `init` — the test waits for it before running axe. If the block removes itself from the DOM on init (like `section-metadata`), use `{ selector: '.my-block', state: 'detached' }` instead of a string.

**Both `test(...)` calls must be written directly in this file**, not moved into a shared helper — Playwright reports a failing test's file/line as wherever `test()` is literally called, so hiding it in `block-a11y.js` would make every block's failures misreport as coming from that one shared file.

If the block fetches remote data at runtime, add a `routes` array to the `block` object to mock those requests — copy [`test/a11y/blocks/header.spec.js`](../../../test/a11y/blocks/header.spec.js) for a worked example. Add reusable mock strings to [`test/a11y/mocks.js`](../../../test/a11y/mocks.js); keep one-off mocks inline in the spec file.

The rare block that renders arbitrary passthrough content with no fixed structure to assert against (e.g. `fragment`) can be exempted instead of given a spec file — see the `EXCLUDED` set in `test/a11y/coverage.spec.js`. This should be an explicit, justified exception, not a default.

For full guidance on route mocking, template-specific requirements (`setConfig`), and running a single block's tests (forward slashes only, even on Windows), see the **Accessibility tests** section in [`AGENTS.md`](../../../AGENTS.md).
39 changes: 39 additions & 0 deletions .github/workflows/a11y.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Accessibility

on:
pull_request:
branches: [main]

concurrency:
group: a11y-${{ github.ref }}
cancel-in-progress: true

jobs:
a11y:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6

- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 20
Comment on lines +20 to +21

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with:
node-version: 20
with:
node-version: 24

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node 20 actions are deprecated so all of the workflows might need updating.

cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox webkit

- name: Run accessibility tests
run: npm run test:a11y

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ helix-importer-ui
.wrangler
.claude
.cursor
playwright-report/
test-results/


.superpowers/
Expand Down
80 changes: 80 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,86 @@ When a task matches one of the following, read and apply the corresponding rule
| Drafting a PR description | [`.ai/rules/pr-descriptions.md`](./.ai/rules/pr-descriptions.md) |
| Drafting a Jira ticket or GitHub issue | [`.ai/rules/issue-ticket.md`](./.ai/rules/issue-ticket.md) |

## Accessibility tests

The project runs axe-core WCAG 2.2 AA scans plus a Playwright `toMatchAriaSnapshot()` accessibility-tree check against every block, template, and shared custom element. Tests live in `test/a11y/` and run on every PR via `.github/workflows/a11y.yml`.

### When you add a block or template

Each block gets its own pair of files — no shared registry to edit.

1. Create an HTML fixture in `test/a11y/fixtures/<name>.html` that initializes the block in isolation. Fixtures are served locally by `aem up` (falls back to a static file when one exists at the requested path) — load block CSS and JS directly with `<link>` and `<script type="module">`.
2. Create `test/a11y/blocks/<name>.spec.js`. Copy an existing file (e.g. [`test/a11y/blocks/card.spec.js`](./test/a11y/blocks/card.spec.js) for the simple case, or [`test/a11y/blocks/header.spec.js`](./test/a11y/blocks/header.spec.js) for one with mocked routes) as a template: define a `block` object (`name`, `path`, `readySelector`, optionally `routes`/`disableRules`/`ariaRoot`), then the light-mode axe test, the accessibility-tree snapshot test, and the dark-mode axe test, in that order, using `test`/`expect` from `../axe-test.js` and `gotoBlock`/`formatViolations` from `../block-a11y.js`.

`readySelector` is a CSS selector that appears in the DOM once the block has finished initializing (or `{ selector, state: 'detached' }` for a block that removes itself from the DOM on init).

For blocks that fetch remote data at runtime (header, footer, sitenav, schedule, playground, profile, component-status, page-hero, status-table, search, youtube), add a `routes` array to the `block` object. Each route intercepts a network request with `page.route()` and returns mock HTML or JSON so the test runs without a live server. Add reusable mock strings to [`test/a11y/mocks.js`](./test/a11y/mocks.js) and import them; keep one-off mocks inline in the spec file.

For templates, call `setConfig({ components: [], hostnames: [], linkBlocks: [] })` before `init()` in the fixture script — templates use `loadBlock()` internally, which requires `components` to be defined.

Some blocks render arbitrary, CMS-authored content passed through verbatim (e.g. `fragment`) rather than a fixed structure — an a11y scan of a canned mock wouldn't test anything real. These are explicitly excluded via the `EXCLUDED` set in [`test/a11y/coverage.spec.js`](./test/a11y/coverage.spec.js) rather than given a spec file. A block whose render output isn't deterministic yet (a known bug), or that removes its own root from the DOM on init, should skip the accessibility-tree snapshot test specifically (with a comment explaining why — see `schedule.spec.js`/`section-metadata.spec.js`) while keeping its axe tests.

Playwright's file/line attribution follows wherever `test()` is actually called — so the light/dark `test(...)` calls must live directly in `test/a11y/blocks/<name>.spec.js`, not inside a shared helper function, or failures will misreport as coming from `block-a11y.js`.

#### Accessibility-tree snapshot test

Alongside the axe scans, each block spec has one more test asserting the block's accessible tree matches a known-good shape — this catches semantic regressions (a heading demoted to a `div`, a landmark losing its name, a role getting clobbered) that axe's rule-based scan won't flag as long as no WCAG rule is technically violated. Pattern (modeled on the one already in use in the sibling `spectrum-web-components` repo):

```js
test(`${block.name} block matches its expected accessibility tree`, async ({ page }, testInfo) => {
// Mobile Chrome also runs on the Chromium engine, so `browserName` alone can't isolate a
// single run — check the project by name to actually run this once, not twice.
test.skip(testInfo.project.name !== 'chromium', 'ARIA tree is browser/viewport-agnostic; only the chromium project needs to run it');

await gotoBlock(page, block);

await expect(page.locator(block.ariaRoot ?? `.${block.name}`)).toMatchAriaSnapshot(`
- ...
`);
});
```

- **One test, not a light/dark pair** — tree structure doesn't vary by color scheme.
- **Gate to the `chromium` project by name, not the `browserName` fixture.** `Mobile Chrome` also runs on the Chromium engine, so `browserName !== 'chromium'` alone lets it slip through as a redundant second run.
- **`ariaRoot`** is an optional field on the `block` object for the block's root locator selector; it defaults to `` `.${block.name}` ``. Set it explicitly when the block's root isn't that class — e.g. it replaces itself with a custom element (`profile` → `se-profile`, `search` → `sh-search`, `youtube` → `.video`) or uses an id instead of a class (`sitenav` → `#sitenav`).
- **Generate/update the snapshot** with `npx playwright test test/a11y/blocks/<name>.spec.js --project=chromium -g "accessibility tree" --update-snapshots`. This writes a `test-results/rebaselines.patch` rather than patching the spec file directly — review it, then `git apply test-results/rebaselines.patch` and delete `test-results/`.
- Review the generated tree like any other diff, and treat it as living documentation: update the snapshot when a tree change is intentional, fix the block when it isn't.

### When you change a block or template

| What changed | What to update |
| --- | --- |
| A WCAG violation is introduced | Fix the accessibility issue in the block |
| The init-produced DOM structure changes | Update `readySelector` in `test/a11y/blocks/<name>.spec.js`, and regenerate the accessibility-tree snapshot if the change was intentional |
| A fetch URL or response format changes | Update the `routes` mock in `test/a11y/blocks/<name>.spec.js` (and/or `test/a11y/mocks.js`) |

### File locations

| What | Path |
| --- | --- |
| Shared AxeBuilder fixture (`test`/`expect`/`makeAxeBuilder`) | `test/a11y/axe-test.js` |
| Shared per-block test utilities (`gotoBlock`, `formatViolations`) | `test/a11y/block-a11y.js` |
| Shared mock HTTP responses | `test/a11y/mocks.js` |
| Per-block spec files (one per block/template) | `test/a11y/blocks/<name>.spec.js` |
| Per-custom-element spec files (one per `deps/se/se.js` element) | `test/a11y/custom-components/<name>.spec.js` |
| Coverage check (fails if a block under `blocks/`, or a custom element in `deps/se/se.js`, has no spec file) | `test/a11y/coverage.spec.js` |
| HTML fixtures (one per block/template, or per custom element under `fixtures/custom-components/`) | `test/a11y/fixtures/` |
| GitHub Actions workflow | `.github/workflows/a11y.yml` |

### Shared custom elements (`deps/se/se.js`)

`deps/se/se.js` registers a handful of shared form/UI web components (`se-button`, `se-input`, `se-textarea`, `se-checkbox`, `se-switch`, `se-select`, `se-segmentedcontrol`, `se-dialog`) used across multiple blocks. They get the same three-test treatment as blocks, in a parallel `test/a11y/custom-components/` suite rather than `test/a11y/blocks/`, since they aren't blocks themselves — testing them directly (rather than relying on incidental coverage from whichever block happens to use them) catches gaps a block fixture wouldn't reach, and covers states (disabled, error, checked) a single block's usage might not exercise.

Convention: one fixture (`test/a11y/fixtures/custom-components/<name>.html`) importing `/deps/se/se.js` directly (a side-effect import registers all elements at once) and authoring the element's meaningful states side-by-side (e.g. `se-input`: default, `type="search"`, error) inside a `<div class="test-container">` wrapper; one spec (`test/a11y/custom-components/<name>.spec.js`) with `ariaRoot: '.test-container'`, otherwise identical in shape to a block spec. `test/a11y/coverage.spec.js` parses `customElements.define(...)` calls out of `deps/se/se.js` and fails if a registered element has no matching spec file, so a newly added element can't ship untested.

### Running a single block's tests

Playwright's CLI treats file-path arguments as regexes matched against forward-slash paths — always use forward slashes, even in PowerShell on Windows, or the match silently fails:

```bash
npx playwright test test/a11y/blocks/card.spec.js
```

## IDE-specific folders

Some editors load extra project config from their own directories (for example `.cursor/` and `.claude/`). Those locations are thin adapters that symlink back to `.ai/`. **`.ai/` remains the portable source of truth** for rules and skills documented here. If instructions conflict, prefer **`.ai/README.md`** and the files under **`.ai/rules/`** and **`.ai/skills/`**.
Loading
Loading