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
126 changes: 126 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Continuous integration for Able Player
#
# Runs ESLint and both Jest projects (jsdom + puppeteer) on every push and
# pull request. The puppeteer project runs headless with request
# interception, so no demo server is needed. Build artifacts are compiled
# to confirm Grunt + Rollup succeed, but are never committed (per
# contributing.md).
#
# The accessibility job runs axe-core (WCAG 2.1 A/AA rules) plus keyboard
# operability checks over a representative demo set at four viewports
# (320px -> 4K). Automated checks catch only a fraction of WCAG failures —
# a green run prevents a class of regressions; it is not a conformance claim.
name: CI

on:
push:
branches: [main, develop]
pull_request:

permissions:
contents: read

# Superseded runs on the same ref are cancelled, so a force-push to an open
# pull request does not leave stale jobs occupying the queue.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Only the puppeteer job launches a browser; skip puppeteer's Chromium
# download elsewhere (setup-node's npm cache does not cover it).
- run: npm ci
env:
PUPPETEER_SKIP_DOWNLOAD: "1"
- run: npm run lint

test:
name: Jest (jsdom)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
env:
PUPPETEER_SKIP_DOWNLOAD: "1"
# webvtt.test.cjs loads build/test/webvtt.umd.js, so the suite must run
# against a fresh build rather than the committed bundle — otherwise a
# change to the WebVTT source is never actually exercised.
- run: npm run build
- run: npx jest --selectProjects jsdom

test-browser:
name: Jest (puppeteer, headless)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# validate.test.cjs loads build/test/validate.umd.js
- run: npm run build
- run: npx jest --selectProjects puppeteer

build:
name: Build (Grunt + Rollup)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
env:
PUPPETEER_SKIP_DOWNLOAD: "1"
- run: npm run build
- name: Confirm build outputs exist
run: |
test -s build/ableplayer.js
test -s build/ableplayer.min.js
test -s build/ableplayer.esm.js
test -s build/ableplayer.min.css

a11y:
name: Accessibility (axe + keyboard, 320px-4K)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# The demo pages load ../build/ableplayer.dist.js + ableplayer.min.css,
# so the suite always scans a fresh build, never a stale committed one.
- run: npm run build
- run: npx playwright install --with-deps chromium
# e2e/serve.mjs serves the repo root so the demos' relative ../build and
# ../media references resolve; Playwright boots it via playwright.config.js.
- run: npm run test:a11y
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: a11y-playwright-report
path: playwright-report/
retention-days: 14
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ npm-debug.log
.htaccess
docs
jsdoc.json
playwright-report/
test-results/
2 changes: 1 addition & 1 deletion contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Able Player uses [Jest][] to run automated tests. Tests are found in `scripts/__
npm test
```

Because Able Player doesn't configure its own local environment, you can run it in any local setup; but you may need to adjust the test runner's target URL for tests requiring a local URL. The default is `http://localhost:8000`.
The suite runs headless and needs no local server: the browser-based tests intercept their own navigation. To watch the browser while debugging, run `HEADFUL=1 npm test`.

Please run the test suite against your changes to ensure there are no unexpected changes.

Expand Down
75 changes: 75 additions & 0 deletions e2e/a11y.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

/**
* Automated WCAG 2.1 A/AA scans (axe-core) over a representative set of the
* demo pages, after Able Player has fully initialized.
*
* Scope and honesty:
* - axe-core automates only a fraction of WCAG success criteria. A green run
* here means "no violations axe can detect on these pages" — it does NOT
* mean the player or the demos are accessible or conformant. Manual testing
* with assistive technology remains essential.
* - Each page runs at four viewports (see playwright.config.js): the same
* markup can pass at 1280px and fail at 320px or 4K, so every page is
* scanned at all four rather than at one representative size.
* - Violations inside a YouTube or Vimeo document belong to the provider, so
* those subtrees are excluded by policy (axe can reach into frames; we
* choose not to act on findings we cannot fix). The scan judges the player
* chrome and page shell we control.
* - Note on the YouTube page: on develop its embed does not currently
* initialize (initSignLanguage throws when the media element has no
* <source> children), so today that entry scans the surrounding page shell
* rather than a live YouTube player.
*/

// Representative demo set: one page per major feature family.
const PAGES = [
{ path: "/demos/index.html", name: "demo index", player: false },
{ path: "/demos/video1.html", name: "video + captions", player: true },
{ path: "/demos/video5.html", name: "video + sign language + descriptions", player: true },
{ path: "/demos/audio1.html", name: "audio player", player: true },
{ path: "/demos/audio3.html", name: "audio + interactive transcript", player: true },
{ path: "/demos/desc1.html", name: "video + audio description", player: true },
{ path: "/demos/youtube1.html", name: "YouTube page shell", player: true, thirdPartyIframe: true },
];

