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
72 changes: 72 additions & 0 deletions .github/workflows/voiceover.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: VoiceOver evidence
on:
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/voiceover.yml'
- 'playwright.voiceover.config.js'
- 'tests/voiceover/**'
permissions:
contents: read
concurrency:
group: voiceover-${{ github.ref }}
cancel-in-progress: true
jobs:
voiceover:
runs-on: macos-26
timeout-minutes: 100
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
cache: npm
- name: Record runner before setup
run: |
mkdir -p test-results/voiceover-environment
sw_vers > test-results/voiceover-environment/runner.txt
echo "Runner image: $ImageOS $ImageVersion" >> test-results/voiceover-environment/runner.txt
say -v '?' > test-results/voiceover-environment/voices.txt
# VoiceOver moves the pointer; a hot corner opened Quick Note mid-journey in run 37843643548.
- name: Disable hot corners
run: |
for corner in tl tr bl br; do defaults write com.apple.dock "wvous-$corner-corner" -int 0; done
killall Dock
# Setup recording stalled on this image; speech capture does not need ffmpeg.
- name: Configure Guidepup
timeout-minutes: 5
shell: bash
run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.1 setup --ci 2>&1 | tee test-results/voiceover-environment/setup.log
- run: npm ci --no-audit --no-fund --no-progress
- run: npx playwright install webkit
- name: Install Guidepup assets
timeout-minutes: 5
shell: bash
run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.1 install 2>&1 | tee test-results/voiceover-environment/install.log
- name: Record environment
run: |
mkdir -p test-results/voiceover-environment
{
sw_vers
echo "Runner image: $ImageOS $ImageVersion"
node --version
npm --version
npx playwright --version
npm ls @guidepup/guidepup
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' /System/Library/CoreServices/VoiceOver.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' /System/Library/CoreServices/VoiceOver.app/Contents/Info.plist
} > test-results/voiceover-environment/versions.txt
say -v '?' > test-results/voiceover-environment/voices.txt
- run: npm run demo
- name: Positive control, required before journeys
run: npm run test:voiceover -- --grep 'positive control'
env:
VO_PHASE: control
- name: Record the eight patterns in English and French
run: npm run test:voiceover -- --grep-invert 'positive control'
- uses: actions/upload-artifact@v4
if: always()
with:
name: voiceover-${{ github.run_id }}-${{ github.run_attempt }}
path: test-results/
retention-days: 14
25 changes: 25 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Third-party notices

The web-content entry routine in `tests/voiceover/session.js` adapts the [Guidepup Playwright fixture](https://github.com/guidepup/guidepup/blob/d5c9d8059954f214b82689dffcb5866a9b55fe6c/examples/playwright-voiceover/voiceover-test.ts). Its licence follows.

MIT License

Copyright (c) 2023 Craig Morten

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
73 changes: 72 additions & 1 deletion package-lock.json

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

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
"demo": "node scripts/build-demo.mjs",
"test:browser": "playwright test --workers=2",
"preview:deploy": "node scripts/check-deploy-account.mjs && npm run demo && npx wrangler@4 deploy",
"icons": "node scripts/build-icons.mjs"
"icons": "node scripts/build-icons.mjs",
"test:voiceover": "playwright test --config=playwright.voiceover.config.js"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@fontsource/source-sans-3": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@playwright/test": "^1.56.0",
"@guidepup/guidepup": "0.35.0",
"@playwright/test": "1.63.0",
"@tabler/icons": "^3.49.0",
"typescript": "^5.9.3"
}
Expand Down
14 changes: 14 additions & 0 deletions playwright.voiceover.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test';

