From cc5e7527c1887471479afe80dc9c77fe0461f627 Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 19:30:17 +0100 Subject: [PATCH 1/6] feat: orca support --- .github/workflows/codeql-analysis.yml | 8 +- .github/workflows/publish.yml | 4 +- .github/workflows/test-nvda.yml | 10 +- .github/workflows/test-orca.yml | 37 ++ .github/workflows/test-screenreader.yml | 40 +- .github/workflows/test-voiceover.yml | 12 +- .github/workflows/test.yml | 4 +- README.md | 153 ++++++-- examples/playwright-nvda/chromium.config.ts | 2 +- examples/playwright-nvda/firefox.config.ts | 2 +- .../tests/chromium/chromium.spec.ts | 112 +++++- .../chromium.spokenPhrase.docs.snapshot.json | 10 + .../chromium.spokenPhrase.snapshot.json | 7 - ...romium.spokenPhrase.textarea.snapshot.json | 1 + .../tests/firefox/firefox.spec.ts | 21 +- .../firefox.spokenPhrase.snapshot.json | 5 +- .../playwright-nvda/tests/headerNavigation.ts | 28 +- .../chromium.config.ts | 2 +- .../chromium/chromium-live-region.spec.ts | 111 ++++-- .../tests/chromium/chromium.spec.ts | 37 +- .../tests/firefox/firefox-live-region.spec.ts | 331 ++++++++++++++++ .../tests/firefox/firefox-orca.spec.ts | 105 +++++ .../firefox.spokenPhrase.snapshot.json | 1 + .../playwright-voiceover/chromium.config.ts | 2 +- .../playwright-voiceover/firefox.config.ts | 2 +- .../chromium/chromium.itemText.snapshot.json | 5 +- .../tests/chromium/chromium.spec.ts | 19 +- .../chromium.spokenPhrase.snapshot.json | 11 +- .../firefox/firefox.itemText.snapshot.json | 5 +- .../tests/firefox/firefox.spec.ts | 29 +- .../firefox.spokenPhrase.snapshot.json | 11 +- .../tests/headerNavigation.ts | 41 +- .../webkit/webkit.itemText.docs.snapshot.json | 10 + .../webkit/webkit.itemText.snapshot.json | 7 - .../tests/webkit/webkit.spec.ts | 105 ++++- .../webkit.spokenPhrase.docs.snapshot.json | 10 + .../webkit/webkit.spokenPhrase.snapshot.json | 7 - ...webkit.spokenPhrase.textarea.snapshot.json | 1 + .../playwright-voiceover/webkit.config.ts | 2 +- package.json | 11 +- src/applicationIdMap.ts | 10 + src/cleanString.ts | 6 + src/index.ts | 2 + src/navigateNVDAtoWebContent.ts | 142 +++++++ src/navigateOrcaToWebContent.ts | 85 +++++ src/navigateVoiceOverToWebContent.ts | 161 ++++++++ src/nvdaTest.ts | 153 +------- src/orcaTest.ts | 107 ++++++ src/screenReaderTest.ts | 358 ++---------------- src/voiceOverTest.ts | 193 +--------- yarn.lock | 43 ++- 51 files changed, 1740 insertions(+), 841 deletions(-) create mode 100644 .github/workflows/test-orca.yml create mode 100644 examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.docs.snapshot.json delete mode 100644 examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.snapshot.json create mode 100644 examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.textarea.snapshot.json create mode 100644 examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts create mode 100644 examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts create mode 100644 examples/playwright-screenreader/tests/firefox/firefox.spokenPhrase.snapshot.json create mode 100644 examples/playwright-voiceover/tests/webkit/webkit.itemText.docs.snapshot.json delete mode 100644 examples/playwright-voiceover/tests/webkit/webkit.itemText.snapshot.json create mode 100644 examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.docs.snapshot.json delete mode 100644 examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.snapshot.json create mode 100644 examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.textarea.snapshot.json create mode 100644 src/applicationIdMap.ts create mode 100644 src/cleanString.ts create mode 100644 src/navigateNVDAtoWebContent.ts create mode 100644 src/navigateOrcaToWebContent.ts create mode 100644 src/navigateVoiceOverToWebContent.ts create mode 100644 src/orcaTest.ts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 113171b..86be33a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,11 +36,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -51,7 +51,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # โ„น๏ธ Command-line programs to run using the OS shell. # ๐Ÿ“š https://git.io/JvXDl @@ -65,5 +65,5 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 continue-on-error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 974ccef..56ba73d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,8 +12,8 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version-file: .nvmrc registry-url: https://registry.npmjs.org diff --git a/.github/workflows/test-nvda.yml b/.github/workflows/test-nvda.yml index 7a6535d..a6e9510 100644 --- a/.github/workflows/test-nvda.yml +++ b/.github/workflows/test-nvda.yml @@ -18,16 +18,16 @@ jobs: os: [windows-2022, windows-2025] browser: [chromium, firefox] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version-file: .nvmrc - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 setup --ci --macos-record + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 setup --ci - run: yarn install --frozen-lockfile - run: yarn pretest - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 install + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install - run: yarn test:nvda:${{ matrix.browser }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/test-orca.yml b/.github/workflows/test-orca.yml new file mode 100644 index 0000000..4a3bd53 --- /dev/null +++ b/.github/workflows/test-orca.yml @@ -0,0 +1,37 @@ +name: Test Orca + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test-orca: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-26.04] + browser: [firefox] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 setup --ci + - run: yarn install --frozen-lockfile + - run: yarn pretest + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install + - run: xvfb-run dbus-run-session -- yarn test:orca:${{ matrix.browser }} + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: artifacts-${{ matrix.os }}-${{ matrix.browser }} + path: | + **/test-results/**/* + **/recordings/**/* diff --git a/.github/workflows/test-screenreader.yml b/.github/workflows/test-screenreader.yml index 1d8ceda..63a7ed9 100644 --- a/.github/workflows/test-screenreader.yml +++ b/.github/workflows/test-screenreader.yml @@ -10,24 +10,50 @@ permissions: contents: read jobs: - test-screenreader: + test-screenreader-chromium: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [macos-14, macos-15, macos-26, windows-2022, windows-2025] + os: [macos-15, macos-26, xcode-27, windows-2022, windows-2025] browser: [chromium] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version-file: .nvmrc - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 setup --ci --macos-record + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 setup --ci --macos-record - run: yarn install --frozen-lockfile - run: yarn pretest - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 install + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install - run: yarn test:screenreader:${{ matrix.browser }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: artifacts-${{ matrix.os }}-${{ matrix.browser }} + path: | + **/test-results/**/* + **/recordings/**/* + + test-screenreader-firefox: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-26.04] + browser: [firefox] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 setup --ci + - run: yarn install --frozen-lockfile + - run: yarn pretest + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install + - run: yarn test:screenreader:${{ matrix.browser }} + - uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/test-voiceover.yml b/.github/workflows/test-voiceover.yml index bbceec3..fa426db 100644 --- a/.github/workflows/test-voiceover.yml +++ b/.github/workflows/test-voiceover.yml @@ -15,19 +15,19 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15, macos-26] + os: [macos-15, macos-26, xcode-27] browser: [chromium, firefox, webkit] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version-file: .nvmrc - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 setup --ci --macos-record + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 setup --ci --macos-record - run: yarn install --frozen-lockfile - run: yarn pretest - - run: npx -y --min-release-age=0 @guidepup/setup@0.24.6 install + - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install - run: yarn test:voiceover:${{ matrix.browser }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb3934a..905b446 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,8 +13,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version-file: .nvmrc - run: yarn install --frozen-lockfile diff --git a/README.md b/README.md index 14678e2..e1b4e1c 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,18 @@ ## [Documentation](https://guidepup.dev) | [API Reference](https://www.guidepup.dev/docs/api/class-guidepup) -[![MacOS Sonoma Support](https://img.shields.io/badge/macos-Somona-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sonoma/id6450717509) -[![MacOS Sequoia Support](https://img.shields.io/badge/macos-Sequoia-blue.svg?logo=apple)](https://apps.apple.com/us/app/macos-sequoia/id6596773750) -[![MacOS Tahoe Support](https://img.shields.io/badge/macos-Tahoe-blue.svg?logo=apple)](https://www.apple.com/uk/os/macos/) +[![macOS Sonoma Support](https://img.shields.io/badge/macos-Sonoma-blue.svg?logo=apple)](https://support.apple.com/en-gb/102662) +[![macOS Sequoia Support](https://img.shields.io/badge/macos-Sequoia-blue.svg?logo=apple)](https://support.apple.com/en-gb/102662) +[![macOS Tahoe Support](https://img.shields.io/badge/macos-Tahoe-blue.svg?logo=apple)](https://support.apple.com/en-gb/102662) +[![macOS Golden Gate Support](https://img.shields.io/badge/macos-Golden%20Gate-blue.svg?logo=apple)](https://support.apple.com/en-gb/127455) [![Windows Server 2022 Support](https://img.shields.io/badge/windows_server-2022-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022) [![Windows Server 2025 Support](https://img.shields.io/badge/windows_server-2025-blue.svg?logo=windows)](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2025) +[![Ubuntu Resolute Support](https://img.shields.io/badge/ubuntu-26.04%2B-blue.svg?logo=ubuntu)](https://releases.ubuntu.com/resolute/) +[![Debian Trixie Support](https://img.shields.io/badge/debian-13%2B-blue.svg?logo=debian)](https://www.debian.org/releases/trixie/) Guidepup is a screen reader automation library for testing. -This package provides [Guidepup](https://github.com/guidepup/guidepup) integration with [Playwright](https://playwright.dev/) to enable testing with VoiceOver on MacOS and NVDA on Windows. +This package provides [Guidepup](https://github.com/guidepup/guidepup) integration with [Playwright](https://playwright.dev/) to enable testing with NVDA on Windows, Orca on Linux, and VoiceOver on macOS. ## Capabilities @@ -78,7 +81,7 @@ Check out the configuration this adds [in the `config.ts` file](./src/config.ts) ### Web content navigation -In addition to the Guidepup APIs the `screenReader`, `voiceOver`, and `nvda` instances provided by the Guidepup Playwright setup have an additional utility method `.navigateToWebContent()`. +In addition to the Guidepup APIs the `screenReader`, `nvda`, `orca`, and `voiceOver` instances provided by the Guidepup Playwright setup have an additional utility method `.navigateToWebContent()`. This method will navigate the screen reader to the first element of the document body in the browser. @@ -101,7 +104,7 @@ await screenReader.navigateToWebContent(); ### Providing screen reader start options -The options provided to `screenReader.start([options])`, `nvda.start([options])`, or `voiceOver.start([options])` can be configured using `test.use(config)` as follows: +The options provided to `screenReader.start([options])`, `nvda.start([options])`, `orca.start([options])`, or `voiceOver.start([options])` can be configured using `test.use(config)` as follows: ```ts // Screen Reader Example @@ -112,24 +115,32 @@ test.use({ screenReaderStartOptions: { capture: true } }); ``` ```ts -// VoiceOver Example -import { voiceOverTest as test } from "@guidepup/playwright"; +// NVDA Example +import { nvdaTest as test } from "@guidepup/playwright"; // Capture all spoken phrases, including usage hints -test.use({ voiceOverStartOptions: { capture: true } }); +test.use({ nvdaStartOptions: { capture: true } }); ``` ```ts -// NVDA Example -import { nvdaTest as test } from "@guidepup/playwright"; +// Orca Example +import { orcaTest as test } from "@guidepup/playwright"; // Capture all spoken phrases, including usage hints -test.use({ nvdaStartOptions: { capture: true } }); +test.use({ orcaStartOptions: { capture: true } }); ``` -The default for VoiceOver and NVDA is set to `"initial"`. `true` captures all spoken phrases, including usage hints. `false` disables spoken phrase capture. +```ts +// VoiceOver Example +import { voiceOverTest as test } from "@guidepup/playwright"; -### VoiceOver example +// Capture all spoken phrases, including usage hints +test.use({ voiceOverStartOptions: { capture: true } }); +``` + +The default for NVDA, Orca, and VoiceOver is set to `"initial"`. `true` captures all spoken phrases, including usage hints. `false` disables spoken phrase capture. + +### NVDA example `playwright.config.ts`: @@ -144,10 +155,10 @@ const config: PlaywrightTestConfig = { retries: 2, projects: [ { - name: "webkit", + name: "chrome", // Take care to ensure all usage is headed - screen readers cannot // operate against headless browsers. - use: { ...devices["Desktop Safari"], headless: false }, + use: { ...devices["Desktop Chrome"], headless: false }, }, ], }; @@ -155,41 +166,43 @@ const config: PlaywrightTestConfig = { export default config; ``` -`voiceOver.spec.ts`: +`nvda.spec.ts`: ```ts -import { voiceOverTest as test } from "@guidepup/playwright"; +import { nvdaTest as test } from "@guidepup/playwright"; import { expect } from "@playwright/test"; -test.describe("Playwright VoiceOver", () => { - test("I can navigate the Guidepup Github page with VoiceOver", async ({ +test.describe("Playwright NVDA", () => { + test("I can navigate the Guidepup Github page with NVDA", async ({ page, - voiceOver, + nvda, }) => { // Navigate to Guidepup GitHub page await page.goto("https://github.com/guidepup/guidepup", { waitUntil: "load", }); - // Wait for page to be ready + // Wait for page to be ready and setup const header = page.locator('header[role="banner"]'); await header.waitFor(); // Interact with the page - await voiceOver.navigateToWebContent(); + await nvda.navigateToWebContent(); - // Move across the page menu to the Guidepup heading using VoiceOver - while ((await voiceOver.itemText()) !== "Guidepup heading level 1") { - await voiceOver.nextHeading(); + // Move across the page menu to the Guidepup heading using NVDA + while ( + !(await nvda.lastSpokenPhrase()).includes("Guidepup, heading, level 1") + ) { + await nvda.nextHeading(); } // Assert that the spoken phrases are as expected - expect(JSON.stringify(await voiceOver.spokenPhraseLog())).toMatchSnapshot(); + expect(JSON.stringify(await nvda.spokenPhraseLog())).toMatchSnapshot(); }); }); ``` -### NVDA example +### Orca example `playwright.config.ts`: @@ -215,16 +228,16 @@ const config: PlaywrightTestConfig = { export default config; ``` -`nvda.spec.ts`: +`orca.spec.ts`: ```ts -import { nvdaTest as test } from "@guidepup/playwright"; +import { orcaTest as test } from "@guidepup/playwright"; import { expect } from "@playwright/test"; -test.describe("Playwright NVDA", () => { - test("I can navigate the Guidepup Github page with NVDA", async ({ +test.describe("Playwright Orca", () => { + test("I can navigate the Guidepup Github page with Orca", async ({ page, - nvda, + orca, }) => { // Navigate to Guidepup GitHub page await page.goto("https://github.com/guidepup/guidepup", { @@ -236,17 +249,77 @@ test.describe("Playwright NVDA", () => { await header.waitFor(); // Interact with the page - await nvda.navigateToWebContent(); + await orca.navigateToWebContent(); - // Move across the page menu to the Guidepup heading using NVDA + // Move across the page menu to the Guidepup heading using Orca while ( - !(await nvda.lastSpokenPhrase()).includes("Guidepup, heading, level 1") + !(await orca.lastSpokenPhrase()).includes("Guidepup, heading, level 1") ) { - await nvda.nextHeading(); + await orca.nextHeading(); } // Assert that the spoken phrases are as expected - expect(JSON.stringify(await nvda.spokenPhraseLog())).toMatchSnapshot(); + expect(JSON.stringify(await orca.spokenPhraseLog())).toMatchSnapshot(); + }); +}); +``` + +### VoiceOver example + +`playwright.config.ts`: + +```ts +import { devices, PlaywrightTestConfig } from "@playwright/test"; +import { screenReaderConfig } from "@guidepup/playwright"; + +const config: PlaywrightTestConfig = { + ...screenReaderConfig, + reportSlowTests: null, + timeout: 5 * 60 * 1000, + retries: 2, + projects: [ + { + name: "webkit", + // Take care to ensure all usage is headed - screen readers cannot + // operate against headless browsers. + use: { ...devices["Desktop Safari"], headless: false }, + }, + ], +}; + +export default config; +``` + +`voiceOver.spec.ts`: + +```ts +import { voiceOverTest as test } from "@guidepup/playwright"; +import { expect } from "@playwright/test"; + +test.describe("Playwright VoiceOver", () => { + test("I can navigate the Guidepup Github page with VoiceOver", async ({ + page, + voiceOver, + }) => { + // Navigate to Guidepup GitHub page + await page.goto("https://github.com/guidepup/guidepup", { + waitUntil: "load", + }); + + // Wait for page to be ready + const header = page.locator('header[role="banner"]'); + await header.waitFor(); + + // Interact with the page + await voiceOver.navigateToWebContent(); + + // Move across the page menu to the Guidepup heading using VoiceOver + while ((await voiceOver.itemText()) !== "Guidepup heading level 1") { + await voiceOver.nextHeading(); + } + + // Assert that the spoken phrases are as expected + expect(JSON.stringify(await voiceOver.spokenPhraseLog())).toMatchSnapshot(); }); }); ``` @@ -267,14 +340,14 @@ expect(capture.spokenPhrase).toContain("Added to cart"); Check out some of the other Guidepup modules: -- [`@guidepup/guidepup`](https://github.com/guidepup/guidepup/) - Reliable automation for your screen reader a11y workflows through JavaScript supporting VoiceOver and NVDA. - [`@guidepup/setup`](https://github.com/guidepup/setup/) - Set up your local or CI environment for screen reader test automation. +- [`@guidepup/guidepup`](https://github.com/guidepup/guidepup/) - Reliable automation for your screen reader a11y workflows through JavaScript supporting VoiceOver, NVDA, and Orca. - [`@guidepup/virtual-screen-reader`](https://github.com/guidepup/virtual-screen-reader/) - Reliable unit testing for your screen reader a11y workflows. - [`@guidepup/jest`](https://github.com/guidepup/jest/) - Jest matchers for reliable unit testing of your screen reader a11y workflows. ## Resources -- [Documentation](https://www.guidepup.dev/docs/example) +- [Documentation](https://www.guidepup.dev/docs/getting-started/real-world-example) - [API Reference](https://www.guidepup.dev/docs/api/class-guidepup) - [Contributing](.github/CONTRIBUTING.md) - [Changelog](https://github.com/guidepup/guidepup-playwright/releases) diff --git a/examples/playwright-nvda/chromium.config.ts b/examples/playwright-nvda/chromium.config.ts index def99d5..bfbb9af 100644 --- a/examples/playwright-nvda/chromium.config.ts +++ b/examples/playwright-nvda/chromium.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "chromium", diff --git a/examples/playwright-nvda/firefox.config.ts b/examples/playwright-nvda/firefox.config.ts index 92a6e17..40e4349 100644 --- a/examples/playwright-nvda/firefox.config.ts +++ b/examples/playwright-nvda/firefox.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "firefox", diff --git a/examples/playwright-nvda/tests/chromium/chromium.spec.ts b/examples/playwright-nvda/tests/chromium/chromium.spec.ts index fb8f631..63f5187 100644 --- a/examples/playwright-nvda/tests/chromium/chromium.spec.ts +++ b/examples/playwright-nvda/tests/chromium/chromium.spec.ts @@ -1,11 +1,46 @@ import { platform, release } from "os"; import { headerNavigation } from "../headerNavigation"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; -import { windowsRecord } from "@guidepup/record"; -import spokenPhraseSnapshot from "./chromium.spokenPhrase.snapshot.json"; +import spokenPhraseSnapshotDocs from "./chromium.spokenPhrase.docs.snapshot.json"; +import spokenPhraseSnapshotTextarea from "./chromium.spokenPhrase.textarea.snapshot.json"; import { nvdaTest as test } from "../../../../src"; +import { log } from "../../../log"; +import { delay } from "../../../../src/delay"; +import { expect } from "@playwright/test"; -test.use({ nvdaStartOptions: { capture: "initial" } }); +const record = async (filepath: string) => { + try { + const { windowsRecord } = await import("@guidepup/record"); + + return windowsRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.use({ + nvdaStartOptions: { + capture: "initial", + settings: { + speech: { + oneCore: { + voice: + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech_OneCore\\Voices\\Tokens\\MSTTS_V110_enGB_SusanM", + }, + }, + vision: { + NVDAHighlighter: { + highlightFocus: false, + highlightNavigator: false, + highlightBrowseMode: false, + enabled: false, + }, + }, + }, + }, +}); test.describe("Chromium Playwright NVDA", () => { test("I can navigate the Guidepup documentation site", async ({ @@ -32,10 +67,10 @@ test.describe("Chromium Playwright NVDA", () => { retry, }); - let stopRecording: (() => void) | undefined; + let stopRecording: (() => Promise) | undefined; try { - stopRecording = windowsRecord(recordingFilePath); + stopRecording = await record(recordingFilePath); await headerNavigation({ page, nvda }); @@ -46,9 +81,72 @@ test.describe("Chromium Playwright NVDA", () => { console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); - logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshotDocs); + } finally { + await stopRecording?.(); + } + }); + + test("I can type text into text areas and it is announced (https://github.com/guidepup/guidepup/issues/115)", async ({ + browser, + browserName, + page, + nvda, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = nvda.name; + const screenReaderVersion = nvda.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-nvda-textarea-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to textarea test page."); + await page.goto("about:blank", { + waitUntil: "load", + }); + + await page.setContent(` + + + `); + + const input = page.locator("#test"); + await input.waitFor(); + await delay(500); + + await nvda.navigateToWebContent(); + await delay(500); + + await nvda.type("Abc"); + await nvda.press("Control+A"); + await nvda.press("Delete"); + await nvda.type("123"); + + await expect(input).toHaveValue("123"); + + const spokenPhraseLog = await nvda.spokenPhraseLog(); + + console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); + + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshotTextarea); } finally { - stopRecording?.(); + await stopRecording?.(); } }); }); diff --git a/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.docs.snapshot.json b/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.docs.snapshot.json new file mode 100644 index 0000000..92dec16 --- /dev/null +++ b/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.docs.snapshot.json @@ -0,0 +1,10 @@ +[ + "Guidepup, heading, level 1", + "Reliable Automation", + "Full Control, heading, level 3", + "Mirrors Real User Experience, heading, level 3", + "Framework Agnostic, heading, level 3", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "link, Get Started", + "link, Git Hub" +] diff --git a/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.snapshot.json b/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.snapshot.json deleted file mode 100644 index 5dda94a..0000000 --- a/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.snapshot.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - "Guidepup, heading, level 1", - "Reliable Automation", - "Full Control, heading, level 3", - "Mirrors Real User Experience, heading, level 3", - "Framework Agnostic, heading, level 3" -] diff --git a/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.textarea.snapshot.json b/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.textarea.snapshot.json new file mode 100644 index 0000000..985cfd3 --- /dev/null +++ b/examples/playwright-nvda/tests/chromium/chromium.spokenPhrase.textarea.snapshot.json @@ -0,0 +1 @@ +["A", "b", "c", "Abc selected", "blank", "1", "2", "3"] diff --git a/examples/playwright-nvda/tests/firefox/firefox.spec.ts b/examples/playwright-nvda/tests/firefox/firefox.spec.ts index f0e1540..54f26fd 100644 --- a/examples/playwright-nvda/tests/firefox/firefox.spec.ts +++ b/examples/playwright-nvda/tests/firefox/firefox.spec.ts @@ -1,13 +1,22 @@ import { platform, release } from "os"; import { headerNavigation } from "../headerNavigation"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; -import { windowsRecord } from "@guidepup/record"; import spokenPhraseSnapshot from "./firefox.spokenPhrase.snapshot.json"; import { nvdaTest as test } from "../../../../src"; -test.use({ nvdaStartOptions: { capture: "initial" } }); +const record = async (filepath: string) => { + try { + const { windowsRecord } = await import("@guidepup/record"); -test.describe("Firefox Playwright VoiceOver", () => { + return windowsRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.describe("Firefox Playwright NVDA", () => { test("I can navigate the Guidepup documentation site", async ({ browser, browserName, @@ -32,10 +41,10 @@ test.describe("Firefox Playwright VoiceOver", () => { retry, }); - let stopRecording: (() => void) | undefined; + let stopRecording: (() => Promise) | undefined; try { - stopRecording = windowsRecord(recordingFilePath); + stopRecording = await record(recordingFilePath); await headerNavigation({ page, nvda }); @@ -48,7 +57,7 @@ test.describe("Firefox Playwright VoiceOver", () => { logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); } finally { - stopRecording?.(); + await stopRecording?.(); } }); }); diff --git a/examples/playwright-nvda/tests/firefox/firefox.spokenPhrase.snapshot.json b/examples/playwright-nvda/tests/firefox/firefox.spokenPhrase.snapshot.json index 5dda94a..92dec16 100644 --- a/examples/playwright-nvda/tests/firefox/firefox.spokenPhrase.snapshot.json +++ b/examples/playwright-nvda/tests/firefox/firefox.spokenPhrase.snapshot.json @@ -3,5 +3,8 @@ "Reliable Automation", "Full Control, heading, level 3", "Mirrors Real User Experience, heading, level 3", - "Framework Agnostic, heading, level 3" + "Framework Agnostic, heading, level 3", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "link, Get Started", + "link, Git Hub" ] diff --git a/examples/playwright-nvda/tests/headerNavigation.ts b/examples/playwright-nvda/tests/headerNavigation.ts index ebf7ca1..d10d469 100644 --- a/examples/playwright-nvda/tests/headerNavigation.ts +++ b/examples/playwright-nvda/tests/headerNavigation.ts @@ -1,7 +1,7 @@ -import { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; +import { delay } from "../../../src/delay"; import { log } from "../../log"; import type { NVDAPlaywright } from "../../../src"; -import { delay } from "../../../src/delay"; const MAX_NAVIGATION_LOOP = 10; @@ -40,4 +40,28 @@ export async function headerNavigation({ await nvda.nextHeading(); log(`Screen reader output: "${await nvda.lastSpokenPhrase()}".`); } + + let tabCount = 0; + + // Move across text and buttons using NVDA + while ( + !(await nvda.lastSpokenPhrase()).replaceAll(/\s/g, "").includes("GitHub") && + tabCount <= MAX_NAVIGATION_LOOP + ) { + tabCount++; + + log(`Performing command: "Right"`); + await nvda.next(); + log(`Screen reader output: "${await nvda.lastSpokenPhrase()}".`); + } + + log(`Performing command: "Left"`); + await nvda.previous(); + log(`Screen reader output: "${await nvda.lastSpokenPhrase()}".`); + + log(`Performing command: "Enter"`); + await nvda.act(); + log(`Screen reader output: "${await nvda.lastSpokenPhrase()}".`); + + await expect(page).toHaveURL("https://www.guidepup.dev/docs/getting-started"); } diff --git a/examples/playwright-screenreader/chromium.config.ts b/examples/playwright-screenreader/chromium.config.ts index def99d5..bfbb9af 100644 --- a/examples/playwright-screenreader/chromium.config.ts +++ b/examples/playwright-screenreader/chromium.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "chromium", diff --git a/examples/playwright-screenreader/tests/chromium/chromium-live-region.spec.ts b/examples/playwright-screenreader/tests/chromium/chromium-live-region.spec.ts index c14ac78..5037710 100644 --- a/examples/playwright-screenreader/tests/chromium/chromium-live-region.spec.ts +++ b/examples/playwright-screenreader/tests/chromium/chromium-live-region.spec.ts @@ -4,6 +4,18 @@ import { expect } from "@playwright/test"; import { log } from "../../../log"; import { screenReaderTest as test } from "../../../../src"; +const record = async (filepath: string) => { + try { + const { record: guidepupRecord } = await import("@guidepup/record"); + + return guidepupRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + test.describe("Chromium Playwright Screen Reader", () => { test("I can capture screen reader output from Playwright commands", async ({ browser, @@ -17,6 +29,7 @@ test.describe("Chromium Playwright Screen Reader", () => { const screenReaderName = screenReader.name; const screenReaderVersion = screenReader.version; const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-live-region-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; console.table({ osName, @@ -28,55 +41,71 @@ test.describe("Chromium Playwright Screen Reader", () => { retry, }); - log("Navigating to live region test page."); + let stopRecording: (() => Promise) | undefined; - await page.goto("about:blank", { - waitUntil: "load", - }); + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to live region test page."); - await page.setContent(` -
-

