11// NVDA smoke: proves the self-hosted Windows runner drives REAL NVDA via
22// guidepup in CI (the Windows analog of the VoiceOver lane). Self-contained —
33// no deploy preview / OpenBao, and no extra deps: uses @guidepup/guidepup's
4- // nvda driver (a repo dependency) + Playwright's bundled chromium. Launches a
5- // static page, focuses it, walks the NVDA cursor, and asserts NVDA actually
6- // spoke the page's content.
4+ // nvda driver + Playwright's bundled chromium (both already repo deps).
5+ //
6+ // The load-bearing trick (mirrors @guidepup/playwright's navigateToWebContent):
7+ // under a CI job the runner console holds the OS foreground, so SetForeground-
8+ // Window is blocked. Instead we bring the browser to NVDA's focus with
9+ // NVDA-driven Alt+Esc app-switching, verified by reading the window title —
10+ // which the OS honors regardless of foreground lock.
711//
812// Run: npx tsx tests/e2e/a11y-nvda-smoke.ts
913import { chromium } from "@playwright/test" ;
10- import { nvda , windowsActivate } from "@guidepup/guidepup" ;
14+ import { nvda , WindowsKeyCodes , WindowsModifiers } from "@guidepup/guidepup" ;
1115import { mkdirSync , writeFileSync } from "node:fs" ;
1216
1317const PAGE =
@@ -23,6 +27,26 @@ const PAGE =
2327 ) ;
2428
2529const sleep = ( ms : number ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
30+ const clean = ( s : string ) => s . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 ] + / g, " " ) . replace ( / \s + / g, " " ) . trim ( ) ;
31+
32+ // Cycle windows via Alt+Esc until NVDA reports the browser window as focused.
33+ // NVDA reports its window title as e.g. "NVDA smoke - Google Chrome for Testing".
34+ // Alt+Esc is OS-honored regardless of foreground lock (unlike SetForegroundWindow).
35+ async function focusBrowser ( pageTitle : string ) : Promise < { ok : boolean ; seen : string [ ] } > {
36+ const want = clean ( pageTitle ) ;
37+ const SWITCH = { keyCode : [ WindowsKeyCodes . Escape ] , modifiers : [ WindowsModifiers . Alt ] } ;
38+ const seen : string [ ] = [ ] ;
39+ for ( let i = 0 ; i < 12 ; i ++ ) {
40+ await nvda . perform ( nvda . keyboardCommands . reportTitle ) ;
41+ await sleep ( 400 ) ; // let NVDA finish speaking before reading the phrase
42+ const title = clean ( await nvda . lastSpokenPhrase ( ) ) ;
43+ seen . push ( title ) ;
44+ if ( want . length && ( title . startsWith ( want ) || title . includes ( "chrom" ) ) ) return { ok : true , seen } ;
45+ await nvda . perform ( SWITCH , { capture : false } ) ;
46+ await sleep ( 700 ) ;
47+ }
48+ return { ok : false , seen } ;
49+ }
2650
2751async function main ( ) {
2852 await nvda . start ( ) ;
@@ -34,18 +58,12 @@ async function main() {
3458 } ) ;
3559 const page = await browser . newPage ( ) ;
3660 await page . goto ( PAGE ) ;
37- await page . bringToFront ( ) ;
38- await sleep ( 800 ) ;
39- // Force the Chromium window to the OS foreground so NVDA's browse cursor
40- // lands in it. Required in CI, where the runner's job console otherwise
41- // holds foreground and NVDA reads nothing (window title = page <title>).
42- await windowsActivate ( "chrome.exe" , "NVDA smoke" ) . catch ( ( ) => { } ) ;
43- await sleep ( 1200 ) ;
44- await page . locator ( "h1" ) . click ( ) . catch ( ( ) => { } ) ;
45- await sleep ( 800 ) ;
61+ await sleep ( 1000 ) ;
4662
63+ const focused = await focusBrowser ( "NVDA smoke" ) ;
64+ console . log ( "focusBrowser ok=" + focused . ok + " titles seen: " + JSON . stringify ( focused . seen ) ) ;
4765 await nvda . clearSpokenPhraseLog ( ) ;
48- await nvda . press ( "Control+Home" ) ; // jump to top of document, reads the H1
66+ await nvda . press ( "Control+Home" ) ; // top of document, reads the H1
4967 for ( let i = 0 ; i < 6 ; i ++ ) {
5068 await nvda . next ( ) ; // walk paragraph, link, button
5169 }
@@ -60,9 +78,8 @@ async function main() {
6078 if ( nonEmpty . length === 0 ) {
6179 throw new Error ( "NVDA produced no spoken content — the AT was not driven" ) ;
6280 }
63- const joined = nonEmpty . join ( " | " ) . toLowerCase ( ) ;
64- if ( ! joined . includes ( "smoke" ) ) {
65- throw new Error ( "NVDA spoke, but did not read the smoke page (H1) — got: " + joined ) ;
81+ if ( ! nonEmpty . join ( " | " ) . toLowerCase ( ) . includes ( "smoke" ) ) {
82+ throw new Error ( "NVDA spoke, but did not read the smoke page: " + nonEmpty . join ( " | " ) ) ;
6683 }
6784 console . log ( "SMOKE PASS: NVDA read the page (H1 + " + ( nonEmpty . length - 1 ) + " more items)" ) ;
6885 } finally {
0 commit comments