Files
gnome-jarvis/test/settings.test.js
T
snxraven aa06c71fe1
Rolling release / release (push) Failing after 1m46s
obsidian
2026-09-14 11:24:10 -04:00

276 lines
16 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { SETTINGS_FIELDS, voiceSettings } from '../daemon/voice-settings.js';
import { mkdtempSync } from 'node:fs';
import { normalizeSettings, mergeSettings, settingVisible } from '../apps/gnome-extension/[email protected]/settings-values.js';
import { TTS_PRESETS, ttsConfiguration, scaleSpeech } from '../daemon/tts-config.js';
import { QvacVoiceAdapter } from '../daemon/voice-adapters.js';
import { Agent } from '../daemon/qvac-master.js';
import { JarvisDaemon } from '../daemon/index.js';
import { ComputerUseSession } from '../computer-use/session.js';
import { PipeWireCapture } from '../daemon/audio-pipewire.js';
import { PipeWirePlayback } from '../daemon/audio-playback.js';
import { VadSegmenter } from '../daemon/vad.js';
import { VoiceLoop } from '../daemon/voice-loop.js';
import { ttsLoadConfigSchema } from '../node_modules/@qvac/inference/dist/schemas/text-to-speech.js';
import * as registry from '../node_modules/@qvac/inference/dist/models/registry/models.js';
process.env.XDG_DATA_HOME ||= mkdtempSync(path.join(tmpdir(), 'jarvis-settings-data-'));
test('settings migrate aliases, retain explicit disables, and reject invalid numeric input', () => {
const settings = voiceSettings({ tts_enabled: false, voice_id: 'M3', language: 'es-MX', computer_step_budget: '50', tts_speed: 1.4 });
assert.equal(settings.ttsEnabled, false); assert.equal(settings.voiceId, 'M3'); assert.equal(settings.asrLanguage, 'es');
assert.equal(settings.computerSteps, 50); assert.equal(settings.ttsSpeed, 1.4);
assert.equal(voiceSettings({}).webcamEnabled, false);
assert.equal(voiceSettings({}).webcamMaxEdge, 720);
assert.equal(voiceSettings({}).webcamGrantMinutes, 3);
assert.equal(voiceSettings({ ttsSpeed: 100 }).ttsSpeed, 1.05);
assert.throws(() => voiceSettings({ ttsSpeed: 100 }, { strict: true }), /Speaking speed/);
assert.throws(() => voiceSettings({ computerSteps: '' }, { strict: true }), /Actions per grant/);
assert.equal(voiceSettings({ ttsEnabled: true, tts_enabled: false }).ttsEnabled, true);
});
test('saving one window preserves unrelated changes from another and removes conflicting aliases', () => {
const merged = mergeSettings({ tts_enabled: false, ttsSpeed: 1.5, unrelated: 'kept' }, { ttsEnabled: true }, SETTINGS_FIELDS);
assert.deepEqual(merged, { ttsSpeed: 1.5, unrelated: 'kept', ttsEnabled: true });
assert.equal(normalizeSettings(merged, SETTINGS_FIELDS).ttsSpeed, 1.5);
assert.equal(voiceSettings({ modelProfile: 'laptop-8gb-mm' }).modelProfile, 'laptop-8gb-mm');
assert.equal(voiceSettings({ modelProfile: 'laptop-4gb-mm' }).modelProfile, 'laptop-4gb-mm');
});
test('every speech preset uses a real registry asset and passes the installed SDK schema', () => {
for (const ttsPreset of Object.keys(TTS_PRESETS)) {
const settings = voiceSettings({ ttsPreset, ttsLanguage: 'fr', voiceId: 'M4', ttsSpeed: 1.25, ttsDescription: 'A warm voice speaks softly.' });
const config = ttsConfiguration(settings);
assert.ok(registry[config.model], config.model);
assert.equal(ttsLoadConfigSchema.safeParse(config.config).success, true, ttsPreset);
if (ttsPreset === 'supertonic-en') assert.equal(config.config.language, 'en');
if (ttsPreset === 'supertonic3') assert.equal(config.config.language, 'fr');
if (ttsPreset === 'parler') { assert.equal(config.config.description, settings.ttsDescription); assert.equal(config.config.voice, undefined); }
}
for (const choice of SETTINGS_FIELDS.find(f => f.key === 'asrModel').options) assert.ok(registry[choice.value], choice.value);
});
test('irrelevant voice controls hide when choosing a different engine', () => {
const field = key => SETTINGS_FIELDS.find(f => f.key === key);
assert.equal(settingVisible(field('ttsReferenceAudio'), { ttsPreset: 'chatterbox' }), true);
assert.equal(settingVisible(field('voiceId'), { ttsPreset: 'chatterbox' }), false);
assert.equal(settingVisible(field('ttsDescription'), { ttsPreset: 'parler' }), true);
assert.equal(settingVisible(field('groqApiKey'), { agentInference: 'local' }), false);
assert.equal(settingVisible(field('groqModel'), { agentInference: 'groq' }), true);
});
test('reply volume scales PCM without modifying the original', () => {
const original = Int16Array.from([32767, -32768, 1000]);
assert.deepEqual([...scaleSpeech(original, 50)], [16384, -16384, 500]);
assert.deepEqual([...scaleSpeech(original, 0)], [0, 0, 0]);
assert.equal(original[0], 32767);
});
test('voice adapter sends selected config and uses each model native sample rate', async () => {
const original = Agent.engine.ensureInit; const loads = [];
Agent.engine.ensureInit = async () => ({
TTS_S3GEN_EN_CHATTERBOX: registry.TTS_S3GEN_EN_CHATTERBOX,
loadModel: async options => { loads.push(options); return 'test-model'; },
unloadModel: async () => {},
textToSpeech: async () => ({ buffer: Int16Array.from([1000, -1000]) }),
});
try {
for (const ttsPreset of ['supertonic3', 'chatterbox']) {
const adapter = new QvacVoiceAdapter({ role: 'tts', settings: voiceSettings({ ttsPreset, voiceId: 'M2', ttsSpeed: 1.3, ttsLanguage: 'fr', ttsVolume: 50, ttsReferenceAudio: '/tmp/reference.wav' }) });
try {
await adapter.start();
const audio = await adapter.speak('Bonjour.');
assert.equal(audio.sampleRate, ttsPreset === 'chatterbox' ? 24000 : 44100);
assert.deepEqual([...audio.samples], [500, -500]);
} finally { await adapter.stop(); }
}
assert.equal(loads[0].modelConfig.voice, 'M2'); assert.equal(loads[0].modelConfig.ttsSpeed, 1.3);
assert.equal(loads[1].modelConfig.referenceAudioSrc, '/tmp/reference.wav');
assert.deepEqual(loads[1].modelConfig.s3genModelSrc, registry.TTS_S3GEN_EN_CHATTERBOX);
} finally { Agent.engine.ensureInit = original; }
});
test('desktop mode and configured duration are enforced at the session boundary', () => {
let now = 0;
const session = new ComputerUseSession({ mode: 'observe', grantMinutes: 1, clock: () => now });
session.grant(); assert.throws(() => session.beginStep(), /observe-only/);
now = 60001; assert.equal(session.status().active, false);
session.mode = 'off'; assert.throws(() => session.grant(), /disabled/);
});
function child() {
const result = new EventEmitter(); result.stdout = new EventEmitter(); result.stderr = new EventEmitter(); result.stdin = new EventEmitter();
result.kill = () => {}; result.stdin.end = () => queueMicrotask(() => result.emit('close', 0)); return result;
}
test('PipeWire routes to the selected devices and uses the synthesis sample rate', async () => {
let captureArgs; let playbackArgs;
const capture = new PipeWireCapture({ target: 'my-mic', spawnImpl: (_command, args) => { captureArgs = args; return child(); } });
capture.start(); capture.stop(); assert.equal(captureArgs[captureArgs.indexOf('--target') + 1], 'my-mic');
const playback = new PipeWirePlayback({ target: 'my-speakers', spawnImpl: (_command, args) => { playbackArgs = args; return child(); } });
await playback.play(new Int16Array(100), 24000);
assert.equal(playbackArgs[playbackArgs.indexOf('--rate') + 1], '24000');
assert.equal(playbackArgs[playbackArgs.indexOf('--target') + 1], 'my-speakers');
});
test('Hold Talk only mode ignores wake and automatic listening', () => {
const capture = new EventEmitter(); let wakeFrames = 0; let vadFrames = 0;
const wake = new EventEmitter(); wake.push = () => wakeFrames++;
const vad = new EventEmitter(); vad.push = () => vadFrames++;
const loop = new VoiceLoop({ daemon: { state: 'LISTENING', emit() {} }, capture, wake, vad, listeningMode: 'ptt' });
loop.running = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(wakeFrames, 0); assert.equal(vadFrames, 0);
loop.ptt = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(vadFrames, 1);
});
test('mute stops capture and ignores wake, VAD, and push-to-talk', async () => {
const capture = new EventEmitter();
let started = 0;
let stopped = 0;
capture.start = () => { started += 1; };
capture.stop = () => { stopped += 1; };
let wakeFrames = 0;
let vadFrames = 0;
let heard = 0;
const wake = new EventEmitter();
wake.push = () => { wakeFrames += 1; };
wake.pause = () => {};
wake.resume = () => {};
wake.start = () => {};
wake.close = () => {};
const vad = new EventEmitter();
vad.push = () => { vadFrames += 1; };
vad.reset = () => {};
vad.end = () => {};
const daemon = new EventEmitter();
daemon.state = 'LISTENING';
daemon.arm = () => { heard += 1; };
const loop = new VoiceLoop({ daemon, capture, wake, vad, asr: { start: async () => {} } });
await loop.start();
assert.ok(started >= 1);
loop.setMuted(true);
assert.equal(loop.muted, true);
assert.equal(loop.status.muted, true);
assert.equal(loop.status.capture, false);
assert.equal(stopped, 1);
loop.pushAudio(Buffer.alloc(10));
assert.equal(wakeFrames, 0);
assert.equal(vadFrames, 0);
loop.wakeHeard('hey jarvis');
assert.equal(heard, 0);
await loop.setPushToTalk(true);
assert.equal(loop.ptt, false);
loop.interrupt();
loop.setMuted(false);
assert.equal(loop.status.capture, true);
await loop.stop();
});
test('VAD discards short noises and bounds the whole recording including pauses', () => {
const vad = new VadSegmenter({ params: { minSpeechDurationMs: 300, minSilenceDurationMs: 500, maxSpeechDurationMs: 600 } });
const speech = Buffer.alloc(3200); for (let i = 0; i < speech.length; i += 2) speech.writeInt16LE(20000, i);
const silence = Buffer.alloc(3200); const utterances = []; vad.on('utterance', audio => utterances.push(audio));
vad.push(speech); for (let i = 0; i < 5; i++) vad.push(silence);
assert.equal(utterances.length, 0); assert.equal(vad.speaking, false);
for (let i = 0; i < 3; i++) { vad.push(speech); vad.push(silence); }
assert.equal(utterances.length, 1); assert.equal(utterances[0].length, 19200);
});
test('Apply reloads voice and desktop settings and keeps restart requirements until restart', async () => {
const previous = process.env.XDG_CONFIG_HOME;
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-settings-'));
process.env.XDG_CONFIG_HOME = dir; await mkdir(path.join(dir, 'jarvis'));
const daemon = new JarvisDaemon(); let stopped = 0;
daemon.setState = state => { daemon.state = state; };
daemon.input = { revoke() {} }; daemon.computer.audit = null;
daemon.startVoice = async () => { daemon.voiceLoop = { stop: async () => stopped++, status: { tts: true, errors: {} } }; };
daemon.voiceLoop = { stop: async () => stopped++, status: {} };
try {
await writeFile(path.join(dir, 'jarvis/config.json'), JSON.stringify({ voiceId: 'M5', computerMode: 'observe', computerSteps: 7, modelProfile: 'desktop-gpu' }));
const result = JSON.parse(await daemon.reloadSettings());
assert.equal(daemon.settings.voiceId, 'M5'); assert.equal(daemon.computer.mode, 'observe'); assert.equal(daemon.computer.stepsMax, 7);
assert.ok(result.restartRequired.includes('modelProfile')); assert.equal(stopped, 1);
assert.ok(JSON.parse(await daemon.reloadSettings()).restartRequired.includes('modelProfile'));
daemon._activeAsk = Promise.resolve(); await assert.rejects(daemon.reloadSettings(), /current request/);
} finally {
clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer);
if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous;
await rm(dir, { recursive: true, force: true });
}
});
test('Obsidian Apply replaces the tool and memory selection, resets context, and revokes access', async () => {
const previous = process.env.XDG_CONFIG_HOME;
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-vault-settings-'));
process.env.XDG_CONFIG_HOME = dir; await mkdir(path.join(dir, 'jarvis'));
const daemon = new JarvisDaemon(); let resets = 0;
daemon.harness.resetContext = async () => { resets++; };
daemon.setState = state => { daemon.state = state; };
daemon.startVoice = async () => { daemon.voiceLoop = { stop: async () => {}, status: { errors: {} } }; };
daemon.voiceLoop = { stop: async () => {} };
try {
const config = path.join(dir, 'jarvis/config.json');
await writeFile(config, JSON.stringify({ obsidianEnabled: true, obsidianMemoryEnabled: true, obsidianVaultPath: path.join(dir, 'vault') }));
await daemon.reloadSettings();
assert.equal(resets, 1);
const tool = daemon.harness.options.tools.find(t => t.name === 'obsidian'); assert.ok(tool);
assert.equal(daemon.harness.options.builtinTools.includes('memory_write'), false);
assert.match(daemon.harness.options.system, /obsidian memory_search/);
assert.equal(JSON.parse(daemon.obsidianAction('{"action":"initialize"}')).ready, true);
assert.equal(JSON.parse(daemon.obsidianAction('{"action":"verify"}')).memoryVerified, true);
tool.execute({ action: 'write', path: 'memory/test.md', content: 'saved memory' });
await writeFile(config, JSON.stringify({ obsidianEnabled: false }));
await daemon.reloadSettings();
assert.equal(resets, 2);
assert.equal(daemon.harness.options.tools.some(t => t.name === 'obsidian'), false);
assert.equal(daemon.harness.options.builtinTools.includes('memory_write'), true);
assert.throws(() => tool.execute({ action: 'read', path: 'memory/test.md' }), /disabled/);
assert.equal(JSON.parse(daemon.obsidianAction('{"action":"status"}')).enabled, false);
} finally {
clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer);
if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous;
await rm(dir, { recursive: true, force: true });
}
});
test('voice preview uses selected text without changing chat history or the repeat reply', async () => {
const daemon = new JarvisDaemon(); const speech = []; const replies = [];
daemon.setState = state => { daemon.state = state; };
daemon.lastReply = 'Original conversation reply';
daemon.on('Reply', text => replies.push(text));
daemon.voiceLoop = { status: { tts: true }, speak: async text => speech.push(text) };
try {
await daemon.previewVoice('A sample of my chosen voice.');
assert.deepEqual(speech, ['A sample of my chosen voice.']);
assert.deepEqual(replies, []); assert.equal(daemon.lastReply, 'Original conversation reply');
assert.equal(daemon.state, 'LISTENING');
daemon.settings = { ...daemon.settings, listeningMode: 'single' };
await daemon.previewVoice('Another sample.'); assert.equal(daemon.state, 'ARMED');
daemon.locked = true; await assert.rejects(daemon.previewVoice('Locked sample'), /Unlock/);
} finally { clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer); }
});
test('a disabled microphone never starts capture while speech output remains available', async () => {
const capture = new EventEmitter(); capture.start = () => assert.fail('microphone should be disabled');
const loop = new VoiceLoop({ capture, asr: null, tts: { start: async () => {} } });
await loop.start();
assert.equal(loop.status.capture, false); assert.equal(loop.status.asr, false); assert.equal(loop.status.tts, false);
assert.equal(await loop.ensureTts(), true); assert.equal(loop.status.tts, true);
});
test('catalog defaults enable CPU wake, workspace files, and idle VRAM unload', () => {
const settings = voiceSettings({});
assert.equal(settings.fsAccess, 'workspace');
assert.equal(settings.freeVramOnIdle, true);
assert.equal(settings.wakeCommand, 'jarvis-wake-bridge');
assert.equal(settings.assistantName, 'Jarvis');
assert.equal(settings.assistantPrompt, '');
assert.equal(voiceSettings({ assistantName: ' Ada ' }).assistantName, 'Ada');
assert.equal(voiceSettings({ assistantName: '<script>' }).assistantName, 'Jarvis');
assert.equal(voiceSettings({ assistantPrompt: 'Be dry.\nSkip filler. ' }).assistantPrompt, 'Be dry.\nSkip filler.');
});