2026-10-24 19:04:49 +00:00
// Real Chromium rendering/input; native Android/Windows recognition is simulated.
import { readFileSync } from 'node:fs' ;
import { chromium , type Browser } from 'playwright' ;
2026-11-09 07:57:14 -08:00
import { afterAll , beforeAll , expect , it } from 'vitest' ;
2026-10-24 19:04:49 +00:00
const source = readFileSync ( new URL ( '../src/web/public/voice-input.js' , import . meta . url ), 'utf8' );
const styles = readFileSync ( new URL ( '../src/web/public/styles.css' , import . meta . url ), 'utf8' );
let browser : Browser ;
beforeAll ( async () => {
browser = await chromium . launch ();
});
afterAll ( async () => {
await browser ? . close ();
});
it . each ([
[ 390 , 'Android' , 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0' ],
[ 820 , 'Linux' , 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0' ],
[ 1292 , 'Windows' , 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0' ],
] as const )( 'dictation at %ipx (%s)' , async ( width , platform , ua ) => {
const page = await browser . newPage ({ viewport : { width , height : 900 }, hasTouch : true , userAgent : ua });
const errors : string [] = [];
page . on ( 'pageerror' , ( e ) => errors . push ( e . message ));
await page . setContent (
'<button id="mic" style="position:fixed;top:20px;left:20px">Microphone</button><div class="voice-compose-overlay"><textarea class="paste-textarea" aria-label="Transcript"></textarea></div>'
);
await page . addStyleTag ({ content : styles });
await page . addScriptTag ({
content : `const platform = ${ JSON . stringify ( platform ) } ;
Object.defineProperty(navigator,'userAgentData',{value:{platform},configurable:true});
Object.defineProperty(navigator,'platform',{value:platform==='Windows'?'Win32':'Linux x86_64',configurable:true});
window.micRequests=0;
Object.defineProperty(navigator,'mediaDevices',{value:{getUserMedia(){window.micRequests++;return Promise.reject(Error('Unexpected second microphone'));}},configurable:true});
window.SpeechRecognition=class{
start(){this.results=[];}
partial(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:platform!=='Windows'&&this.continuous});if(r.isFinal)this.results.push(r);else this.results=[r];this.onresult({resultIndex:r.isFinal?this.results.length-1:0,results:this.results});}
final(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:true});if(platform!=='Windows'&&this.continuous)this.results.push(r);else this.results=[r];this.onresult({resultIndex:0,results:this.results});}
stop(){setTimeout(()=>{this.final(this.text);this.onend();},50);}
abort(){}
};
window.app={activeSessionId:'fixture',showToast(){}};
` ,
});
await page . addScriptTag ({
content :
source +
` \ nwindow.voice=VoiceInput;VoiceInput._getDeepgramConfig=()=>({provider:'webspeech',insertMode:'compose',silenceTimeout:0});document.getElementById('mic').onclick=()=>VoiceInput.toggle();` ,
});
await page . click ( '#mic' );
2026-11-09 07:57:14 -08:00
expect ( await page . evaluate (() => ( window as any ). voice . recognition . continuous )). toBe ( platform === 'Windows' );
2026-10-24 19:04:49 +00:00
await page . evaluate (() => ( window as any ). voice . recognition . onsoundstart ());
await page . waitForFunction (() => document . querySelectorAll ( '.voice-level-bar.active' ). length > 0 );
const counts = new Set ();
for ( let i = 0 ; i < 8 ; i ++ ) {
await page . waitForTimeout ( 90 );
counts . add ( await page . locator ( '.voice-level-bar.active' ). count ());
}
2026-11-09 07:57:14 -08:00
expect ( counts . size , 'speech bars must animate' ). toBeGreaterThan ( 1 );
2026-10-24 19:04:49 +00:00
await page . evaluate (() => ( window as any ). voice . recognition . onsoundend ());
await page . waitForFunction (() => document . querySelectorAll ( '.voice-level-bar.active' ). length === 0 );
await page . evaluate (() => {
const voice = ( window as any ). voice ;
for ( const text of [ 'one' , 'one two' , 'one two three' , 'one two three four' , 'one two three four five' ])
voice . recognition . partial ( text );
voice . recognition . final ( 'one two three four five' );
});
2026-11-09 07:57:14 -08:00
expect ( await page . evaluate (() => ( window as any ). voice . _previewTextNode . textContent )). toBe ( 'one two three four five' );
2026-10-24 19:04:49 +00:00
await page . click ( '#mic' );
await page . waitForFunction (() => document . querySelector ( 'textarea' ) ? . value === 'one two three four five' );
2026-11-09 07:57:14 -08:00
expect ( await page . evaluate (() => ( window as any ). voice . isRecording )). toBe ( false );
2026-10-24 19:04:49 +00:00
await page . click ( '#mic' );
await page . evaluate (() => {
const voice = ( window as any ). voice ;
voice . recognition . final ( 'go go' );
voice . recognition . onend ();
voice . recognition . partial ( 'go go' );
});
await page . click ( '#mic' );
await page . waitForFunction (() => document . querySelector ( 'textarea' ) ? . value === 'go go go go' );
2026-11-09 07:57:14 -08:00
expect ( await page . evaluate (() => ( window as any ). micRequests )). toBe ( 0 );
expect ( errors ). toEqual ([]);
2026-10-24 19:04:49 +00:00
await page . close ();
});