const port = Number(process.env.LP_PORT ?? 4187);
export default defineConfig({
testDir: './tests/voiceover',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 20 * 60 * 1000,
outputDir: `test-results/voiceover-${process.env.VO_PHASE ?? 'journeys'}`,
reporter: [['list']],
use: { browserName: 'webkit', headless: false, baseURL: `http://127.0.0.1:${port}`, viewport: { width: 1280, height: 900 }, screenshot: 'only-on-failure' },
webServer: { command: 'node scripts/serve-demo.mjs', url: `http://127.0.0.1:${port}/index.html`, reuseExistingServer: false, env: { LP_PORT: String(port) } }
});
22 changes: 22 additions & 0 deletions tests/voiceover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Hosted VoiceOver evidence

The separate VoiceOver workflow runs on GitHub-hosted macOS 26 with headed Playwright WebKit and one worker. It runs only when its workflow, config or tests change in a pull request, or when dispatched manually. It builds the standard demo with `npm run demo`. The existing browser checks remain separate.

Guidepup 0.35.0, its setup tool 0.29.1 and Playwright 1.63.0 are pinned. Web-content entry follows [Guidepup's MIT fixture](https://github.com/guidepup/guidepup/blob/d5c9d8059954f214b82689dffcb5866a9b55fe6c/examples/playwright-voiceover/voiceover-test.ts). This tests Playwright WebKit, not installed Safari.

The positive control must capture the heading, button name and role, and one live-message occurrence before any pattern journey runs. It also checks native text entry. Each English and French journey records native VoiceOver keyboard actions, spoken phrases, cursor text, DOM focus and status mutations. Tab searches fall back to VO navigation where WebKit skips buttons. The artifacts include per-step JSON, phrase logs, HTML, accessibility snapshots and screenshots. Journey errors are recorded without failing CI, so a green job means the control passed and evidence was collected, not that the pages passed an accessibility review.

Navigation uses Guidepup's initial capture; result actions use full capture. Initial capture can miss later phrases in a long utterance. Guidepup polls VoiceOver's last phrase and combines changed phrases; consecutive identical announcements may not appear separately. Interpret announcement counts with those limits. Installed voices and portable preferences are recorded, but phrase text cannot prove French pronunciation.

Before native keys or text entry, System Events brings the Playwright process to the foreground and checks its name. Keys then go directly to the foreground application. This avoids repeated application activation through AppleScript, which timed out during native text entry on the hosted runner. Highlight uses the current inline span buttons through Tab and Space.

Run on a dedicated macOS desktop configured for Guidepup:

```sh
npm ci
npm run demo
VO_PHASE=control npm run test:voiceover -- --grep 'positive control'
npm run test:voiceover -- --grep-invert 'positive control'
```

Read the artifacts against each pattern's README before assigning a page verdict. A navigation error needs investigation rather than an automatic page failure.
24 changes: 24 additions & 0 deletions tests/voiceover/control.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
import { startSession } from './session.js';

test('positive control captures heading, button name and role, and one live message', async ({ page }, info) => {
await page.goto('/index.html');
await page.setContent('<!doctype html><html lang="en"><title>VoiceOver positive control</title><h1>Control heading</h1><button onclick="document.getElementById(\'status\').textContent=\'Control live message\'">Control action</button><div id="status" role="status" aria-live="polite"></div><label>Control entry<input></label></html>');
const session = await startSession(page, info);
try {
await session.observe(); await session.enter();
await session.next('Control button name and role', true);
const arrival = session.rows.flatMap(row => row.speech).join('\n');
expect(arrival).toContain('Control heading');
expect(arrival).toMatch(/Control action.*button|button.*Control action/i);
await session.activate('button', 'Activate control button', 'Control+Alt+Space');
await session.snapshot('control-result');
const activated = session.rows.filter(row => row.step === 'Activate control button').flatMap(row => row.speech).join('\n');
expect(activated.split('Control live message').length - 1).toBe(1);
expect(await page.locator('#status').textContent()).toBe('Control live message');
await session.seek('input', 'Control entry');
await session.type('Control input', 'Type control text');
expect(await page.locator('input').inputValue()).toBe('Control input');
await session.snapshot('control-input');
} finally { await session.stop(); }
});
Loading
Loading