atlas-iac/testing/tests/data/atlas_voice_language_probe.js

574 lines
20 KiB
JavaScript

// Deterministic browser stub that drives dockerfiles/hermes-webui-atlas-voice.js
// through complete hands-free turns with no microphone, audio device or GPU.
//
// The script under test is an IIFE with no exported seams, so the only honest
// way to assert what reaches POST /api/tts is to run it against a fake DOM and
// fake clock and record the requests it actually makes. Usage:
//
// node atlas_voice_language_probe.js <path-to-atlas-voice.js>
//
// It prints one JSON object describing every scenario to stdout.
'use strict';
const fs = require('fs');
const vm = require('vm');
const SCRIPT_PATH = process.argv[2];
if (!SCRIPT_PATH) {
throw new Error('usage: atlas_voice_language_probe.js <atlas-voice.js>');
}
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
function flush() {
// Four macrotask hops drain the promise chains the script builds around
// fetch()/json()/blob()/play() without ever waiting on wall-clock time.
return new Promise((resolve) => {
let hops = 0;
(function hop() {
hops += 1;
if (hops > 12) {
resolve();
return;
}
setImmediate(hop);
})();
});
}
function makeElement(id) {
return {
id,
style: {
values: new Map(),
setProperty(name, value) { this.values.set(name, String(value)); },
removeProperty(name) { this.values.delete(name); },
getPropertyValue(name) { return this.values.get(name) || ''; },
},
dataset: {},
attributes: {},
value: '',
textContent: '',
className: '',
classList: {
entries: new Set(),
add(name) { this.entries.add(name); },
remove(name) { this.entries.delete(name); },
contains(name) { return this.entries.has(name); },
},
listeners: [],
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name)
? this.attributes[name] : null;
},
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
removeEventListener(type, handler) {
this.listeners = this.listeners.filter((entry) => entry.handler !== handler);
},
click() {
const event = { preventDefault() {}, stopImmediatePropagation() {} };
this.listeners
.filter((entry) => entry.type === 'click')
.forEach((entry) => entry.handler(event));
},
querySelector() { return null; },
insertBefore() {},
appendChild() {},
};
}
function makeHarness(options = {}) {
const clock = { now: 1000000 };
const timeouts = [];
const intervals = new Map();
let timerId = 1;
const ttsRequests = [];
const ttsStreamRequests = [];
const transcribeCalls = [];
const toasts = [];
const sends = [];
let capability = { ok: true, available: true, provider: 'local_command' };
let transcribeResponse = { ok: true, transcript: 'hello', language: 'en' };
let transcribeStatus = 200;
let assistantRows = [];
let loud = false;
let recorder = null;
let lastAudio = null;
const storage = new Map();
const elements = {};
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
.forEach((id) => { elements[id] = makeElement(id); });
function MediaRecorder() {
this.state = 'recording';
this.ondataavailable = null;
this.onstop = null;
recorder = this;
}
MediaRecorder.prototype.start = function start() { this.state = 'recording'; };
MediaRecorder.prototype.stop = function stop() {
if (this.state === 'inactive') return;
this.state = 'inactive';
if (this.onstop) this.onstop();
};
MediaRecorder.isTypeSupported = function isTypeSupported() { return true; };
function AudioContext() {
this.createAnalyser = () => ({
fftSize: 2048,
getByteTimeDomainData(samples) {
for (let i = 0; i < samples.length; i += 1) {
samples[i] = loud ? (i % 2 ? 200 : 56) : 128;
}
},
});
this.createBiquadFilter = () => ({
type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {},
});
this.createMediaStreamSource = () => ({ connect() {} });
this.close = () => {};
}
function AudioElement() {
this.currentTime = 0;
this.onended = null;
this.onerror = null;
this.paused = false;
this.pause = () => { this.paused = true; };
this.play = () => {
this.paused = false;
if (!options.manualAudio) setImmediate(() => { if (this.onended) this.onended(); });
return Promise.resolve();
};
this.finish = () => { if (this.onended) this.onended(); };
lastAudio = this;
}
async function fetchStub(url, init) {
if (url === '/api/transcribe/capability') {
return { ok: true, status: 200, json: async () => capability };
}
if (url === '/api/voice/streaming/capability') {
return {
ok: !!options.streamingCapability,
status: options.streamingCapability ? 200 : 404,
json: async () => options.streamingCapability || {},
};
}
if (url === '/api/transcribe') {
transcribeCalls.push({ body: init && init.body });
return {
ok: transcribeStatus < 400,
status: transcribeStatus,
json: async () => transcribeResponse,
};
}
if (url === '/api/tts') {
ttsRequests.push(JSON.parse(init.body));
return {
ok: true,
status: 200,
blob: async () => ({ synthetic: true }),
json: async () => ({}),
};
}
if (url === '/api/tts/stream') {
ttsStreamRequests.push(JSON.parse(init.body));
return {
ok: !options.ttsStreamFails,
status: options.ttsStreamFails ? 503 : 200,
body: null,
headers: { get() { return null; } },
};
}
throw new Error(`unexpected fetch: ${url}`);
}
const context = {
AbortController,
console,
Uint8Array,
Promise,
Math,
JSON,
String,
Number,
Error,
parseInt,
isNaN,
Set,
Map,
Array,
Object,
Date: { now: () => clock.now },
Blob: function Blob(parts, options) { this.parts = parts; this.type = (options || {}).type || ''; },
File: function File(parts, name, options) {
this.parts = parts; this.name = name; this.type = (options || {}).type || '';
},
FormData: function FormData() { this.entries = []; this.append = (k, v) => this.entries.push([k, v]); },
URL: { createObjectURL: () => 'blob:atlas-test', revokeObjectURL() {} },
Audio: AudioElement,
AudioWorkletNode: options.enableAudioWorklet ? function AudioWorkletNode() {} : undefined,
MediaRecorder,
AudioContext,
ReadableStream: options.enableAudioWorklet ? function ReadableStream() {} : undefined,
fetch: fetchStub,
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => { storage.set(key, String(value)); },
removeItem: (key) => { storage.delete(key); },
},
navigator: {
mediaDevices: {
getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }),
getSupportedConstraints: () => ({}),
},
},
document: {
getElementById: (id) => elements[id] || null,
querySelectorAll: () => assistantRows,
createElement: () => ({ value: '', textContent: '' }),
},
S: { session: { session_id: 'session-1' }, busy: false },
setTimeout: (fn, delay) => {
const id = timerId; timerId += 1;
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
return id;
},
clearTimeout: (id) => {
const index = timeouts.findIndex((entry) => entry.id === id);
if (index >= 0) timeouts.splice(index, 1);
},
setInterval: (fn, delay) => {
const id = timerId; timerId += 1;
intervals.set(id, { fn, delay: delay || 0 });
return id;
},
clearInterval: (id) => { intervals.delete(id); },
};
context.window = context;
context.showToast = (message) => { toasts.push(message); };
context.send = () => { sends.push(elements.msg.value); };
context.autoResize = () => {};
vm.createContext(context);
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
function runDueTimeouts() {
const due = timeouts.filter((entry) => entry.at <= clock.now);
due.forEach((entry) => {
const index = timeouts.indexOf(entry);
if (index >= 0) timeouts.splice(index, 1);
entry.fn();
});
}
function tick(ms) {
clock.now += ms;
Array.from(intervals.values()).forEach((entry) => entry.fn());
runDueTimeouts();
}
return {
context,
elements,
ttsRequests,
ttsStreamRequests,
transcribeCalls,
toasts,
sends,
clock,
recorder: () => recorder,
get lastAudio() { return lastAudio; },
setLoud: (value) => { loud = value; },
setCapability: (value) => { capability = value; },
setTranscribeResponse: (value, status) => {
transcribeResponse = value;
transcribeStatus = status === undefined ? 200 : status;
},
setAssistantReply: (text) => { assistantRows = [{ dataset: { rawText: text } }]; },
setSession: (id) => { context.S.session = { session_id: id }; },
advance: (ms) => { clock.now += ms; },
tick,
runDueTimeouts,
flush,
};
}
// Walk one capture window: pre-roll audio, three loud frames so the VAD latches
// speech, then silence past the hangover so MediaRecorder.stop() fires.
async function captureSpeech(harness) {
const active = harness.recorder();
if (!active) throw new Error('voice mode never created a recorder');
active.ondataavailable({ data: { size: 512 } });
harness.setLoud(true);
for (let i = 0; i < 4; i += 1) harness.tick(100);
active.ondataavailable({ data: { size: 512 } });
harness.setLoud(false);
harness.advance(2500);
harness.tick(100);
await harness.flush();
}
async function startVoiceMode(harness) {
await harness.flush();
harness.elements.btnVoiceMode.click();
await harness.flush();
}
// One complete turn: speak, transcribe, let the app "answer", read it back.
async function runTurn(harness, { transcript, language, reply }) {
const payload = { ok: true, transcript };
if (language !== undefined) payload.language = language;
harness.setTranscribeResponse(payload);
await captureSpeech(harness);
harness.setAssistantReply(reply || 'An answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
}
async function restartListening(harness) {
harness.advance(1000);
harness.runDueTimeouts();
await harness.flush();
}
const scenarios = {};
scenarios.english_turn_speaks_english = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'What is the weather?', language: 'en' });
return { tts: harness.ttsRequests };
};
scenarios.russian_turn_speaks_russian = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
return { tts: harness.ttsRequests };
};
scenarios.spanish_turn_speaks_spanish = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: '¿Qué tal?', language: 'es', reply: 'Muy bien.' });
return { tts: harness.ttsRequests };
};
scenarios.missing_language_falls_back = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Hello there.' });
return { tts: harness.ttsRequests };
};
scenarios.unsupported_language_falls_back = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Bonjour tout le monde.', language: 'fr' });
return { tts: harness.ttsRequests };
};
scenarios.hostile_language_values_are_dropped = async () => {
const results = [];
const hostile = [
'ru; rm -rf /',
'../../ru_RU-irina-medium',
'ru\\u0000',
'RUSSIAN',
{ language: 'ru' },
['ru'],
42,
null,
'r',
'ru ru',
'x'.repeat(4096),
];
for (const language of hostile) {
const harness = makeHarness();
// eslint-disable-next-line no-await-in-loop
await startVoiceMode(harness);
// eslint-disable-next-line no-await-in-loop
await runTurn(harness, { transcript: 'Say something.', language });
results.push({ sent: String(language), tts: harness.ttsRequests });
}
return { results };
};
scenarios.voice_field_is_never_sent = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru' });
return { tts: harness.ttsRequests };
};
scenarios.language_does_not_leak_into_later_turn = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
await restartListening(harness);
await runTurn(harness, { transcript: 'And in English?', language: undefined });
await restartListening(harness);
await runTurn(harness, { transcript: '¿Y ahora?', language: 'es' });
return { tts: harness.ttsRequests };
};
scenarios.empty_transcript_does_not_arm_a_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: ' ', language: 'ru' });
await captureSpeech(harness);
const sendsAfterBlank = harness.sends.slice();
// A reply landing while the blank turn winds down must not inherit a
// language that transcript never earned.
harness.setAssistantReply('A stray answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello.', language: undefined });
return { sendsAfterBlank, tts: harness.ttsRequests, sends: harness.sends };
};
scenarios.session_change_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.setSession('session-2');
harness.setAssistantReply('Reply that belongs to another chat.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterSwitch = harness.ttsRequests.slice();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello again.', language: undefined });
return { afterSwitch, tts: harness.ttsRequests };
};
scenarios.deactivation_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.elements.btnVoiceMode.click();
await harness.flush();
harness.setAssistantReply('Late reply after the user left voice mode.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterDeactivate = harness.ttsRequests.slice();
harness.elements.btnVoiceMode.click();
await harness.flush();
await runTurn(harness, { transcript: 'Fresh start.', language: undefined });
return { afterDeactivate, tts: harness.ttsRequests };
};
scenarios.transcribe_error_speaks_nothing = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ error: 'Whisper is down' }, 503);
await captureSpeech(harness);
harness.setAssistantReply('Some earlier answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { tts: harness.ttsRequests, toasts: harness.toasts };
};
scenarios.adaptive_chunks_are_sentence_gated = async () => {
const harness = makeHarness();
await harness.flush();
const partial = harness.context._atlasAdaptiveChunks(
'Dr. Rivera is still explaining this opening thought without a safe sentence boundary yet',
false,
);
const complete = harness.context._atlasAdaptiveChunks(
'This opening clause gives Hermes a clean and quick place to begin speaking, while the rest of the first sentence remains coherent. '
+ 'The following explanation is deliberately long enough to demonstrate that later punctuation aligned chunks use a much larger target and retain natural prosody for listeners. '
+ 'A compact final tail remains.',
true,
);
return { partial, complete };
};
scenarios.first_sentence_speaks_before_stream_completion = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Tell me something useful.', language: 'en' });
await captureSpeech(harness);
harness.setAssistantReply('This answer is still streaming without a complete sentence');
harness.tick(100);
await harness.flush();
const beforeBoundary = harness.ttsRequests.length;
harness.setAssistantReply('This answer now has its first complete sentence. The remainder is still');
harness.tick(100);
await harness.flush();
const afterBoundary = harness.ttsRequests.length;
harness.setAssistantReply('This answer now has its first complete sentence. The remainder is still being generated and is now complete.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { beforeBoundary, afterBoundary, tts: harness.ttsRequests };
};
scenarios.final_renderer_revision_closes_speech_queue = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Explain this.', language: 'en' });
await captureSpeech(harness);
harness.setAssistantReply('Hermes starts with a complete sentence. The draft tail is still');
harness.tick(100);
await harness.flush();
const beforeRevision = harness.ttsRequests.length;
harness.setAssistantReply('The renderer revised the complete sentence. The final tail is now safe and complete.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { beforeRevision, afterRevision: harness.ttsRequests.length };
};
scenarios.streaming_tts_failure_falls_back_to_wav = async () => {
const harness = makeHarness({
enableAudioWorklet: true,
ttsStreamFails: true,
streamingCapability: {
tts: { available: true, transport: 'http', format: 'pcm_s16le', sample_rate: 22050 },
stt: { available: false },
},
});
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Fallback please.', language: 'en', reply: 'A complete answer.' });
return { stream: harness.ttsStreamRequests, wav: harness.ttsRequests };
};
scenarios.one_ahead_is_bounded_and_turn_cancel_stops_audio = async () => {
const harness = makeHarness({ manualAudio: true });
await startVoiceMode(harness);
const reply = 'This opening sentence is sufficiently complete for a short first speech chunk. '
+ 'This second section contains enough carefully chosen words to become a larger punctuation aligned chunk without losing its natural rhythm or clarity for the listener. '
+ 'This third section is also deliberately long enough to require another queued synthesis request after playback advances.';
await runTurn(harness, { transcript: 'Read it.', language: 'en', reply });
const beforeFirstEnds = harness.ttsRequests.length;
const firstAudio = harness.lastAudio;
harness.elements.btnVoiceMode.click();
await harness.flush();
return {
beforeFirstEnds,
afterCancel: harness.ttsRequests.length,
active: harness.context._voiceModeActive(),
firstAudioPaused: !!(firstAudio && firstAudio.paused),
};
};
(async () => {
const output = {};
const names = Object.keys(scenarios);
for (const name of names) {
// eslint-disable-next-line no-await-in-loop
output[name] = await scenarios[name]();
}
process.stdout.write(JSON.stringify(output, null, 1));
})().catch((error) => {
process.stderr.write(String((error && error.stack) || error));
process.exit(1);
});