Example 1

- -
+ await page.goto("about:blank", { + waitUntil: "load", + }); - + await page.setContent(` + + + + Guidepup Live Region Test + + +
+

Example 1

+ +
- + + + + + `); - const button = page.locator("#trigger"); - await button.waitFor(); - await delay(500); + const button = page.locator("#trigger"); + await button.waitFor(); + await delay(500); - await screenReader.navigateToWebContent(); - await delay(500); + await screenReader.navigateToWebContent(); + await delay(500); - log(`Performing capture: Playwright focus`); - const { spokenPhrase: focusSpokenPhrase } = await screenReader.capture(() => - button.focus(), - ); - log(`Screen reader output: "${focusSpokenPhrase}".`); - - log(`Performing capture: Playwright click`); - const { spokenPhrase: clickSpokenPhrase } = await screenReader.capture( - () => button.click(), - { - // Capture full output as there is potential for multiple phrases: - // - // 1. The button itself - // 2. And the live region announcement - // - // And the default capture of "initial" will cut off the announcement. - capture: true, - }, - ); - log(`Screen reader output: "${clickSpokenPhrase}".`); + log(`Performing capture: Playwright focus`); + const { spokenPhrase: focusSpokenPhrase } = await screenReader.capture( + () => button.focus(), + ); + log(`Screen reader output: "${focusSpokenPhrase}".`); + + log(`Performing capture: Playwright click`); + const { spokenPhrase: clickSpokenPhrase } = await screenReader.capture( + () => button.click(), + { + // Capture full output as there is potential for multiple phrases: + // + // 1. The button itself + // 2. And the live region announcement + // + // And the default capture of "initial" will cut off the announcement. + capture: true, + }, + ); + log(`Screen reader output: "${clickSpokenPhrase}".`); - expect(clickSpokenPhrase).toContain("testing testing 123"); + expect(clickSpokenPhrase).toContain("testing testing 123"); + } finally { + await stopRecording?.(); + } }); }); diff --git a/examples/playwright-screenreader/tests/chromium/chromium.spec.ts b/examples/playwright-screenreader/tests/chromium/chromium.spec.ts index 529e7df..738cb11 100644 --- a/examples/playwright-screenreader/tests/chromium/chromium.spec.ts +++ b/examples/playwright-screenreader/tests/chromium/chromium.spec.ts @@ -4,6 +4,18 @@ import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases" import spokenPhraseSnapshot from "./chromium.spokenPhrase.snapshot.json"; import { screenReaderTest as test } from "../../../../src"; +const record = async (filepath: string) => { + try { + const { record: guidepupRecord } = await import("@guidepup/record"); + + return guidepupRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + test.describe("Chromium Playwright Screen Reader", () => { test("I can navigate the Guidepup documentation site", async ({ browser, @@ -17,6 +29,7 @@ test.describe("Chromium Playwright Screen Reader", () => { const screenReaderName = screenReader.name; const screenReaderVersion = screenReader.version; const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; console.table({ osName, @@ -28,17 +41,25 @@ test.describe("Chromium Playwright Screen Reader", () => { retry, }); - await headerNavigation({ page, screenReader }); + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + await headerNavigation({ page, screenReader }); - // Assert that we've ended up where we expected and what we were told on - // the way there is as expected. + // Assert that we've ended up where we expected and what we were told on + // the way there is as expected. - const itemTextLog = await screenReader.itemTextLog(); - const spokenPhraseLog = await screenReader.spokenPhraseLog(); + const itemTextLog = await screenReader.itemTextLog(); + const spokenPhraseLog = await screenReader.spokenPhraseLog(); - console.log(JSON.stringify(itemTextLog, undefined, 2)); - console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); + console.log(JSON.stringify(itemTextLog, undefined, 2)); + console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); - logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + } finally { + await stopRecording?.(); + } }); }); diff --git a/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts b/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts new file mode 100644 index 0000000..35fb913 --- /dev/null +++ b/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts @@ -0,0 +1,331 @@ +import { platform, release } from "os"; +import { delay } from "../../../../src/delay"; +import { expect } from "@playwright/test"; +import { log } from "../../../log"; +import { screenReaderTest as test } from "../../screenreader-test"; + +const MAX_NAVIGATION_LOOP = 10; + +const record = async (filepath: string) => { + try { + const { record: guidepupRecord } = await import("@guidepup/record"); + + return guidepupRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.describe("Firefox Playwright Screen Reader: Orca", () => { + test("I can capture live region screen reader output from Orca commands (Object Navigator Click)", async ({ + browser, + browserName, + page, + screenReader, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = screenReader.name; + const screenReaderVersion = screenReader.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-live-region-orca-commands-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to live region test page."); + + await page.goto("about:blank", { + waitUntil: "load", + }); + + // NOTE: Orca does not support `role="alert"` etc.correctly. + // Authors are required to implement `aria-live` explicitly. + await page.setContent(` + + + + Guidepup Live Region Test + + +
+

Example 1

+ +
+ + + + + + + `); + + await page.locator("#trigger").waitFor(); + await delay(500); + + await screenReader.navigateToWebContent(); + await delay(500); + + let navigationCount = 0; + + while ( + !(await screenReader.itemText()) + .replaceAll(/\s/g, "") + .includes("Update") && + navigationCount <= MAX_NAVIGATION_LOOP + ) { + navigationCount++; + + log(`Performing command: "Orca+Ctrl+Right Arrow"`); + await screenReader.next(); + log( + `Screen reader output: "${await screenReader.lastSpokenPhrase()}".`, + ); + } + + // TODO: potential bug in Orca - using object navigator to place Orca on + // a button and then `Orca+Ctrl+Enter` to activate it does not announce a + // live region _unless_ focus is first place on the button. + // REF: https://github.com/guidepup/guidepup/pull/143#issuecomment-6106014653 + await page.locator("#trigger").focus(); + await delay(500); + + log(`Performing command: "Orca+Ctrl+Enter"`); + await screenReader.act(); + const clickSpokenPhrase = await screenReader.lastSpokenPhrase(); + log(`Screen reader output: "${clickSpokenPhrase}".`); + + expect(clickSpokenPhrase).toContain("testing testing 123"); + } finally { + await stopRecording?.(); + } + }); + + test("I can capture live region screen reader output from Orca commands (Playwright Click)", async ({ + browser, + browserName, + page, + screenReader, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = screenReader.name; + const screenReaderVersion = screenReader.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-live-region-orca-commands-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to live region test page."); + + await page.goto("about:blank", { + waitUntil: "load", + }); + + // NOTE: Orca does not support `role="alert"` etc.correctly. + // Authors are required to implement `aria-live` explicitly. + await page.setContent(` + + + + Guidepup Live Region Test + + +
+

Example 1

+ +
+ + + + + + + `); + + const button = page.locator("#trigger"); + await button.waitFor(); + await delay(500); + + await screenReader.navigateToWebContent(); + await delay(500); + + let navigationCount = 0; + + while ( + !(await screenReader.itemText()) + .replaceAll(/\s/g, "") + .includes("Update") && + navigationCount <= MAX_NAVIGATION_LOOP + ) { + navigationCount++; + + log(`Performing command: "Orca+Ctrl+Right Arrow"`); + await screenReader.next(); + log( + `Screen reader output: "${await screenReader.lastSpokenPhrase()}".`, + ); + } + + // TODO: potential bug in Orca - using object navigator to place Orca on + // a button and then `Orca+Ctrl+Enter` to activate it does not announce a + // live region _unless_ focus is first place on the button. + // REF: https://github.com/guidepup/guidepup/pull/143#issuecomment-6106014653 + await page.locator("#trigger").focus(); + await delay(500); + + log(`Performing capture: Playwright click`); + const { spokenPhrase: clickSpokenPhrase } = await screenReader.capture( + () => button.click(), + { + // Capture full output as there is potential for multiple phrases: + // + // 1. The button itself + // 2. And the live region announcement + // + // And the default capture of "initial" will cut off the announcement. + capture: true, + }, + ); + log(`Screen reader output: "${clickSpokenPhrase}".`); + + expect(clickSpokenPhrase).toContain("testing testing 123"); + } finally { + await stopRecording?.(); + } + }); + + test("I can capture live region screen reader output from Playwright commands", async ({ + browser, + browserName, + page, + screenReader, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = screenReader.name; + const screenReaderVersion = screenReader.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-live-region-capture-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to live region test page."); + + await page.goto("about:blank", { + waitUntil: "load", + }); + + // NOTE: Orca does not support `role="alert"` etc.correctly. + // Authors are required to implement `aria-live` explicitly. + await page.setContent(` + + + + Guidepup Live Region Test + + +
+

Example 1

+ +
+ + + + + + + `); + + const button = page.locator("#trigger"); + await button.waitFor(); + await delay(500); + + await screenReader.navigateToWebContent(); + await delay(500); + + log(`Performing capture: Playwright focus`); + const { spokenPhrase: focusSpokenPhrase } = await screenReader.capture( + () => button.focus(), + ); + log(`Screen reader output: "${focusSpokenPhrase}".`); + + await delay(500); + + log(`Performing capture: Playwright click`); + const { spokenPhrase: clickSpokenPhrase } = await screenReader.capture( + () => button.click(), + { + // Capture full output as there is potential for multiple phrases: + // + // 1. The button itself + // 2. And the live region announcement + // + // And the default capture of "initial" will cut off the announcement. + capture: true, + }, + ); + log(`Screen reader output: "${clickSpokenPhrase}".`); + + // TODO: support capture from Playwright interactions. + expect(clickSpokenPhrase).toContain("testing testing 123"); + } finally { + await stopRecording?.(); + } + }); +}); diff --git a/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts b/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts new file mode 100644 index 0000000..8448170 --- /dev/null +++ b/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts @@ -0,0 +1,105 @@ +import { platform, release } from "os"; +import { delay } from "../../../../src/delay"; +import { log } from "../../../log"; +import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; +import spokenPhraseSnapshot from "./firefox.spokenPhrase.snapshot.json"; +import { screenReaderTest as test } from "../../screenreader-test"; + +const MAX_NAVIGATION_LOOP = 10; + +const record = async (filepath: string) => { + try { + const { record: guidepupRecord } = await import("@guidepup/record"); + + return guidepupRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.describe("Firefox Playwright Screen Reader", () => { + test("I can navigate the Guidepup documentation site", async ({ + browser, + browserName, + page, + screenReader, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = screenReader.name; + const screenReaderVersion = screenReader.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-screenreader-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to URL: https://www.guidepup.dev."); + await page.goto("https://www.guidepup.dev", { + waitUntil: "load", + }); + + const header = page.locator("h1"); + await header.waitFor(); + await delay(500); + + await screenReader.navigateToWebContent(); + await delay(500); + + // Navigate out of the skip to main content and into the navigation bar + await screenReader.stopInteracting(); + await screenReader.next(); + await screenReader.interact(); + + let navigationCount = 0; + + // Move across the header content + while ( + !(await screenReader.itemText()) + .replaceAll(/\s/g, "") + .includes("GitHub") && + navigationCount <= MAX_NAVIGATION_LOOP + ) { + navigationCount++; + + log(`Performing command: next`); + await screenReader.next(); + log( + `Screen reader output: "${await screenReader.lastSpokenPhrase()}".`, + ); + } + + log(`Performing command: act`); + await screenReader.act(); + log(`Screen reader output: "${await screenReader.lastSpokenPhrase()}".`); + + // Assert that we've ended up where we expected and what we were told on + // the way there is as expected. + + const itemTextLog = await screenReader.itemTextLog(); + const spokenPhraseLog = await screenReader.spokenPhraseLog(); + + console.log(JSON.stringify(itemTextLog, undefined, 2)); + console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); + + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + } finally { + await stopRecording?.(); + } + }); +}); diff --git a/examples/playwright-screenreader/tests/firefox/firefox.spokenPhrase.snapshot.json b/examples/playwright-screenreader/tests/firefox/firefox.spokenPhrase.snapshot.json new file mode 100644 index 0000000..68bd46a --- /dev/null +++ b/examples/playwright-screenreader/tests/firefox/firefox.spokenPhrase.snapshot.json @@ -0,0 +1 @@ +["Guidepup", "Docs", "API", "GitHub"] diff --git a/examples/playwright-voiceover/chromium.config.ts b/examples/playwright-voiceover/chromium.config.ts index def99d5..bfbb9af 100644 --- a/examples/playwright-voiceover/chromium.config.ts +++ b/examples/playwright-voiceover/chromium.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "chromium", diff --git a/examples/playwright-voiceover/firefox.config.ts b/examples/playwright-voiceover/firefox.config.ts index 92a6e17..40e4349 100644 --- a/examples/playwright-voiceover/firefox.config.ts +++ b/examples/playwright-voiceover/firefox.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "firefox", diff --git a/examples/playwright-voiceover/tests/chromium/chromium.itemText.snapshot.json b/examples/playwright-voiceover/tests/chromium/chromium.itemText.snapshot.json index 238b26c..166b0bc 100644 --- a/examples/playwright-voiceover/tests/chromium/chromium.itemText.snapshot.json +++ b/examples/playwright-voiceover/tests/chromium/chromium.itemText.snapshot.json @@ -3,5 +3,8 @@ "Screen Reader A11y Workflows", "Full Control heading level 3", "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" + "Framework Agnostic heading level 3", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "Get Started link", + "GitHub link" ] diff --git a/examples/playwright-voiceover/tests/chromium/chromium.spec.ts b/examples/playwright-voiceover/tests/chromium/chromium.spec.ts index 5bc05e9..0cc6e62 100644 --- a/examples/playwright-voiceover/tests/chromium/chromium.spec.ts +++ b/examples/playwright-voiceover/tests/chromium/chromium.spec.ts @@ -2,11 +2,20 @@ import { platform, release } from "os"; import { headerNavigation } from "../headerNavigation"; import itemTextSnapshot from "./chromium.itemText.snapshot.json"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; -import { macOSRecord } from "@guidepup/record"; import spokenPhraseSnapshot from "./chromium.spokenPhrase.snapshot.json"; import { voiceOverTest as test } from "../../../../src"; -test.use({ voiceOverStartOptions: { capture: "initial" } }); +const record = async (filepath: string) => { + try { + const { macOSRecord } = await import("@guidepup/record"); + + return macOSRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; test.describe("Chromium Playwright VoiceOver", () => { test("I can navigate the Guidepup documentation site", async ({ @@ -33,10 +42,10 @@ test.describe("Chromium Playwright VoiceOver", () => { retry, }); - let stopRecording: (() => void) | undefined; + let stopRecording: (() => Promise) | undefined; try { - stopRecording = macOSRecord(recordingFilePath); + stopRecording = await record(recordingFilePath); await headerNavigation({ page, voiceOver }); @@ -52,7 +61,7 @@ test.describe("Chromium Playwright VoiceOver", () => { logIncludesExpectedPhrases(itemTextLog, itemTextSnapshot); logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); } finally { - stopRecording?.(); + await stopRecording?.(); } }); }); diff --git a/examples/playwright-voiceover/tests/chromium/chromium.spokenPhrase.snapshot.json b/examples/playwright-voiceover/tests/chromium/chromium.spokenPhrase.snapshot.json index 238b26c..329dd7c 100644 --- a/examples/playwright-voiceover/tests/chromium/chromium.spokenPhrase.snapshot.json +++ b/examples/playwright-voiceover/tests/chromium/chromium.spokenPhrase.snapshot.json @@ -1,7 +1,10 @@ [ - "Guidepup heading level 1", + "heading level 1 Guidepup", "Screen Reader A11y Workflows", - "Full Control heading level 3", - "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" + "heading level 3 Full Control", + "heading level 3 Mirrors Real User Experience", + "heading level 3 Framework Agnostic", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "link Get Started", + "link GitHub" ] diff --git a/examples/playwright-voiceover/tests/firefox/firefox.itemText.snapshot.json b/examples/playwright-voiceover/tests/firefox/firefox.itemText.snapshot.json index 238b26c..166b0bc 100644 --- a/examples/playwright-voiceover/tests/firefox/firefox.itemText.snapshot.json +++ b/examples/playwright-voiceover/tests/firefox/firefox.itemText.snapshot.json @@ -3,5 +3,8 @@ "Screen Reader A11y Workflows", "Full Control heading level 3", "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" + "Framework Agnostic heading level 3", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "Get Started link", + "GitHub link" ] diff --git a/examples/playwright-voiceover/tests/firefox/firefox.spec.ts b/examples/playwright-voiceover/tests/firefox/firefox.spec.ts index c0aab28..f62e8d3 100644 --- a/examples/playwright-voiceover/tests/firefox/firefox.spec.ts +++ b/examples/playwright-voiceover/tests/firefox/firefox.spec.ts @@ -2,11 +2,30 @@ import { platform, release } from "os"; import { headerNavigation } from "../headerNavigation"; import itemTextSnapshot from "./firefox.itemText.snapshot.json"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; -import { macOSRecord } from "@guidepup/record"; import spokenPhraseSnapshot from "./firefox.spokenPhrase.snapshot.json"; import { voiceOverTest as test } from "../../../../src"; -test.use({ voiceOverStartOptions: { capture: "initial" } }); +const record = async (filepath: string) => { + try { + const { macOSRecord } = await import("@guidepup/record"); + + return macOSRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.use({ + voiceOverStartOptions: { + capture: "initial", + settings: { + SCRCategories_SCRCategorySystemWide_SCRSpeechLanguages_default_SCRSpeechComponentSettings_SCRVoiceIdentifier: + "com.apple.speech.synthesis.voice.custom.siri.martha.compact", + }, + }, +}); test.describe("Firefox Playwright VoiceOver", () => { test("I can navigate the Guidepup documentation site", async ({ @@ -33,10 +52,10 @@ test.describe("Firefox Playwright VoiceOver", () => { retry, }); - let stopRecording: (() => void) | undefined; + let stopRecording: (() => Promise) | undefined; try { - stopRecording = macOSRecord(recordingFilePath); + stopRecording = await record(recordingFilePath); await headerNavigation({ page, voiceOver }); @@ -52,7 +71,7 @@ test.describe("Firefox Playwright VoiceOver", () => { logIncludesExpectedPhrases(itemTextLog, itemTextSnapshot); logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); } finally { - stopRecording?.(); + await stopRecording?.(); } }); }); diff --git a/examples/playwright-voiceover/tests/firefox/firefox.spokenPhrase.snapshot.json b/examples/playwright-voiceover/tests/firefox/firefox.spokenPhrase.snapshot.json index 238b26c..329dd7c 100644 --- a/examples/playwright-voiceover/tests/firefox/firefox.spokenPhrase.snapshot.json +++ b/examples/playwright-voiceover/tests/firefox/firefox.spokenPhrase.snapshot.json @@ -1,7 +1,10 @@ [ - "Guidepup heading level 1", + "heading level 1 Guidepup", "Screen Reader A11y Workflows", - "Full Control heading level 3", - "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" + "heading level 3 Full Control", + "heading level 3 Mirrors Real User Experience", + "heading level 3 Framework Agnostic", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "link Get Started", + "link GitHub" ] diff --git a/examples/playwright-voiceover/tests/headerNavigation.ts b/examples/playwright-voiceover/tests/headerNavigation.ts index 09531e3..2b25c19 100644 --- a/examples/playwright-voiceover/tests/headerNavigation.ts +++ b/examples/playwright-voiceover/tests/headerNavigation.ts @@ -1,7 +1,7 @@ -import { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; +import { delay } from "../../../src/delay"; import { log } from "../../log"; import type { VoiceOverPlaywright } from "../../../src"; -import { delay } from "../../../src/delay"; const MAX_NAVIGATION_LOOP = 10; @@ -49,4 +49,41 @@ export async function headerNavigation({ await voiceOver.perform(voiceOver.keyboardCommands.describeItem); log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); } + + let tabCount = 0; + + // Move across text and buttons using VoiceOver + while ( + !(await voiceOver.itemText()).includes("GitHub") && + tabCount <= MAX_NAVIGATION_LOOP + ) { + tabCount++; + + log(`Performing command: "VO+Right Arrow"`); + await voiceOver.next(); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log( + `Performing command: "VO+F3" - "Describe the item in the VoiceOver cursor"`, + ); + await voiceOver.perform(voiceOver.keyboardCommands.describeItem); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + } + + log(`Performing command: "VO+Left Arrow"`); + await voiceOver.previous(); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log( + `Performing command: "VO+F3" - "Describe the item in the VoiceOver cursor"`, + ); + await voiceOver.perform(voiceOver.keyboardCommands.describeItem); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + // Navigate to the Guidepup Getting Started docs + log(`Performing command: "VO+Space bar"`); + await voiceOver.act(); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + await expect(page).toHaveURL("https://www.guidepup.dev/docs/getting-started"); } diff --git a/examples/playwright-voiceover/tests/webkit/webkit.itemText.docs.snapshot.json b/examples/playwright-voiceover/tests/webkit/webkit.itemText.docs.snapshot.json new file mode 100644 index 0000000..166b0bc --- /dev/null +++ b/examples/playwright-voiceover/tests/webkit/webkit.itemText.docs.snapshot.json @@ -0,0 +1,10 @@ +[ + "Guidepup heading level 1", + "Screen Reader A11y Workflows", + "Full Control heading level 3", + "Mirrors Real User Experience heading level 3", + "Framework Agnostic heading level 3", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "Get Started link", + "GitHub link" +] diff --git a/examples/playwright-voiceover/tests/webkit/webkit.itemText.snapshot.json b/examples/playwright-voiceover/tests/webkit/webkit.itemText.snapshot.json deleted file mode 100644 index 238b26c..0000000 --- a/examples/playwright-voiceover/tests/webkit/webkit.itemText.snapshot.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - "Guidepup heading level 1", - "Screen Reader A11y Workflows", - "Full Control heading level 3", - "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" -] diff --git a/examples/playwright-voiceover/tests/webkit/webkit.spec.ts b/examples/playwright-voiceover/tests/webkit/webkit.spec.ts index 3d96364..ea44aaa 100644 --- a/examples/playwright-voiceover/tests/webkit/webkit.spec.ts +++ b/examples/playwright-voiceover/tests/webkit/webkit.spec.ts @@ -1,12 +1,24 @@ import { platform, release } from "os"; import { headerNavigation } from "../headerNavigation"; -import itemTextSnapshot from "./webkit.itemText.snapshot.json"; +import itemTextSnapshotDocs from "./webkit.itemText.docs.snapshot.json"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; -import { macOSRecord } from "@guidepup/record"; -import spokenPhraseSnapshot from "./webkit.spokenPhrase.snapshot.json"; +import spokenPhraseSnapshotDocs from "./webkit.spokenPhrase.docs.snapshot.json"; +import spokenPhraseSnapshotTextarea from "./webkit.spokenPhrase.textarea.snapshot.json"; import { voiceOverTest as test } from "../../../../src"; +import { log } from "../../../log"; +import { expect } from "@playwright/test"; -test.use({ voiceOverStartOptions: { capture: "initial" } }); +const record = async (filepath: string) => { + try { + const { macOSRecord } = await import("@guidepup/record"); + + return macOSRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; test.describe("Webkit Playwright VoiceOver", () => { test("I can navigate the Guidepup documentation site", async ({ @@ -33,10 +45,10 @@ test.describe("Webkit Playwright VoiceOver", () => { retry, }); - let stopRecording: (() => void) | undefined; + let stopRecording: (() => Promise) | undefined; try { - stopRecording = macOSRecord(recordingFilePath); + stopRecording = await record(recordingFilePath); await headerNavigation({ page, voiceOver }); @@ -49,10 +61,85 @@ test.describe("Webkit Playwright VoiceOver", () => { console.log(JSON.stringify(itemTextLog, undefined, 2)); console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); - logIncludesExpectedPhrases(itemTextLog, itemTextSnapshot); - logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + logIncludesExpectedPhrases(itemTextLog, itemTextSnapshotDocs); + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshotDocs); + } finally { + await stopRecording?.(); + } + }); + + test("I can type text into text areas and it is announced (https://github.com/guidepup/guidepup/issues/115)", async ({ + browser, + browserName, + page, + voiceOver, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = voiceOver.name; + const screenReaderVersion = voiceOver.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-voiceover-textarea-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + log("Navigating to textarea test page."); + await page.goto("about:blank", { + waitUntil: "load", + }); + + await page.setContent(` + + + `); + + const input = page.locator("#test"); + await input.waitFor(); + await voiceOver.navigateToWebContent(); + + log(`Performing command: "VO+Right Arrow"`); + await voiceOver.next(); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log(`Typing: "Abc"`); + await voiceOver.type("Abc"); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log(`Pressing: "Cmd+A"`); + await voiceOver.press("Cmd+A"); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log(`Pressing: "Delete"`); + await voiceOver.press("Delete"); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + log(`Typing: "123"`); + await voiceOver.type("123"); + log(`Screen reader output: "${await voiceOver.lastSpokenPhrase()}".`); + + await expect(input).toHaveValue("123"); + + const spokenPhraseLog = await voiceOver.spokenPhraseLog(); + + console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); + + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshotTextarea); } finally { - stopRecording?.(); + await stopRecording?.(); } }); }); diff --git a/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.docs.snapshot.json b/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.docs.snapshot.json new file mode 100644 index 0000000..329dd7c --- /dev/null +++ b/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.docs.snapshot.json @@ -0,0 +1,10 @@ +[ + "heading level 1 Guidepup", + "Screen Reader A11y Workflows", + "heading level 3 Full Control", + "heading level 3 Mirrors Real User Experience", + "heading level 3 Framework Agnostic", + "Run with Jest, with Playwright, as an independent script, no vendor lock-in.", + "link Get Started", + "link GitHub" +] diff --git a/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.snapshot.json b/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.snapshot.json deleted file mode 100644 index 238b26c..0000000 --- a/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.snapshot.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - "Guidepup heading level 1", - "Screen Reader A11y Workflows", - "Full Control heading level 3", - "Mirrors Real User Experience heading level 3", - "Framework Agnostic heading level 3" -] diff --git a/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.textarea.snapshot.json b/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.textarea.snapshot.json new file mode 100644 index 0000000..d68b4f4 --- /dev/null +++ b/examples/playwright-voiceover/tests/webkit/webkit.spokenPhrase.textarea.snapshot.json @@ -0,0 +1 @@ +["A", "b", "c", "Abc selected", "1", "2", "3"] diff --git a/examples/playwright-voiceover/webkit.config.ts b/examples/playwright-voiceover/webkit.config.ts index b4a8662..9f4d81f 100644 --- a/examples/playwright-voiceover/webkit.config.ts +++ b/examples/playwright-voiceover/webkit.config.ts @@ -5,7 +5,7 @@ const config: PlaywrightTestConfig = { ...screenReaderConfig, reportSlowTests: null, timeout: 5 * 60 * 1000, - retries: 1, + retries: 5, projects: [ { name: "webkit", diff --git a/package.json b/package.json index e70d676..eda288b 100644 --- a/package.json +++ b/package.json @@ -34,18 +34,21 @@ "prepublish": "yarn build", "pretest": "npx playwright install chromium firefox webkit", "test:nvda": "yarn test:nvda:chromium && yarn test:nvda:firefox", - "test:screenreader": "yarn test:screenreader:chromium", + "test:orca": "yarn test:orca:firefox", + "test:screenreader": "yarn test:screenreader:chromium && yarn test:screenreader:firefox", "test:voiceover": "yarn test:voiceover:chromium && yarn test:voiceover:firefox && yarn test:voiceover:webkit", "test:nvda:chromium": "playwright test --config ./examples/playwright-nvda/chromium.config.ts ./examples/playwright-nvda/tests/chromium/", "test:nvda:firefox": "playwright test --config ./examples/playwright-nvda/firefox.config.ts ./examples/playwright-nvda/tests/firefox/", + "test:orca:firefox": "playwright test --config ./examples/playwright-orca/firefox.config.ts ./examples/playwright-orca/tests/firefox/", "test:screenreader:chromium": "playwright test --config ./examples/playwright-screenreader/chromium.config.ts ./examples/playwright-screenreader/tests/chromium/", + "test:screenreader:firefox": "playwright test --config ./examples/playwright-screenreader/firefox.config.ts ./examples/playwright-screenreader/tests/firefox/", "test:voiceover:chromium": "playwright test --config ./examples/playwright-voiceover/chromium.config.ts ./examples/playwright-voiceover/tests/chromium/", "test:voiceover:firefox": "playwright test --config ./examples/playwright-voiceover/firefox.config.ts ./examples/playwright-voiceover/tests/firefox/", "test:voiceover:webkit": "playwright test --config ./examples/playwright-voiceover/webkit.config.ts ./examples/playwright-voiceover/tests/webkit/" }, "devDependencies": { - "@guidepup/guidepup": "^0.33.0", - "@guidepup/record": "^0.1.0", + "@guidepup/guidepup": "^0.36.1", + "@guidepup/record": "^0.3.0", "@playwright/test": "^1.60.0", "@types/node": "^24.10.1", "@typescript-eslint/eslint-plugin": "^8.48.1", @@ -58,7 +61,7 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@guidepup/guidepup": ">=0.33.0", + "@guidepup/guidepup": ">=0.36.1", "@playwright/test": "^1.57.0" }, "resolutions": { diff --git a/src/applicationIdMap.ts b/src/applicationIdMap.ts new file mode 100644 index 0000000..3dff95a --- /dev/null +++ b/src/applicationIdMap.ts @@ -0,0 +1,10 @@ +export const applicationIdMap = { + chromium: "com.google.chrome.for.testing", + chrome: "com.google.Chrome", + "chrome-beta": "com.google.Chrome.beta", + msedge: "com.microsoft.edgemac", + "msedge-beta": "com.microsoft.edgemac.Beta", + "msedge-dev": "com.microsoft.edgemac.Dev", + firefox: "org.mozilla.nightlyunofficial", + webkit: "org.webkit.Playwright", +}; diff --git a/src/cleanString.ts b/src/cleanString.ts new file mode 100644 index 0000000..db20afd --- /dev/null +++ b/src/cleanString.ts @@ -0,0 +1,6 @@ +export const cleanString = (str: string): string => + str + .toLowerCase() + .replace(/[|ยฆ:;'"`\-โ€โ€“โ€”ยท_()[\]{}\\^~]/g, " ") + .replace(/\s+/g, " ") + .trim(); diff --git a/src/index.ts b/src/index.ts index 54e8f4b..a6d105f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export { nvdaTest } from "./nvdaTest"; export type { NVDAPlaywright } from "./nvdaTest"; +export { orcaTest } from "./orcaTest"; +export type { OrcaPlaywright } from "./orcaTest"; export { screenReaderConfig } from "./screenReaderConfig"; export { screenReaderTest } from "./screenReaderTest"; export type { ScreenReaderPlaywright } from "./screenReaderTest"; diff --git a/src/navigateNVDAtoWebContent.ts b/src/navigateNVDAtoWebContent.ts new file mode 100644 index 0000000..3e4769a --- /dev/null +++ b/src/navigateNVDAtoWebContent.ts @@ -0,0 +1,142 @@ +import { + type CommandOptions, + type NVDA, + NVDAKeyCodeCommands, + WindowsKeyCodes, + WindowsModifiers, +} from "@guidepup/guidepup"; +import type { Page } from "@playwright/test"; +import { cleanString } from "./cleanString"; +import { delay } from "./delay"; + +const MAX_APPLICATION_SWITCH_RETRY_COUNT = 10; + +const SWITCH_APPLICATION = { + keyCode: [WindowsKeyCodes.Escape], + modifiers: [WindowsModifiers.Alt], +}; + +const MOVE_TO_TOP = { + keyCode: [WindowsKeyCodes.Home], + modifiers: [WindowsModifiers.Control], +}; + +const hasBrowserFocus = ({ + applicationName, + pageTitle, + windowTitle, +}: { + applicationName: string; + pageTitle: string; + windowTitle: string; +}): boolean => { + const cleanedApplicationName = cleanString(applicationName); + const cleanedPageTitle = cleanString(pageTitle); + const cleanedWindowTitle = cleanString(windowTitle); + + return ( + (cleanedPageTitle.length > 0 && + cleanedWindowTitle.startsWith(cleanedPageTitle)) || + cleanedWindowTitle.includes(cleanedApplicationName) + ); +}; + +const focusBrowser = async ({ + nvda, + applicationName, + pageTitle, +}: { + nvda: NVDA; + applicationName: string; + pageTitle: string; +}): Promise => { + await nvda.perform(NVDAKeyCodeCommands.reportTitle); + + let windowTitle = await nvda.lastSpokenPhrase(); + + if (hasBrowserFocus({ applicationName, pageTitle, windowTitle })) { + return; + } + + let applicationSwitchRetryCount = 0; + + while (applicationSwitchRetryCount < MAX_APPLICATION_SWITCH_RETRY_COUNT) { + applicationSwitchRetryCount++; + + await nvda.perform(SWITCH_APPLICATION, { capture: false }); + await delay(500); + + await nvda.perform(NVDAKeyCodeCommands.reportTitle); + + windowTitle = await nvda.lastSpokenPhrase(); + + if (hasBrowserFocus({ applicationName, pageTitle, windowTitle })) { + break; + } + } + + if (applicationSwitchRetryCount === MAX_APPLICATION_SWITCH_RETRY_COUNT) { + throw new Error( + `Unable to focus the application window with name "${applicationName}" or title "${pageTitle}" after ${MAX_APPLICATION_SWITCH_RETRY_COUNT} attempts.`, + ); + } +}; + +export const navigateNVDAtoWebContent = async ({ + nvda, + page, + applicationName, + capture, +}: { + nvda: NVDA; + page: Page; + applicationName: string; + capture?: CommandOptions["capture"]; +}): Promise => { + const currentSpokenPhraseLog = [...(await nvda.spokenPhraseLog())]; + const currentItemTextLog = [...(await nvda.itemTextLog())]; + + await nvda.perform(NVDAKeyCodeCommands.exitFocusMode, { capture: false }); + await delay(100); + + const pageTitle = await page.title(); + await focusBrowser({ nvda, applicationName, pageTitle }); + + await page.bringToFront(); + await page.locator("body").waitFor(); + await page.locator("body").focus(); + await page.locator("body").click(); + await page.locator("body").blur(); + + // Firefox can leave NVDA focused on the whole browser after an application + // switch. Toggling browse/focus mode after moving to a focusable item restores + // browse mode. REF: https://github.com/nvaccess/nvda/issues/5758 + await nvda.perform(NVDAKeyCodeCommands.readNextFocusableItem, { + capture: false, + }); + await delay(100); + + await nvda.perform(NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode, { + capture: false, + }); + await delay(100); + + await nvda.perform(NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode, { + capture: false, + }); + await delay(100); + + await nvda.perform(NVDAKeyCodeCommands.exitFocusMode, { capture: false }); + await delay(100); + + await nvda.clearSpokenPhraseLog(); + await nvda.clearItemTextLog(); + + const spokenPhraseLog = await nvda.spokenPhraseLog(); + const itemTextLog = await nvda.itemTextLog(); + + spokenPhraseLog.push(...currentSpokenPhraseLog); + itemTextLog.push(...currentItemTextLog); + + await nvda.perform(MOVE_TO_TOP, { capture }); +}; diff --git a/src/navigateOrcaToWebContent.ts b/src/navigateOrcaToWebContent.ts new file mode 100644 index 0000000..3c808de --- /dev/null +++ b/src/navigateOrcaToWebContent.ts @@ -0,0 +1,85 @@ +import { execFileSync } from "node:child_process"; +import { + type CommandOptions, + type Orca, + orcaKeyCodeCommands, +} from "@guidepup/guidepup"; +import type { Page } from "@playwright/test"; +import { cleanString } from "./cleanString"; +import { delay } from "./delay"; + +const MAX_APPLICATION_SWITCH_RETRY_COUNT = 10; + +const focusOrcaBrowser = async ({ + orca, + pageTitle, +}: { + orca: Orca; + pageTitle: string; +}): Promise => { + const cleanedPageTitle = cleanString(pageTitle); + + if (!cleanedPageTitle) { + throw new Error( + "Cannot focus an application window with an empty page title.", + ); + } + + const currentSpokenPhraseLog = [...(await orca.spokenPhraseLog())]; + + try { + for ( + let retryCount = 0; + retryCount <= MAX_APPLICATION_SWITCH_RETRY_COUNT; + retryCount++ + ) { + await orca.clearSpokenPhraseLog(); + await orca.perform(orcaKeyCodeCommands.PresentTitle, { + capture: "initial", + }); + + const windowTitle = cleanString(await orca.lastSpokenPhrase()); + + if (windowTitle.includes(cleanedPageTitle)) { + return; + } + + if (retryCount < MAX_APPLICATION_SWITCH_RETRY_COUNT) { + execFileSync("xdotool", ["key", "--clearmodifiers", "alt+Tab"]); + await delay(100); + } + } + + throw new Error( + `Unable to focus the application window with title "${pageTitle}" after ${MAX_APPLICATION_SWITCH_RETRY_COUNT} attempts.`, + ); + } finally { + await orca.clearSpokenPhraseLog(); + + const spokenPhraseLog = await orca.spokenPhraseLog(); + + spokenPhraseLog.push(...currentSpokenPhraseLog); + } +}; + +export const navigateOrcaToWebContent = async ({ + orca, + page, + capture, +}: { + orca: Orca; + page: Page; + capture?: CommandOptions["capture"]; +}): Promise => { + const pageTitle = await page.title(); + + await focusOrcaBrowser({ orca, pageTitle }); + + await page.bringToFront(); + await page.locator("body").waitFor(); + await page.locator("body").focus(); + await page.locator("body").click(); + await page.locator("body").blur(); + + await orca.perform(orcaKeyCodeCommands.StartOfFile, { capture }); +}; diff --git a/src/navigateVoiceOverToWebContent.ts b/src/navigateVoiceOverToWebContent.ts new file mode 100644 index 0000000..a656b0a --- /dev/null +++ b/src/navigateVoiceOverToWebContent.ts @@ -0,0 +1,161 @@ +import { + type CommandOptions, + macOSActivateId, + MacOSKeyCodes, + type VoiceOver, + voiceOverKeyCodeCommands, +} from "@guidepup/guidepup"; +import type { Page } from "@playwright/test"; +import { delay } from "./delay"; + +const MAX_WEB_CONTENT_SEARCH_ATTEMPTS = 10; + +const cancelCurrentInteraction = async ( + voiceOver: VoiceOver, +): Promise => { + await voiceOver.perform( + { keyCode: MacOSKeyCodes.Control }, + { capture: false }, + ); +}; + +const closeMenus = async (voiceOver: VoiceOver): Promise => { + await voiceOver.perform( + { keyCode: MacOSKeyCodes.Escape }, + { capture: false }, + ); +}; + +const openItemChooser = async (voiceOver: VoiceOver): Promise => { + let lastSpokenPhrase = ""; + + while (!lastSpokenPhrase.toLowerCase().includes("item chooser")) { + await cancelCurrentInteraction(voiceOver); + await delay(100); + + await closeMenus(voiceOver); + await delay(100); + + await voiceOver.perform(voiceOverKeyCodeCommands.openItemChooser, { + capture: true, + }); + + lastSpokenPhrase = await voiceOver.lastSpokenPhrase(); + } +}; + +const selectWebContentItem = async (voiceOver: VoiceOver): Promise => { + let matched = false; + + for ( + let attempt = 0; + attempt < MAX_WEB_CONTENT_SEARCH_ATTEMPTS && !matched; + attempt++ + ) { + for (const character of "web content") { + await cancelCurrentInteraction(voiceOver); + await delay(100); + + await voiceOver.type(character, { capture: "initial" }); + + const lastSpokenPhrase = await voiceOver.lastSpokenPhrase(); + + if (lastSpokenPhrase.toLowerCase().includes("web content")) { + matched = true; + + break; + } + } + + if (matched) { + break; + } + + if (attempt + 1 < MAX_WEB_CONTENT_SEARCH_ATTEMPTS) { + await voiceOver.perform( + { keyCode: MacOSKeyCodes.Backspace }, + { capture: false }, + ); + await delay(100); + } + } + + if (!matched) { + throw new Error( + `Unable to find "web content" in the VoiceOver Item Chooser after ${MAX_WEB_CONTENT_SEARCH_ATTEMPTS} attempts.`, + ); + } + + await voiceOver.perform({ keyCode: MacOSKeyCodes.Enter }, { capture: false }); +}; + +export const navigateVoiceOverToWebContent = async ({ + voiceOver, + page, + applicationId, + capture, +}: { + voiceOver: VoiceOver; + page: Page; + applicationId: string; + capture?: CommandOptions["capture"]; +}): Promise => { + const currentSpokenPhraseLog = [...(await voiceOver.spokenPhraseLog())]; + const currentItemTextLog = [...(await voiceOver.itemTextLog())]; + + await macOSActivateId(applicationId); + await cancelCurrentInteraction(voiceOver); + await delay(100); + + await page.bringToFront(); + await page.locator("body").waitFor(); + + try { + await page.evaluate(() => { + const marker = document.createElement("input"); + + marker.id = "__guidepup_marker__"; + marker.type = "text"; + marker.value = "Guidepup Marker"; + marker.readOnly = true; + marker.tabIndex = -1; + marker.autocomplete = "off"; + marker.setAttribute("aria-label", "Guidepup Marker"); + marker.style.cssText = ` + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + `; + + document.body.prepend(marker); + }); + + await openItemChooser(voiceOver); + await selectWebContentItem(voiceOver); + + await voiceOver.interact({ capture: false }); + await delay(100); + + await voiceOver.clearSpokenPhraseLog(); + await voiceOver.clearItemTextLog(); + + const spokenPhraseLog = await voiceOver.spokenPhraseLog(); + const itemTextLog = await voiceOver.itemTextLog(); + + spokenPhraseLog.push(...currentSpokenPhraseLog); + itemTextLog.push(...currentItemTextLog); + + await voiceOver.next({ capture }); + } finally { + await page.evaluate(() => { + const marker = document.querySelector("#__guidepup_marker__"); + + if (marker) { + document.body.removeChild(marker); + } + }); + } +}; diff --git a/src/nvdaTest.ts b/src/nvdaTest.ts index 7e1cea5..f8bba31 100644 --- a/src/nvdaTest.ts +++ b/src/nvdaTest.ts @@ -4,11 +4,9 @@ import { nvda, type NVDA, type StartOptions, - WindowsKeyCodes, - WindowsModifiers, } from "@guidepup/guidepup"; import { applicationNameMap } from "./applicationNameMap"; -import { delay } from "./delay"; +import { navigateNVDAtoWebContent } from "./navigateNVDAtoWebContent"; type Prettify = { [K in keyof T]: T[K]; @@ -51,78 +49,6 @@ export interface NVDAPlaywright extends NVDA { const nvdaPlaywright: NVDAPlaywright = nvda as NVDAPlaywright; -const MAX_APPLICATION_SWITCH_RETRY_COUNT = 10; - -const SWITCH_APPLICATION = { - keyCode: [WindowsKeyCodes.Escape], - modifiers: [WindowsModifiers.Alt], -}; - -const MOVE_TO_TOP = { - keyCode: [WindowsKeyCodes.Home], - modifiers: [WindowsModifiers.Control], -}; - -type FocusBrowserParams = { - applicationName: string; - pageTitle: string; -}; - -const cleanString = (str: string): string => - str - .toLowerCase() - // REF: https://github.com/nvaccess/nvda/blob/master/source/locale/en/symbols.dic - .replace(/[|ยฆ:;'"`\-โ€โ€“โ€”ยท_()[\]{}\\^~]/g, " ") - .replace(/\s+/g, " ") - .trim(); - -const hasFocus = ({ - applicationName, - pageTitle, - windowTitle, -}: FocusBrowserParams & { windowTitle: string }) => { - const cleanedApplicationName = cleanString(applicationName); - const cleanedPageTitle = cleanString(pageTitle); - const cleanedWindowTitle = cleanString(windowTitle); - - return ( - (cleanedPageTitle.length && - cleanedWindowTitle.startsWith(cleanedPageTitle)) || - cleanedWindowTitle.includes(cleanedApplicationName) - ); -}; - -const focusBrowser = async ({ - applicationName, - pageTitle, -}: { - applicationName: string; - pageTitle: string; -}) => { - await nvdaPlaywright.perform(nvdaPlaywright.keyboardCommands.reportTitle); - let windowTitle = await nvdaPlaywright.lastSpokenPhrase(); - - if (hasFocus({ applicationName, pageTitle, windowTitle })) { - return; - } - - let applicationSwitchRetryCount = 0; - - while (applicationSwitchRetryCount < MAX_APPLICATION_SWITCH_RETRY_COUNT) { - applicationSwitchRetryCount++; - - await nvdaPlaywright.perform(SWITCH_APPLICATION, { capture: false }); - await delay(500); - - await nvdaPlaywright.perform(nvdaPlaywright.keyboardCommands.reportTitle); - windowTitle = await nvdaPlaywright.lastSpokenPhrase(); - - if (hasFocus({ applicationName, pageTitle, windowTitle })) { - break; - } - } -}; - /** * These tests extend the default Playwright environment that launches the * browser with a running instance of the NVDA screen reader for Windows. @@ -166,77 +92,16 @@ export const nvdaTest = test.extend<{ const applicationName = applicationNameMap[browserName]; if (!applicationName) { - throw new Error(`Browser ${browserName} is not installed.`); + throw new Error(`Browser ${browserName} is not recognised.`); } - nvdaPlaywright.navigateToWebContent = async ({ capture } = {}) => { - const currentSpokenPhraseLog = [ - ...(await nvdaPlaywright.spokenPhraseLog()), - ]; - const currentItemTextLog = [...(await nvdaPlaywright.itemTextLog())]; - - // Make sure NVDA is not in focus mode. - await nvdaPlaywright.perform( - nvdaPlaywright.keyboardCommands.exitFocusMode, - { capture: false }, - ); - await delay(100); - - // Ensure application is brought to front and focused. - const pageTitle = await page.title(); - await focusBrowser({ applicationName, pageTitle }); - - // Ensure the document is ready and focused. - await page.bringToFront(); - await page.locator("body").waitFor(); - await page.locator("body").focus(); - await page.locator("body").click(); - await page.locator("body").blur(); - - // NVDA appears to not work well with Firefox when switching between - // applications resulting in the entire browser window having NVDA focus - // with focus mode. - // - // One workaround is to tab to the next focusable item. From there we can - // toggle into (yes although we are already in it...) focus mode and back - // out. In case this ever transpires to not happen as expect, we then ensure - // we exit focus mode and move NVDA to the top of the page. - // - // REF: https://github.com/nvaccess/nvda/issues/5758 - await nvdaPlaywright.perform( - nvdaPlaywright.keyboardCommands.readNextFocusableItem, - { capture: false }, - ); - await delay(100); - await nvdaPlaywright.perform( - nvdaPlaywright.keyboardCommands.toggleBetweenBrowseAndFocusMode, - { capture: false }, - ); - await delay(100); - await nvdaPlaywright.perform( - nvdaPlaywright.keyboardCommands.toggleBetweenBrowseAndFocusMode, - { capture: false }, - ); - await delay(100); - await nvdaPlaywright.perform( - nvdaPlaywright.keyboardCommands.exitFocusMode, - { capture: false }, - ); - await delay(100); - - await nvdaPlaywright.clearSpokenPhraseLog(); - await nvdaPlaywright.clearItemTextLog(); - - const spokenPhraseLog = await nvdaPlaywright.spokenPhraseLog(); - const itemTextLog = await nvdaPlaywright.itemTextLog(); - - spokenPhraseLog.push(...currentSpokenPhraseLog); - itemTextLog.push(...currentItemTextLog); - - // Navigate to the beginning of the web content, using chosen capture - // settings, so don't miss announcing the first item on the page. - await nvdaPlaywright.perform(MOVE_TO_TOP, { capture }); - }; + nvdaPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateNVDAtoWebContent({ + nvda: nvdaPlaywright, + page, + applicationName, + capture, + }); await nvdaPlaywright.start(nvdaStartOptions); await use(nvdaPlaywright); diff --git a/src/orcaTest.ts b/src/orcaTest.ts new file mode 100644 index 0000000..b03291c --- /dev/null +++ b/src/orcaTest.ts @@ -0,0 +1,107 @@ +import { test } from "@playwright/test"; +import { + type CommandOptions, + type Orca, + type StartOptions, + unstable_orca, +} from "@guidepup/guidepup"; +import { navigateOrcaToWebContent } from "./navigateOrcaToWebContent"; + +/** + * [API Reference](https://www.guidepup.dev/docs/api/class-orca) + * + * This object can be used to launch and control Orca. + * + * Here's a typical example: + * + * ```ts + * import { unstable_orca } from "@guidepup/guidepup"; + * + * (async () => { + * // Start Orca. + * await unstable_orca.start(); + * + * // Move to the next item. + * await unstable_orca.next(); + * + * // ... perform some commands. + * + * // Stop Orca. + * await unstable_orca.stop(); + * })(); + * ``` + */ +export interface OrcaPlaywright extends Orca { + /** + * Guidepup Playwright specific command that navigates Orca to the beginning + * of the browser's web content. + * + * This command should be used after a page navigation has completed. + */ + navigateToWebContent( + options?: Pick, + ): Promise; +} + +const orcaPlaywright: OrcaPlaywright = unstable_orca as OrcaPlaywright; + +/** + * These tests extend the default Playwright environment that launches the + * browser with a running instance of the Orca screen reader for macOS. + * + * A fresh started Orca instance `orca` is provided to each test. + */ +export const orcaTest = test.extend<{ + /** + * [API Reference](https://www.guidepup.dev/docs/api/class-orca) + * + * This object can be used to launch and control Orca. + * + * Here's a typical example: + * + * ```ts + * import { unstable_orca } from "@guidepup/guidepup"; + * + * (async () => { + * // Start Orca. + * await unstable_orca.start(); + * + * // Move to the next item. + * await unstable_orca.next(); + * + * // ... perform some commands. + * + * // Stop Orca. + * await unstable_orca.stop(); + * })(); + * ``` + */ + orca: OrcaPlaywright; + /** + * [API Reference](https://www.guidepup.dev/docs/api/class-start-options) + * + * Options to start Orca with, see also [unstable_orca.start([options])](https://www.guidepup.dev/docs/api/class-orca#unstable-orca-start). + */ + orcaStartOptions: StartOptions; +}>({ + orcaStartOptions: { capture: "initial" }, + orca: async ({ page, orcaStartOptions }, use) => { + try { + orcaPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateOrcaToWebContent({ + orca: orcaPlaywright, + page, + capture, + }); + + await orcaPlaywright.start(orcaStartOptions); + await use(orcaPlaywright); + } finally { + try { + await orcaPlaywright.stop(); + } catch { + // swallow stop failure + } + } + }, +}); diff --git a/src/screenReaderTest.ts b/src/screenReaderTest.ts index b8f8229..103f896 100644 --- a/src/screenReaderTest.ts +++ b/src/screenReaderTest.ts @@ -1,20 +1,18 @@ import { test } from "@playwright/test"; import { type CommandOptions, - macOSActivate, - MacOSKeyCodes, nvda, - NVDAKeyCodeCommands, screenReader, type ScreenReader, type StartOptions, + unstable_orca, voiceOver, - voiceOverKeyCodeCommands, - WindowsKeyCodes, - WindowsModifiers, } from "@guidepup/guidepup"; import { applicationNameMap } from "./applicationNameMap"; -import { delay } from "./delay"; +import { navigateNVDAtoWebContent } from "./navigateNVDAtoWebContent"; +import { navigateOrcaToWebContent } from "./navigateOrcaToWebContent"; +import { navigateVoiceOverToWebContent } from "./navigateVoiceOverToWebContent"; +import { applicationIdMap } from "./applicationIdMap"; type Prettify = { [K in keyof T]: T[K]; @@ -23,81 +21,9 @@ type Prettify = { type CaptureCommandOptions = Prettify>; type CaptureStartOptions = Prettify>; -const MAX_APPLICATION_SWITCH_RETRY_COUNT = 10; - -const SWITCH_APPLICATION = { - keyCode: [WindowsKeyCodes.Escape], - modifiers: [WindowsModifiers.Alt], -}; - -const MOVE_TO_TOP = { - keyCode: [WindowsKeyCodes.Home], - modifiers: [WindowsModifiers.Control], -}; - -type FocusBrowserParams = { - applicationName: string; - pageTitle: string; -}; - -const cleanString = (str: string): string => - str - .toLowerCase() - // REF: https://github.com/nvaccess/nvda/blob/master/source/locale/en/symbols.dic - .replace(/[|ยฆ:;'"`\-โ€โ€“โ€”ยท_()[\]{}\\^~]/g, " ") - .replace(/\s+/g, " ") - .trim(); - -const hasFocus = ({ - applicationName, - pageTitle, - windowTitle, -}: FocusBrowserParams & { windowTitle: string }) => { - const cleanedApplicationName = cleanString(applicationName); - const cleanedPageTitle = cleanString(pageTitle); - const cleanedWindowTitle = cleanString(windowTitle); - - return ( - (cleanedPageTitle.length && - cleanedWindowTitle.startsWith(cleanedPageTitle)) || - cleanedWindowTitle.includes(cleanedApplicationName) - ); -}; - -const focusBrowser = async ({ - applicationName, - pageTitle, -}: { - applicationName: string; - pageTitle: string; -}) => { - await screenReaderPlaywright.perform(NVDAKeyCodeCommands.reportTitle); - let windowTitle = await screenReaderPlaywright.lastSpokenPhrase(); - - if (hasFocus({ applicationName, pageTitle, windowTitle })) { - return; - } - - let applicationSwitchRetryCount = 0; - - while (applicationSwitchRetryCount < MAX_APPLICATION_SWITCH_RETRY_COUNT) { - applicationSwitchRetryCount++; - - await screenReaderPlaywright.perform(SWITCH_APPLICATION, { - capture: false, - }); - await delay(500); - - await screenReaderPlaywright.perform(NVDAKeyCodeCommands.reportTitle); - windowTitle = await screenReaderPlaywright.lastSpokenPhrase(); - - if (hasFocus({ applicationName, pageTitle, windowTitle })) { - break; - } - } -}; - /** + * [API Reference](https://www.guidepup.dev/docs/api/class-screenreader) + * * This object can be used to launch and control the default screen reader for * the environment. * @@ -130,91 +56,6 @@ export interface ScreenReaderPlaywright extends ScreenReader { navigateToWebContent(options?: CaptureCommandOptions): Promise; } -const closeMenus = async ({ - screenReaderPlaywright, -}: { - screenReaderPlaywright: ScreenReaderPlaywright; -}) => { - await screenReaderPlaywright.perform( - { keyCode: MacOSKeyCodes.Escape }, - { capture: false }, - ); -}; - -const cancelCurrentInteraction = async ({ - screenReaderPlaywright, -}: { - screenReaderPlaywright: ScreenReaderPlaywright; -}) => { - await screenReaderPlaywright.perform( - { keyCode: MacOSKeyCodes.Control }, - { capture: false }, - ); -}; - -const openItemChooser = async ({ - screenReaderPlaywright, -}: { - screenReaderPlaywright: ScreenReaderPlaywright; -}) => { - let lastSpokenPhrase = ""; - - while (!lastSpokenPhrase.toLowerCase().includes("item chooser")) { - await cancelCurrentInteraction({ screenReaderPlaywright }); - await delay(100); - await closeMenus({ screenReaderPlaywright }); - await delay(100); - - await screenReaderPlaywright.perform( - voiceOverKeyCodeCommands.openItemChooser, - { capture: true }, - ); - - lastSpokenPhrase = await screenReaderPlaywright.lastSpokenPhrase(); - } -}; - -async function selectWebContentItem({ - screenReaderPlaywright, -}: { - screenReaderPlaywright: ScreenReaderPlaywright; -}) { - // eslint-disable-next-line no-constant-condition - while (true) { - let matched = false; - - for (const character of "web content") { - await cancelCurrentInteraction({ screenReaderPlaywright }); - await delay(100); - - await screenReaderPlaywright.type(character, { capture: "initial" }); - const lastSpokenPhrase = await screenReaderPlaywright.lastSpokenPhrase(); - - if (lastSpokenPhrase.toLowerCase().includes("web content")) { - matched = true; - - break; - } - } - - if (matched) { - break; - } - - // Clear the entire Item Chooser search. - await screenReaderPlaywright.perform( - { keyCode: MacOSKeyCodes.Backspace }, - { capture: false }, - ); - await delay(100); - } - - await screenReaderPlaywright.perform( - { keyCode: MacOSKeyCodes.Enter }, - { capture: false }, - ); -} - const screenReaderPlaywright: ScreenReaderPlaywright = screenReader as ScreenReaderPlaywright; @@ -228,6 +69,8 @@ const screenReaderPlaywright: ScreenReaderPlaywright = */ export const screenReaderTest = test.extend<{ /** + * [API Reference](https://www.guidepup.dev/docs/api/class-screenreader) + * * This object can be used to launch and control the default screen reader for * the environment. * @@ -252,7 +95,9 @@ export const screenReaderTest = test.extend<{ */ screenReader: ScreenReaderPlaywright; /** - * Options to start the default screen reader with. + * [API Reference](https://www.guidepup.dev/docs/api/class-start-options) + * + * Options to start the default screen reader with, see also [screenReader.start([options])](https://www.guidepup.dev/docs/api/class-screenreader#screenreader-start). */ screenReaderStartOptions: CaptureStartOptions; }>({ @@ -263,166 +108,35 @@ export const screenReaderTest = test.extend<{ ) => { try { const applicationName = applicationNameMap[browserName]; + const applicationId = applicationIdMap[browserName]; - if (!applicationName) { - throw new Error(`Browser ${browserName} is not installed.`); + if (!applicationName || !applicationId) { + throw new Error(`Browser ${browserName} is not recognised.`); } if (nvda.default()) { - screenReaderPlaywright.navigateToWebContent = async ({ - capture, - } = {}) => { - const currentSpokenPhraseLog = [ - ...(await screenReaderPlaywright.spokenPhraseLog()), - ]; - const currentItemTextLog = [ - ...(await screenReaderPlaywright.itemTextLog()), - ]; - - // Make sure NVDA is not in focus mode. - await screenReaderPlaywright.perform( - NVDAKeyCodeCommands.exitFocusMode, - { capture: false }, - ); - await delay(100); - - // Ensure application is brought to front and focused. - const pageTitle = await page.title(); - await focusBrowser({ applicationName, pageTitle }); - - // Ensure the document is ready and focused. - await page.bringToFront(); - await page.locator("body").waitFor(); - await page.locator("body").focus(); - await page.locator("body").click(); - await page.locator("body").blur(); - - // NVDA appears to not work well with Firefox when switching between - // applications resulting in the entire browser window having NVDA focus - // with focus mode. - // - // One workaround is to tab to the next focusable item. From there we can - // toggle into (yes although we are already in it...) focus mode and back - // out. In case this ever transpires to not happen as expect, we then ensure - // we exit focus mode and move NVDA to the top of the page. - // - // REF: https://github.com/nvaccess/nvda/issues/5758 - await screenReaderPlaywright.perform( - NVDAKeyCodeCommands.readNextFocusableItem, - { capture: false }, - ); - await delay(100); - await screenReaderPlaywright.perform( - NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode, - { capture: false }, - ); - await delay(100); - await screenReaderPlaywright.perform( - NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode, - { capture: false }, - ); - await delay(100); - await screenReaderPlaywright.perform( - NVDAKeyCodeCommands.exitFocusMode, - { capture: false }, - ); - await delay(100); - - await screenReaderPlaywright.clearSpokenPhraseLog(); - await screenReaderPlaywright.clearItemTextLog(); - - const spokenPhraseLog = - await screenReaderPlaywright.spokenPhraseLog(); - const itemTextLog = await screenReaderPlaywright.itemTextLog(); - - spokenPhraseLog.push(...currentSpokenPhraseLog); - itemTextLog.push(...currentItemTextLog); - - // Navigate to the beginning of the web content, using chosen capture - // settings, so don't miss announcing the first item on the page. - await screenReaderPlaywright.perform(MOVE_TO_TOP, { capture }); - }; + screenReaderPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateNVDAtoWebContent({ + nvda, + page, + applicationName, + capture, + }); } else if (voiceOver.default()) { - screenReaderPlaywright.navigateToWebContent = async ({ - capture, - } = {}) => { - const currentSpokenPhraseLog = [ - ...(await screenReaderPlaywright.spokenPhraseLog()), - ]; - const currentItemTextLog = [ - ...(await screenReaderPlaywright.itemTextLog()), - ]; - - // Ensure application is brought to front and focused. - await macOSActivate(applicationName); - - // Cancel automatic behaviours/previous commands. - await cancelCurrentInteraction({ screenReaderPlaywright }); - await delay(100); - - // Ensure the document is ready and focused. - await page.bringToFront(); - await page.locator("body").waitFor(); - - try { - // Add an interactive marker to the page that will force VoiceOver - // to listen to events emitted by Playwright interactions when - // navigated to. - await page.evaluate(() => { - const marker = document.createElement("input"); - - marker.id = "__guidepup_marker__"; - marker.type = "text"; - marker.value = "Guidepup Marker"; - marker.readOnly = true; - marker.tabIndex = -1; - marker.autocomplete = "off"; - marker.setAttribute("aria-label", "Guidepup Marker"); - marker.style.cssText = ` - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - `; - - document.body.prepend(marker); - }); - - // Open item chooser and select web content. - await openItemChooser({ screenReaderPlaywright }); - await selectWebContentItem({ screenReaderPlaywright }); - - // Navigate into web content. - await screenReaderPlaywright.interact({ capture: false }); - await delay(100); - - await screenReaderPlaywright.clearSpokenPhraseLog(); - await screenReaderPlaywright.clearItemTextLog(); - - const spokenPhraseLog = - await screenReaderPlaywright.spokenPhraseLog(); - const itemTextLog = await screenReaderPlaywright.itemTextLog(); - - spokenPhraseLog.push(...currentSpokenPhraseLog); - itemTextLog.push(...currentItemTextLog); - - // Navigate to the first element of the page using the provided - // capture settings. - await screenReaderPlaywright.next({ capture }); - } finally { - // Remove the temporary Guidepup marker element to restore original - // page structure. - await page.evaluate(() => { - const marker = document.querySelector("#__guidepup_marker__"); - - if (marker) { - document.body.removeChild(marker); - } - }); - } - }; + screenReaderPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateVoiceOverToWebContent({ + voiceOver, + page, + applicationId, + capture, + }); + } else if (unstable_orca.default()) { + screenReaderPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateOrcaToWebContent({ + orca: unstable_orca, + page, + capture, + }); } else { throw new Error("No supported screen reader"); } diff --git a/src/voiceOverTest.ts b/src/voiceOverTest.ts index f8123e5..b404b1c 100644 --- a/src/voiceOverTest.ts +++ b/src/voiceOverTest.ts @@ -1,12 +1,12 @@ -import { test } from "@playwright/test"; -import { voiceOver, macOSActivate, MacOSKeyCodes } from "@guidepup/guidepup"; -import type { - StartOptions, - CommandOptions, - VoiceOver, +import { + type CommandOptions, + type StartOptions, + type VoiceOver, + voiceOver, } from "@guidepup/guidepup"; -import { applicationNameMap } from "./applicationNameMap"; -import { delay } from "./delay"; +import { applicationIdMap } from "./applicationIdMap"; +import { navigateVoiceOverToWebContent } from "./navigateVoiceOverToWebContent"; +import { test } from "@playwright/test"; /** * [API Reference](https://www.guidepup.dev/docs/api/class-voiceover) @@ -44,97 +44,12 @@ export interface VoiceOverPlaywright extends VoiceOver { ): Promise; } -const closeMenus = async ({ - voiceOverPlaywright, -}: { - voiceOverPlaywright: VoiceOverPlaywright; -}) => { - await voiceOverPlaywright.perform( - { keyCode: MacOSKeyCodes.Escape }, - { capture: false }, - ); -}; - -const cancelCurrentInteraction = async ({ - voiceOverPlaywright, -}: { - voiceOverPlaywright: VoiceOverPlaywright; -}) => { - await voiceOverPlaywright.perform( - { keyCode: MacOSKeyCodes.Control }, - { capture: false }, - ); -}; - -const openItemChooser = async ({ - voiceOverPlaywright, -}: { - voiceOverPlaywright: VoiceOverPlaywright; -}) => { - let lastSpokenPhrase = ""; - - while (!lastSpokenPhrase.toLowerCase().includes("item chooser")) { - await cancelCurrentInteraction({ voiceOverPlaywright }); - await delay(100); - await closeMenus({ voiceOverPlaywright }); - await delay(100); - - await voiceOverPlaywright.perform( - voiceOverPlaywright.keyboardCommands.openItemChooser, - { capture: true }, - ); - - lastSpokenPhrase = await voiceOverPlaywright.lastSpokenPhrase(); - } -}; - -async function selectWebContentItem({ - voiceOverPlaywright, -}: { - voiceOverPlaywright: VoiceOverPlaywright; -}) { - // eslint-disable-next-line no-constant-condition - while (true) { - let matched = false; - - for (const character of "web content") { - await cancelCurrentInteraction({ voiceOverPlaywright }); - await delay(100); - - await voiceOverPlaywright.type(character, { capture: "initial" }); - const lastSpokenPhrase = await voiceOverPlaywright.lastSpokenPhrase(); - - if (lastSpokenPhrase.toLowerCase().includes("web content")) { - matched = true; - - break; - } - } - - if (matched) { - break; - } - - // Clear the entire Item Chooser search. - await voiceOverPlaywright.perform( - { keyCode: MacOSKeyCodes.Backspace }, - { capture: false }, - ); - await delay(100); - } - - await voiceOverPlaywright.perform( - { keyCode: MacOSKeyCodes.Enter }, - { capture: false }, - ); -} - const voiceOverPlaywright: VoiceOverPlaywright = voiceOver as VoiceOverPlaywright; /** * These tests extend the default Playwright environment that launches the - * browser with a running instance of the VoiceOver screen reader for MacOS. + * browser with a running instance of the VoiceOver screen reader for macOS. * * A fresh started VoiceOver instance `voiceOver` is provided to each test. */ @@ -174,89 +89,19 @@ export const voiceOverTest = test.extend<{ voiceOverStartOptions: { capture: "initial" }, voiceOver: async ({ browserName, page, voiceOverStartOptions }, use) => { try { - const applicationName = applicationNameMap[browserName]; + const applicationId = applicationIdMap[browserName]; - if (!applicationName) { - throw new Error(`Browser ${browserName} is not installed.`); + if (!applicationId) { + throw new Error(`Browser ${browserName} is not recognised.`); } - voiceOverPlaywright.navigateToWebContent = async ({ capture } = {}) => { - const currentSpokenPhraseLog = [ - ...(await voiceOverPlaywright.spokenPhraseLog()), - ]; - const currentItemTextLog = [ - ...(await voiceOverPlaywright.itemTextLog()), - ]; - - // Ensure application is brought to front and focused. - await macOSActivate(applicationName); - - // Cancel automatic behaviours/previous commands. - await cancelCurrentInteraction({ voiceOverPlaywright }); - await delay(100); - - // Ensure the document is ready and focused. - await page.bringToFront(); - await page.locator("body").waitFor(); - - try { - // Add an interactive marker to the page that will force VoiceOver - // to listen to events emitted by Playwright interactions when - // navigated to. - await page.evaluate(() => { - const marker = document.createElement("input"); - - marker.id = "__guidepup_marker__"; - marker.type = "text"; - marker.value = "Guidepup Marker"; - marker.readOnly = true; - marker.tabIndex = -1; - marker.autocomplete = "off"; - marker.setAttribute("aria-label", "Guidepup Marker"); - marker.style.cssText = ` - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - `; - - document.body.prepend(marker); - }); - - // Open item chooser and select web content. - await openItemChooser({ voiceOverPlaywright }); - await selectWebContentItem({ voiceOverPlaywright }); - - // Navigate into web content. - await voiceOverPlaywright.interact({ capture: false }); - await delay(100); - - await voiceOverPlaywright.clearSpokenPhraseLog(); - await voiceOverPlaywright.clearItemTextLog(); - - const spokenPhraseLog = await voiceOverPlaywright.spokenPhraseLog(); - const itemTextLog = await voiceOverPlaywright.itemTextLog(); - - spokenPhraseLog.push(...currentSpokenPhraseLog); - itemTextLog.push(...currentItemTextLog); - - // Navigate to the first element of the page using the provided - // capture settings. - await voiceOverPlaywright.next({ capture }); - } finally { - // Remove the temporary Guidepup marker element to restore original - // page structure. - await page.evaluate(() => { - const marker = document.querySelector("#__guidepup_marker__"); - - if (marker) { - document.body.removeChild(marker); - } - }); - } - }; + voiceOverPlaywright.navigateToWebContent = ({ capture } = {}) => + navigateVoiceOverToWebContent({ + voiceOver: voiceOverPlaywright, + page, + applicationId, + capture, + }); await voiceOverPlaywright.start(voiceOverStartOptions); await use(voiceOverPlaywright); diff --git a/yarn.lock b/yarn.lock index df88107..a548423 100644 --- a/yarn.lock +++ b/yarn.lock @@ -62,17 +62,19 @@ "@shikijs/types" "^3.23.0" "@shikijs/vscode-textmate" "^10.0.2" -"@guidepup/guidepup@^0.33.0": - version "0.33.0" - resolved "https://registry.yarnpkg.com/@guidepup/guidepup/-/guidepup-0.33.0.tgz#60025daa906f5c055e60a095c27dad1f02359264" - integrity sha512-CNFMsUtalwr9varDvevUwv8vKs3VWDshsMehlUGuEkxE4BiQ+Dlz2j0A1LSvPcdIN40oNi98sx4sXHygjdfR3g== +"@guidepup/guidepup@^0.36.1": + version "0.36.1" + resolved "https://registry.yarnpkg.com/@guidepup/guidepup/-/guidepup-0.36.1.tgz#dff3bcb0233f229f36c17e507a845723987d5d05" + integrity sha512-Pucx6sFUeGSeh2Q0bE4JtoH2z0a+BC6yNIFYpBBic6jDI6I1zk5duvg5Jmn0tMN9VSQbGM1KHSXRI//tm9d4rA== dependencies: + dbus-native "^0.15.1" + debug "^4.4.3" plist "^4.0.0" -"@guidepup/record@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@guidepup/record/-/record-0.1.0.tgz#03acb4286aab208037a4d5ef65fc2f5615ce485f" - integrity sha512-jtSqZ0CZVdWzWIbLWHYoUi1IbNJVKns20oEYzwcTftw8rY5RXsK1OTXaXFx9Gup5mnmYaFLnON8WwTS+4DYwrQ== +"@guidepup/record@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@guidepup/record/-/record-0.3.0.tgz#0bf0861d12da27592b0032d04efbb44fad3234f4" + integrity sha512-0PyoVl9yt/k34oYCWoo3WUKy452zsJDrz1xOq0SKeQM0RDD8M1emTRNEjuFU7+/BpZHisWaNXRRGIrnGvEkizg== dependencies: ffmpeg-static "^5.2.0" @@ -474,6 +476,13 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" +dbus-native@^0.15.1: + version "0.15.2" + resolved "https://registry.yarnpkg.com/dbus-native/-/dbus-native-0.15.2.tgz#ac6094e7c95f9e60bdfb7f7229a4adbc9296d861" + integrity sha512-J1IBi4LmbqrFhroN66Dj1tCCTP6TVbZgUKhWQZf3uS6t93yRWEcRRDjOZV9N6boGRhOp/BOjLliFm9vWu1QZOA== + dependencies: + xml2js "^0.6.2" + debug@4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -1112,6 +1121,11 @@ safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +sax@>=0.6.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.1.tgz#4c23cf608c0b693ab54b4b5888e92cfe977b9843" + integrity sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q== + semver@^7.7.3: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" @@ -1278,11 +1292,24 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +xml2js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + yaml@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" From 06896a16819cc6da51fc65c4f7419ead00823ecc Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 19:42:47 +0100 Subject: [PATCH 2/6] test: add an orca test suite --- examples/playwright-nvda/README.md | 3 +- examples/playwright-orca/README.md | 17 +++++ examples/playwright-orca/firefox.config.ts | 18 +++++ .../tests/firefox/firefox.spec.ts | 65 ++++++++++++++++++ .../firefox.spokenPhrase.snapshot.json | 11 +++ .../playwright-orca/tests/headerNavigation.ts | 67 +++++++++++++++++++ examples/playwright-screenreader/README.md | 5 +- examples/playwright-voiceover/README.md | 3 +- 8 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 examples/playwright-orca/README.md create mode 100644 examples/playwright-orca/firefox.config.ts create mode 100644 examples/playwright-orca/tests/firefox/firefox.spec.ts create mode 100644 examples/playwright-orca/tests/firefox/firefox.spokenPhrase.snapshot.json create mode 100644 examples/playwright-orca/tests/headerNavigation.ts diff --git a/examples/playwright-nvda/README.md b/examples/playwright-nvda/README.md index 4d6960d..60db23c 100644 --- a/examples/playwright-nvda/README.md +++ b/examples/playwright-nvda/README.md @@ -13,6 +13,5 @@ npm run test ## Test flow 1. The test launches the browser using Playwright -2. Navigates to the GitHub website +2. Navigates to the Guidepup website 3. Moves through the website using NVDA controlled by Guidepup -4. Traverses headings until the Guidepup heading in the README.md is found diff --git a/examples/playwright-orca/README.md b/examples/playwright-orca/README.md new file mode 100644 index 0000000..6608567 --- /dev/null +++ b/examples/playwright-orca/README.md @@ -0,0 +1,17 @@ +# Orca Example + +An example demonstrating using Guidepup for testing Orca automation with [Playwright](https://playwright.dev/). + +Run this example's test with the following from the root directory: + +```console +xvfb-run dbus-run-session -- npm run test +``` + +> Note: please ensure you have set up the [Orca prerequisites](https://www.guidepup.dev/docs/guides/environment) before running this example. + +## Test flow + +1. The test launches the browser using Playwright +2. Navigates to the Guidepup website +3. Moves through the website using Orca controlled by Guidepup diff --git a/examples/playwright-orca/firefox.config.ts b/examples/playwright-orca/firefox.config.ts new file mode 100644 index 0000000..40e4349 --- /dev/null +++ b/examples/playwright-orca/firefox.config.ts @@ -0,0 +1,18 @@ +import { devices, PlaywrightTestConfig } from "@playwright/test"; +import { screenReaderConfig } from "../../src"; + +const config: PlaywrightTestConfig = { + ...screenReaderConfig, + reportSlowTests: null, + timeout: 5 * 60 * 1000, + retries: 5, + projects: [ + { + name: "firefox", + use: { ...devices["Desktop Firefox"], headless: false }, + }, + ], + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", +}; + +export default config; diff --git a/examples/playwright-orca/tests/firefox/firefox.spec.ts b/examples/playwright-orca/tests/firefox/firefox.spec.ts new file mode 100644 index 0000000..fd50625 --- /dev/null +++ b/examples/playwright-orca/tests/firefox/firefox.spec.ts @@ -0,0 +1,65 @@ +import { platform, release } from "os"; +import { headerNavigation } from "../headerNavigation"; +import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; +import spokenPhraseSnapshot from "./firefox.spokenPhrase.snapshot.json"; +import { orcaTest as test } from "../../../../src"; + +const record = async (filepath: string) => { + try { + const { linuxRecord } = await import("@guidepup/record"); + + return linuxRecord(filepath); + } catch { + console.warn( + "@guidepup/record not available. Recording will be skipped. This is expected on platforms without ffmpeg support (e.g., Windows ARM64).", + ); + } +}; + +test.use({ orcaStartOptions: { capture: true } }); + +test.describe("Firefox Playwright Orca", () => { + test("I can navigate the Guidepup documentation site", async ({ + browser, + browserName, + page, + orca, + }) => { + const osName = platform(); + const osVersion = release(); + const browserVersion = browser.version(); + const screenReaderName = orca.name; + const screenReaderVersion = orca.version; + const { retry } = test.info(); + const recordingFilePath = `./recordings/playwright-orca-${osName}-${osVersion}-${browserName}-${browserVersion}-attempt-${retry}-${+new Date()}.mov`; + + console.table({ + osName, + osVersion, + browserName, + browserVersion, + screenReaderName, + screenReaderVersion, + retry, + }); + + let stopRecording: (() => Promise) | undefined; + + try { + stopRecording = await record(recordingFilePath); + + await headerNavigation({ page, orca }); + + // Assert that we've ended up where we expected and what we were told on + // the way there is as expected. + + const spokenPhraseLog = await orca.spokenPhraseLog(); + + console.log(JSON.stringify(spokenPhraseLog, undefined, 2)); + + logIncludesExpectedPhrases(spokenPhraseLog, spokenPhraseSnapshot); + } finally { + await stopRecording?.(); + } + }); +}); diff --git a/examples/playwright-orca/tests/firefox/firefox.spokenPhrase.snapshot.json b/examples/playwright-orca/tests/firefox/firefox.spokenPhrase.snapshot.json new file mode 100644 index 0000000..1f3d87a --- /dev/null +++ b/examples/playwright-orca/tests/firefox/firefox.spokenPhrase.snapshot.json @@ -0,0 +1,11 @@ +[ + "Guidepup. heading 1", + "Reliable Automation For Your Screen Reader A11y Workflows Through JavaScript", + "Full Control", + "Mirrors Real User Experience", + "Framework Agnostic", + "Run with Jest, with Playwright, as an. paragraph", + "Get Started. link", + "GitHub. link", + "Get Started. link" +] diff --git a/examples/playwright-orca/tests/headerNavigation.ts b/examples/playwright-orca/tests/headerNavigation.ts new file mode 100644 index 0000000..5b9a715 --- /dev/null +++ b/examples/playwright-orca/tests/headerNavigation.ts @@ -0,0 +1,67 @@ +import { expect, type Page } from "@playwright/test"; +import { delay } from "../../../src/delay"; +import { log } from "../../log"; +import type { OrcaPlaywright } from "../../../src"; + +const MAX_NAVIGATION_LOOP = 10; + +export async function headerNavigation({ + page, + orca, +}: { + page: Page; + orca: OrcaPlaywright; +}) { + // Navigate to Guidepup Website ๐ŸŽ‰ + log("Navigating to URL: https://www.guidepup.dev."); + await page.goto("https://www.guidepup.dev", { + waitUntil: "load", + }); + + // Wait for page to be ready and interact ๐Ÿ™Œ + const header = page.locator("h1"); + await header.waitFor(); + await delay(500); + + // Make sure interacting with the web content + await orca.navigateToWebContent(); + await delay(500); + + let headingCount = 0; + + // Move across the headings using VoiceOver ๐Ÿ”Ž + while ( + !(await orca.lastSpokenPhrase()).includes("Framework Agnostic") && + headingCount <= MAX_NAVIGATION_LOOP + ) { + headingCount++; + + log(`Performing command: "H" - "Find the next heading"`); + await orca.nextHeading(); + log(`Screen reader output: "${await orca.lastSpokenPhrase()}".`); + } + + let tabCount = 0; + + // Move across text and buttons using Orca + while ( + !(await orca.lastSpokenPhrase()).replaceAll(/\s/g, "").includes("GitHub") && + tabCount <= MAX_NAVIGATION_LOOP + ) { + tabCount++; + + log(`Performing command: "Orca+Ctrl+Right Arrow"`); + await orca.next(); + log(`Screen reader output: "${await orca.lastSpokenPhrase()}".`); + } + + log(`Performing command: "Orca+Ctrl+Left Arrow"`); + await orca.previous(); + log(`Screen reader output: "${await orca.lastSpokenPhrase()}".`); + + log(`Performing command: "Orca+Ctrl+Enter"`); + await orca.act(); + log(`Screen reader output: "${await orca.lastSpokenPhrase()}".`); + + await expect(page).toHaveURL("https://www.guidepup.dev/docs/getting-started"); +} diff --git a/examples/playwright-screenreader/README.md b/examples/playwright-screenreader/README.md index 3b3065a..8c19cd7 100644 --- a/examples/playwright-screenreader/README.md +++ b/examples/playwright-screenreader/README.md @@ -13,6 +13,5 @@ npm run test ## Test flow 1. The test launches the browser using Playwright -2. Navigates to the GitHub website -3. Moves through the website using the default screen reader for the environment, controlled by Guidepup -4. Traverses headings until the Guidepup heading in the README.md is found +2. Navigates to the Guidepup website +3. Moves through the website using the default screen reader for the OS, controlled by Guidepup diff --git a/examples/playwright-voiceover/README.md b/examples/playwright-voiceover/README.md index 35977a1..602ec29 100644 --- a/examples/playwright-voiceover/README.md +++ b/examples/playwright-voiceover/README.md @@ -13,6 +13,5 @@ npm run test ## Test flow 1. The test launches the browser using Playwright -2. Navigates to the GitHub website +2. Navigates to the Guidepup website 3. Moves through the website using VoiceOver controlled by Guidepup -4. Traverses headings until the Guidepup heading in the README.md is found From cf030df30ff460916079381f40b5163e82dad890 Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 19:47:59 +0100 Subject: [PATCH 3/6] test: add missing firefox config --- .../playwright-screenreader/firefox.config.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/playwright-screenreader/firefox.config.ts diff --git a/examples/playwright-screenreader/firefox.config.ts b/examples/playwright-screenreader/firefox.config.ts new file mode 100644 index 0000000..40e4349 --- /dev/null +++ b/examples/playwright-screenreader/firefox.config.ts @@ -0,0 +1,18 @@ +import { devices, PlaywrightTestConfig } from "@playwright/test"; +import { screenReaderConfig } from "../../src"; + +const config: PlaywrightTestConfig = { + ...screenReaderConfig, + reportSlowTests: null, + timeout: 5 * 60 * 1000, + retries: 5, + projects: [ + { + name: "firefox", + use: { ...devices["Desktop Firefox"], headless: false }, + }, + ], + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", +}; + +export default config; From f07ef352ea3a8dcd1664a76de7fb735055bd0553 Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 19:50:47 +0100 Subject: [PATCH 4/6] test: fix import path --- .../tests/firefox/firefox-live-region.spec.ts | 2 +- .../playwright-screenreader/tests/firefox/firefox-orca.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts b/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts index 35fb913..da59d61 100644 --- a/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts +++ b/examples/playwright-screenreader/tests/firefox/firefox-live-region.spec.ts @@ -2,7 +2,7 @@ import { platform, release } from "os"; import { delay } from "../../../../src/delay"; import { expect } from "@playwright/test"; import { log } from "../../../log"; -import { screenReaderTest as test } from "../../screenreader-test"; +import { screenReaderTest as test } from "../../../../src"; const MAX_NAVIGATION_LOOP = 10; diff --git a/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts b/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts index 8448170..03181da 100644 --- a/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts +++ b/examples/playwright-screenreader/tests/firefox/firefox-orca.spec.ts @@ -3,7 +3,7 @@ import { delay } from "../../../../src/delay"; import { log } from "../../../log"; import { logIncludesExpectedPhrases } from "../../../logIncludesExpectedPhrases"; import spokenPhraseSnapshot from "./firefox.spokenPhrase.snapshot.json"; -import { screenReaderTest as test } from "../../screenreader-test"; +import { screenReaderTest as test } from "../../../../src"; const MAX_NAVIGATION_LOOP = 10; From 89679082d18c0c019a29a257faafc6e40901ed75 Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 19:54:00 +0100 Subject: [PATCH 5/6] ci: add `xvfb-run dbus-run-session` pre-req command --- .github/workflows/test-screenreader.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-screenreader.yml b/.github/workflows/test-screenreader.yml index 63a7ed9..277a001 100644 --- a/.github/workflows/test-screenreader.yml +++ b/.github/workflows/test-screenreader.yml @@ -36,7 +36,7 @@ jobs: **/test-results/**/* **/recordings/**/* - test-screenreader-firefox: + test-screenreader-orca-firefox: runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -52,7 +52,7 @@ jobs: - run: yarn install --frozen-lockfile - run: yarn pretest - run: npx -y --min-release-age=0 --ignore-scripts=false @guidepup/setup@0.29.2 install - - run: yarn test:screenreader:${{ matrix.browser }} + - run: xvfb-run dbus-run-session -- yarn test:screenreader:${{ matrix.browser }} - uses: actions/upload-artifact@v7 if: always() continue-on-error: true From 897083f3b24f58ef51f66939ee072fd358f7b79a Mon Sep 17 00:00:00 2001 From: cmorten Date: Wed, 14 Oct 2026 20:11:02 +0100 Subject: [PATCH 6/6] feat: bump playwright deps --- package.json | 4 ++-- yarn.lock | 35 ++++++++++++++--------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index eda288b..67b8ae4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "devDependencies": { "@guidepup/guidepup": "^0.36.1", "@guidepup/record": "^0.3.0", - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.62.1", "@types/node": "^24.10.1", "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", @@ -62,7 +62,7 @@ }, "peerDependencies": { "@guidepup/guidepup": ">=0.36.1", - "@playwright/test": "^1.57.0" + "@playwright/test": ">=1.62.1" }, "resolutions": { "strip-ansi": "6.0.1", diff --git a/yarn.lock b/yarn.lock index a548423..2e844af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -136,12 +136,12 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@playwright/test@^1.60.0": - version "1.61.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.61.1.tgz#48568dc22af7819e55fa5e8e3bc79b7e6a3e6675" - integrity sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig== +"@playwright/test@^1.62.1": + version "1.64.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.64.1.tgz#03d48b0cd135db5a70a6fbca54b2b91a597eed01" + integrity sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg== dependencies: - playwright "1.61.1" + playwright "1.64.1" "@shikijs/engine-oniguruma@^3.23.0": version "3.23.0" @@ -705,11 +705,6 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" @@ -1028,19 +1023,17 @@ picomatch@^4.0.4: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== -playwright-core@1.61.1: - version "1.61.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc" - integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg== +playwright-core@1.64.1: + version "1.64.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.64.1.tgz#ca9ec82d7da6ced1b2326ec59ef097aaa9a29f94" + integrity sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ== -playwright@1.61.1: - version "1.61.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc" - integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ== +playwright@1.64.1: + version "1.64.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.64.1.tgz#c174f48e17f5f3fba71726a9bcdeb8534d5c665d" + integrity sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA== dependencies: - playwright-core "1.61.1" - optionalDependencies: - fsevents "2.3.2" + playwright-core "1.64.1" plist@^4.0.0: version "4.0.0"