mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
_onWebSpeechEnd counted every run that ended without a result toward the two-retry cap, however long the run lasted. Under 30 s or Manual, each no-speech window of the engine is one silent run, so the third ended the recording at about 18 s of silence, before the deadline, and Manual stopped on its own. Both recognition.start() sites now stamp the run start; an empty run counts toward the cap only when it ended within a second of starting (a recognizer that cannot listen), and any other run resets it. The 250 ms backoff is unchanged. The vm harness in test/voice-silence-timeout.test.ts now passes the test's Date into the context, whose own Date ignores fake timers. Also from review: the VoiceInput stubs of the four settings-ui.js tests carry SILENCE_TIMEOUT_SECONDS; Settings-Reference lists Stop after silence; the touchscreen Linux fallback leaves out ChromeOS and says a touch Linux laptop still matches; the dead _webSpeechStream teardown is gone; the two voice tests use vitest expect instead of node:assert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
4.5 KiB
TypeScript
82 lines
4.5 KiB
TypeScript
// Real Chromium rendering/input; native Android/Windows recognition is simulated.
|
|
import { readFileSync } from 'node:fs';
|
|
import { chromium, type Browser } from 'playwright';
|
|
import { afterAll, beforeAll, expect, it } from 'vitest';
|
|
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');
|
|
expect(await page.evaluate(() => (window as any).voice.recognition.continuous)).toBe(platform === 'Windows');
|
|
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());
|
|
}
|
|
expect(counts.size, 'speech bars must animate').toBeGreaterThan(1);
|
|
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');
|
|
});
|
|
expect(await page.evaluate(() => (window as any).voice._previewTextNode.textContent)).toBe('one two three four five');
|
|
await page.click('#mic');
|
|
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'one two three four five');
|
|
expect(await page.evaluate(() => (window as any).voice.isRecording)).toBe(false);
|
|
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');
|
|
expect(await page.evaluate(() => (window as any).micRequests)).toBe(0);
|
|
expect(errors).toEqual([]);
|
|
await page.close();
|
|
});
|