for (const demo of PAGES) {
test(`${demo.name} (${demo.path}) — axe WCAG 2.1 A/AA scan`, async ({ page }) => {
const response = await page.goto(demo.path);
expect(response?.ok(), `expected ${demo.path} to serve 200`).toBeTruthy();

if (demo.player) {
// Able Player rebuilds the media element into its accessible UI on
// DOM ready; scanning before that would audit the wrong DOM.
await page.waitForSelector(".able-wrapper", { timeout: 20_000 });
// Let the controller finish its first layout pass (icons, tooltips).
await page.waitForSelector(".able-controller", { timeout: 20_000 });
}

let builder = new AxeBuilder({ page }).withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
]);

// Policy exclusion, not a technical limit: axe does inject into frames,
// but violations inside a YouTube or Vimeo document are the provider's to
// fix, and letting them fail this job would make the gate unactionable.
// Trade-off worth knowing: excluding the element also drops frame-title
// (SC 4.1.2) on the embed itself, which IS ours — worth a dedicated
// assertion if the embed path is ever covered directly.
if (demo.thirdPartyIframe) {
builder = builder.exclude("iframe");
}

const results = await builder.analyze();

// Compact, reviewable failure output: rule id, impact, node count.
const summary = results.violations.map(
(v) => `${v.id} (${v.impact}): ${v.nodes.length} node(s) — ${v.help}`,
);
expect(summary, `axe violations on ${demo.path}`).toEqual([]);
});
}
120 changes: 120 additions & 0 deletions e2e/keyboard.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { test, expect } from "@playwright/test";

/**
* Keyboard operability checks for the initialized player — the half of
* accessibility testing axe cannot do. axe inspects the static accessibility
* tree; it cannot prove the player is reachable by Tab, that play/pause is
* operable with a keyboard, or that focus stays visible.
*
* Deliberately small scope for a first suite: reachability, operability of
* the primary control, and a visible focus indicator. It does not attempt to
* cover Able Player's full keyboard model (modifier hotkeys, seekbar arrows,
* preference dialogs) — those deserve dedicated specs over time.
*
* Runs at all four viewports (playwright.config.js): a control that falls out
* of the tab order or loses its focus ring at one breakpoint still fails.
*/

const DEMO = "/demos/video1.html";

/** Read the active element's identity synchronously (never auto-waits). */
async function activeInfo(page) {
return page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return { tag: "body", cls: "", label: "" };
return {
tag: el.tagName.toLowerCase(),
cls: el.className || "",
label: el.getAttribute("aria-label") || (el.textContent || "").trim(),
};
});
}

/** True when the focused element shows a rendered outline or box-shadow ring. */
async function focusedHasVisibleRing(page) {
return page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return false;
const style = getComputedStyle(el);
const outlineVisible =
style.outlineStyle !== "none" &&
parseFloat(style.outlineWidth) > 0 &&
// A fully transparent outline is not a visible indicator. Able Player's
// own :focus rule paints a solid var(--able-focus-outline), so this
// guards against a theme that overrides the color to transparent.
!/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/.test(style.outlineColor) &&
style.outlineColor !== "transparent";
const shadowVisible = style.boxShadow !== "none" && style.boxShadow !== "";
return outlineVisible || shadowVisible;
});
}

test.beforeEach(async ({ page }) => {
await page.goto(DEMO);
await page.waitForSelector(".able-controller", { timeout: 20_000 });
});

test("player controls are reachable by Tab from the top of the document", async ({ page }) => {
// Walk the tab ring from the document start. The page has a small nav
// (2 links) before the player; 40 stops is a generous ceiling that still
// fails fast if the player is unreachable.
let reachedPlayer = false;
for (let i = 0; i < 40; i++) {
await page.keyboard.press("Tab");
const info = await activeInfo(page);
if (info.tag === "body") break; // wrapped: end of ring, player never seen
if (/able-/.test(info.cls) || (await page.evaluate(() =>
Boolean(document.activeElement?.closest(".able-wrapper")),
))) {
reachedPlayer = true;
break;
}
}
expect(reachedPlayer, "tabbing must reach the Able Player UI").toBe(true);
});

test("play/pause is keyboard-operable and announces its state", async ({ page }) => {
const playButton = page.locator(".able-button-handler-play").first();
await playButton.focus();

const before = await playButton.getAttribute("aria-label");
expect(before, "play/pause control must have an accessible name").toBeTruthy();

await page.keyboard.press("Enter");

// Media playback must actually start (paused flips false) — the control is
// operable, not merely focusable.
await expect
.poll(async () => page.evaluate(() => document.querySelector("video, audio")?.paused), {
timeout: 10_000,
message: "pressing Enter on the play control must start playback",
})
.toBe(false);

// And the accessible name must flip to reflect the new state (Play → Pause
// family — exact string is locale/config dependent, so assert change only).
await expect
.poll(async () => playButton.getAttribute("aria-label"), {
timeout: 10_000,
message: "the control's accessible name must update after activation",
})
.not.toBe(before);

// Enter again pauses — round trip proves both directions are operable.
await page.keyboard.press("Enter");
await expect
.poll(async () => page.evaluate(() => document.querySelector("video, audio")?.paused), {
timeout: 10_000,
message: "pressing Enter again must pause playback",
})
.toBe(true);
});

test("focused player controls show a visible focus indicator", async ({ page }) => {
const playButton = page.locator(".able-button-handler-play").first();
await playButton.focus();
expect(
await focusedHasVisibleRing(page),
"the focused play control must render a visible focus ring",
).toBe(true);
});
Loading
Loading