@@ -60,3 +60,20 @@ test('computer audit stores hashes and never stores screenshot bytes', async ()
|
||||
const row = await audit.record('observe', { name: 'Window', role: 'window' }, { ok: true, screenshotPath: frame }); const text = await readFile(path.join(dir, `cu-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}.jsonl`), 'utf8');
|
||||
assert.equal(row.screenshot_hash.length, 64); assert.doesNotMatch(text, /private pixels/); assert.doesNotMatch(text, /frame\.png/);
|
||||
});
|
||||
|
||||
test('event socket bounds UTF-8 input, handles async rejection, and closes active clients', async (t) => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-errors-'));
|
||||
let ipc;
|
||||
try { ipc = await createEventSocket({ socketPath: path.join(dir, 'events.sock'), onMessage: async () => { throw new Error('handler failed'); } }); }
|
||||
catch (error) { if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; } throw error; }
|
||||
const connect = () => new Promise((resolve, reject) => { const client = net.createConnection(ipc.socketPath, () => resolve(client)); client.on('error', reject); });
|
||||
try {
|
||||
const client = await connect();
|
||||
const response = new Promise(resolve => client.once('data', resolve));
|
||||
client.write('{}\n'); assert.match(String(await response), /invalid IPC message/);
|
||||
const closed = new Promise(resolve => client.once('close', resolve));
|
||||
client.write('é'.repeat(40 * 1024)); await closed;
|
||||
const idle = await connect(); const idleClosed = new Promise(resolve => idle.once('close', resolve));
|
||||
await ipc.close(); await idleClosed;
|
||||
} finally { if (ipc.server.listening) await ipc.close(); }
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
process.env.XDG_STATE_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-test-'));
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
||||
@@ -145,3 +149,24 @@ test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
||||
assert.deepEqual(order, ['first', 'voice', 'media']);
|
||||
assert.deepEqual(scheduler.status(), { running: 0, queued: [] });
|
||||
});
|
||||
|
||||
test('shutdown still closes the harness when recovery or voice cleanup fails', async () => {
|
||||
const daemon = new JarvisDaemon(); let closed = false;
|
||||
daemon.recovery.save = () => { throw new Error('disk full'); };
|
||||
daemon.harness = { cancel() {}, close: async () => { closed = true; } };
|
||||
daemon.voiceLoop = { stop: async () => { throw new Error('voice cleanup'); } };
|
||||
await assert.rejects(daemon.close(), /voice cleanup/);
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test('cancel suppresses a late reply and revokes portal input', async () => {
|
||||
const daemon = new JarvisDaemon(); let resolve; let revoked = false;
|
||||
daemon.input = { revoke: () => { revoked = true; } };
|
||||
daemon.harness = { ask: () => new Promise(r => { resolve = r; }), cancel() {}, close: async () => {} };
|
||||
const replies = []; daemon.on('Reply', text => replies.push(text));
|
||||
try {
|
||||
const ask = daemon.ask('hello'); await Promise.resolve(); daemon.cancel();
|
||||
resolve({ text: 'late answer' }); assert.equal(await ask, '');
|
||||
assert.deepEqual(replies, []); assert.equal(daemon.state, 'ARMED'); assert.equal(revoked, true);
|
||||
} finally { await daemon.close(); }
|
||||
});
|
||||
|
||||
@@ -211,12 +211,18 @@ test('GNOME GI imports expose default namespaces and panel has a real menu', ()
|
||||
assert.match(extensionSource, /import Shell from 'gi:\/\/Shell'/);
|
||||
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
|
||||
assert.match(extensionSource, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
||||
assert.match(extensionSource, /PushToTalk/);
|
||||
assert.doesNotMatch(extensionSource, /DO_NOT_AUTO_START/);
|
||||
assert.match(extensionSource, /DBusProxyFlags\.NONE/);
|
||||
assert.match(extensionSource, /_keepSyncing/);
|
||||
assert.match(extensionSource, /Jarvis is starting/);
|
||||
assert.match(extensionSource, /\['Thinking'/);
|
||||
assert.match(extensionSource, /\['ToolCall'/);
|
||||
assert.match(extensionSource, /_openPopup/);
|
||||
assert.match(uiSource, /Open conversation/);
|
||||
assert.match(extensionSource, /PopupMenuSection/);
|
||||
assert.match(extensionSource, /osd\.attach\(this\._panelBox\)/);
|
||||
assert.match(uiSource, /jarvis-panel-state/);
|
||||
assert.doesNotMatch(uiSource, /style_class: 'jarvis-osd'/);
|
||||
assert.match(extensionSource, /_openSettings/);
|
||||
assert.match(extensionSource, /PopupMenuItem\('Settings'\)/);
|
||||
assert.match(extensionSource, /PopupMenuItem\('Grant desktop'\)/);
|
||||
@@ -249,12 +255,14 @@ test('session panel stays closed while Jarvis speaks unless expanded', () => {
|
||||
assert.match(session.view.transcript.children[0].text, /still talking/);
|
||||
});
|
||||
|
||||
test('OSD shows listening without opening a conversation panel', () => {
|
||||
const { JarvisOsd, SessionPanel, timers } = harness();
|
||||
test('voice status lives in the panel chip, not a floating overlay', () => {
|
||||
const { JarvisOsd, SessionPanel, timers, chrome } = harness();
|
||||
const osd = new JarvisOsd();
|
||||
const session = new SessionPanel();
|
||||
osd.attach();
|
||||
const bar = { children: [], add_child(child) { this.children.push(child); } };
|
||||
osd.attach(bar);
|
||||
session.attach();
|
||||
assert.equal(bar.children[0], osd.root);
|
||||
osd.setState('LISTENING');
|
||||
assert.equal(osd.root.visible, true);
|
||||
assert.match(osd.label.text, /Listening/);
|
||||
@@ -263,11 +271,18 @@ test('OSD shows listening without opening a conversation panel', () => {
|
||||
assert.equal(osd.root.visible, false);
|
||||
osd.setState('SPEAKING');
|
||||
assert.equal(osd.root.visible, true);
|
||||
assert.ok(timers.size >= 1);
|
||||
assert.match(osd.label.text, /Speaking/);
|
||||
assert.equal(timers.size, 0);
|
||||
osd.setState('THINKING');
|
||||
assert.match(osd.label.text, /Thinking/);
|
||||
osd.setState('SLEEPING');
|
||||
assert.match(osd.label.text, /Privacy/);
|
||||
osd.showWake();
|
||||
assert.match(osd.label.text, /Wake/);
|
||||
assert.equal(timers.size, 1);
|
||||
osd.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(chrome.includes(osd.root), false);
|
||||
});
|
||||
|
||||
test('computer-use chrome shows a target without a transcript', () => {
|
||||
@@ -376,18 +391,9 @@ test('closing the popup ends hold-to-talk', () => {
|
||||
assert.deepEqual(talks, [false]);
|
||||
});
|
||||
|
||||
test('prefs bind every GSettings schema key', () => {
|
||||
test('both settings entry points use the shared preferences window', () => {
|
||||
const prefs = readFileSync(new URL('../apps/gnome-extension/[email protected]/prefs.js', import.meta.url), 'utf8');
|
||||
const schema = readFileSync(new URL('../apps/gnome-extension/[email protected]/schemas/org.gnome.shell.extensions.jarvis.gschema.xml', import.meta.url), 'utf8');
|
||||
const keys = [...schema.matchAll(/<key name="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.ok(keys.length >= 14);
|
||||
for (const key of keys) assert.match(prefs, new RegExp(`['"]${key}['"]`));
|
||||
assert.match(prefs, /wakePhrase/);
|
||||
assert.match(prefs, /ttsEnabled/);
|
||||
assert.match(prefs, /modelProfile/);
|
||||
assert.match(prefs, /'tray'/);
|
||||
assert.match(prefs, /'expanded'/);
|
||||
assert.match(prefs, /ComputerGrant/);
|
||||
assert.match(prefs, /Allow now/);
|
||||
assert.match(prefs, /ComputerRevoke/);
|
||||
const control = readFileSync(new URL('../apps/control-center/main.js', import.meta.url), 'utf8');
|
||||
assert.match(prefs, /fillSettingsWindow/);
|
||||
assert.match(control, /fillSettingsWindow/);
|
||||
});
|
||||
|
||||
+50
-7
@@ -105,7 +105,12 @@ exit 0
|
||||
assert.equal(await readFile(path.join(configDir, 'config.json'), 'utf8'), '{"wakePhrase":"hey jarvis","ttsEnabled":true}\n');
|
||||
const unit = await readFile(path.join(home, '.config/systemd/user/jarvisd.service'), 'utf8');
|
||||
assert.match(unit, /bare-runtime-linux/);
|
||||
assert.match(unit, /WantedBy=default\.target/);
|
||||
assert.match(unit, /Restart=always/);
|
||||
assert.doesNotMatch(unit, /\/usr\/bin\/node/);
|
||||
const dbusService = await readFile(path.join(home, '.local/share/dbus-1/services/io.qvac.Jarvis.service'), 'utf8');
|
||||
assert.match(dbusService, /Name=io\.qvac\.Jarvis/);
|
||||
assert.match(dbusService, /SystemdService=jarvisd\.service/);
|
||||
const commands = await readFile(log, 'utf8');
|
||||
assert.match(commands, /systemctl --user stop jarvisd\.service/);
|
||||
assert.match(commands, /gnome-extensions enable jarvis@qvac\.local/);
|
||||
@@ -128,11 +133,49 @@ exit 0
|
||||
}
|
||||
});
|
||||
|
||||
test('first-run keeps TTS enabled even when the preview is skipped', () => {
|
||||
const source = readFileSync(new URL('../packaging/first-run.sh', import.meta.url), 'utf8');
|
||||
assert.match(source, /Play a TTS preview now\?/);
|
||||
assert.match(source, /TTS_ENABLED=true/);
|
||||
assert.doesNotMatch(source, /Enable TTS preview\?/);
|
||||
assert.doesNotMatch(source, /TTS_ENABLED=false/);
|
||||
assert.match(source, /spoken replies remain enabled/);
|
||||
test('install.sh seeds default config when missing and does not require first-run', async () => {
|
||||
const work = await mkdtemp(path.join(os.tmpdir(), 'jarvis-install-defaults-'));
|
||||
const home = path.join(work, 'home');
|
||||
const root = path.join(work, 'src');
|
||||
const bin = path.join(work, 'bin');
|
||||
const machine = (await run('uname', ['-m'], process.env)).stdout.trim();
|
||||
const bareName = /aarch64|arm64/.test(machine) ? 'bare-runtime-linux-arm64' : 'bare-runtime-linux-x64';
|
||||
try {
|
||||
await mkdir(path.join(root, 'packaging'), { recursive: true });
|
||||
await mkdir(path.join(root, 'apps/gnome-extension'), { recursive: true });
|
||||
await mkdir(path.join(root, 'node_modules', bareName, 'bin'), { recursive: true });
|
||||
await mkdir(bin, { recursive: true });
|
||||
await cp(path.join(repo, 'packaging/install.sh'), path.join(root, 'packaging/install.sh'));
|
||||
await cp(path.join(repo, 'packaging/jarvisd.service'), path.join(root, 'packaging/jarvisd.service'));
|
||||
await cp(path.join(repo, 'packaging/qvac.config.template.json'), path.join(root, 'packaging/qvac.config.template.json'));
|
||||
await cp(path.join(repo, 'apps/gnome-extension/[email protected]'), path.join(root, 'apps/gnome-extension/[email protected]'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', bareName, 'bin', 'bare'), '#!/bin/sh\nexit 0\n');
|
||||
await chmod(path.join(root, 'node_modules', bareName, 'bin', 'bare'), 0o755);
|
||||
for (const name of ['systemctl', 'gnome-extensions', 'gsettings', 'gdbus', 'logger']) {
|
||||
await writeFile(path.join(bin, name), `#!/bin/sh\nexit 0\n`);
|
||||
await chmod(path.join(bin, name), 0o755);
|
||||
}
|
||||
|
||||
const result = await run('bash', [path.join(root, 'packaging/install.sh'), '--enable'], {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
XDG_DATA_HOME: path.join(home, '.local/share'),
|
||||
XDG_CACHE_HOME: path.join(home, '.cache'),
|
||||
XDG_STATE_HOME: path.join(home, '.local/state'),
|
||||
});
|
||||
|
||||
const config = JSON.parse(await readFile(path.join(home, '.config/jarvis/config.json'), 'utf8'));
|
||||
assert.equal(config.wakePhrase, 'hey jarvis');
|
||||
assert.equal(config.modelProfile, 'laptop-16gb');
|
||||
assert.equal(config.ttsEnabled, true);
|
||||
assert.match(result.stdout, /Jarvis is ready/);
|
||||
assert.doesNotMatch(result.stdout, /first-run/);
|
||||
const installSource = readFileSync(new URL('../packaging/install.sh', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(installSource, /first-run/);
|
||||
assert.equal(existsSync(path.join(repo, 'packaging/first-run.sh')), false);
|
||||
assert.equal(existsSync(path.join(repo, 'packaging/write-config.js')), false);
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Agent, acquireQvac, releaseQvac, closeQvac, qvacStatus } from '../daemon/qvac-master.js';
|
||||
|
||||
test('concurrent master acquisition loads once and failed acquisition does not leak owners', async () => {
|
||||
const original = { resources: Agent.engine.resources, load: Agent.engine.load, close: Agent.engine.close };
|
||||
let loads = 0;
|
||||
Agent.engine.resources = async () => ({ gpus: [{}] });
|
||||
Agent.engine.load = async () => { loads++; return { device: 'gpu' }; };
|
||||
Agent.engine.close = async () => {};
|
||||
try {
|
||||
await Promise.all([acquireQvac(), acquireQvac()]);
|
||||
assert.equal(loads, 1); assert.equal(qvacStatus().owners, 2);
|
||||
releaseQvac(); releaseQvac(); await closeQvac();
|
||||
Agent.engine.resources = async () => { throw new Error('discovery failed'); };
|
||||
await assert.rejects(acquireQvac(), /discovery failed/);
|
||||
assert.equal(qvacStatus().owners, 0);
|
||||
Agent.engine.resources = async () => ({ gpus: [{}] });
|
||||
await acquireQvac(); assert.equal(loads, 2); releaseQvac();
|
||||
} finally { await closeQvac(); Object.assign(Agent.engine, original); }
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, mkdir, writeFile, readFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { ComputerUseSession } from '../computer-use/session.js';
|
||||
import { ComputerActuator } from '../computer-use/actuator.js';
|
||||
import { PortalInputBackend } from '../computer-use/portal-input.js';
|
||||
import { createComputerObserveTools } from '../skills/computer-observe.js';
|
||||
import { createPhase2Tools } from '../skills/phase2-tools.js';
|
||||
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
||||
import { assertLocalEndpoint } from '../daemon/network-policy.js';
|
||||
const require = createRequire(import.meta.url);
|
||||
const custom = require('../vendor/agent-harness/agent/custom-tools.js');
|
||||
|
||||
test('custom tool permissions survive registration and default to confirmation', () => {
|
||||
const id = 'review-permissions';
|
||||
try {
|
||||
custom.register(id, ['read', 'write', 'dangerous', 'computer-use', undefined].map((permission, i) => ({ name: `review_${i}`, permission })));
|
||||
assert.equal(custom.needsPermission(id, 'review_0', 'ask'), false);
|
||||
for (let i = 1; i < 5; i++) {
|
||||
assert.equal(custom.needsPermission(id, `review_${i}`, 'ask'), true);
|
||||
assert.equal(custom.needsPermission(id, `review_${i}`, 'always-approve'), false);
|
||||
}
|
||||
custom.unregister(id, 'review_1');
|
||||
assert.equal(custom.needsPermission(id, 'review_1', 'ask'), false);
|
||||
} finally { custom.clear(id); }
|
||||
});
|
||||
|
||||
test('workspace tools reject outside roots and symlink escapes and count UTF-8 bytes', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-path-test-'));
|
||||
const root = path.join(dir, 'workspace'); await mkdir(root);
|
||||
const outside = path.join(dir, 'outside'); await mkdir(outside);
|
||||
await writeFile(path.join(outside, 'secret'), 'private');
|
||||
await symlink(outside, path.join(root, 'link'));
|
||||
await symlink(path.join(outside, 'missing'), path.join(root, 'dangling'));
|
||||
const tools = Object.fromEntries(createPhase2Tools({ cwd: root }).map(t => [t.name, t]));
|
||||
await assert.rejects(tools.fs_read.execute({ file: 'link/secret' }), /outside/);
|
||||
await assert.rejects(tools.fs_write.execute({ file: 'link/new', contents: 'x', confirmed: true }), /outside/);
|
||||
await assert.rejects(tools.fs_write.execute({ file: 'dangling', contents: 'x', confirmed: true }), /symlink/);
|
||||
await assert.rejects(tools.fs_search.execute({ root: outside, query: 'secret' }), /outside/);
|
||||
const result = await tools.fs_write.execute({ file: 'new', contents: 'é', confirmed: true });
|
||||
assert.equal(result.bytes, 2);
|
||||
assert.equal(await readFile(path.join(root, 'new'), 'utf8'), 'é');
|
||||
});
|
||||
|
||||
test('coordinate click uses portal input even when accessibility actions exist', async () => {
|
||||
const session = new ComputerUseSession(); session.grant(); const sent = [];
|
||||
const actuator = new ComputerActuator({ session, input: { send: a => sent.push(a) }, atspiAction: () => assert.fail('unexpected semantic click'), sleep: async () => {} });
|
||||
await actuator.click({ x: 20, y: 30 });
|
||||
assert.equal(sent[0].x, 20);
|
||||
});
|
||||
|
||||
test('stale semantic refs fail before input and revocation during preview prevents action', async () => {
|
||||
const session = new ComputerUseSession(); session.grant();
|
||||
const actuator = new ComputerActuator({ session, find: async () => [], input: { send: () => assert.fail('unexpected input') }, highlight: async () => session.revoke() });
|
||||
await assert.rejects(actuator.type({ ref: 'missing', text: 'secret' }), /stale/);
|
||||
await assert.rejects(actuator.click({ x: 1, y: 1 }), /inactive/);
|
||||
});
|
||||
|
||||
test('expired grants prevent desktop observation', async () => {
|
||||
let now = 0; const computer = new ComputerUseSession({ clock: () => now }); computer.grant(); now = 180001;
|
||||
const [observe] = createComputerObserveTools({ computer, observer: { observe: () => assert.fail('expired observation') } });
|
||||
await assert.rejects(observe.execute(), /grant/);
|
||||
});
|
||||
|
||||
function helper() {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter(); child.stdin.writable = true; child.stdin.write = () => {};
|
||||
child.kill = () => { child.killed = true; };
|
||||
return child;
|
||||
}
|
||||
|
||||
test('portal grants wait for a complete readiness message and clear on exit', async () => {
|
||||
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
||||
const grant = input.grant();
|
||||
child.stdout.emit('data', '{"type":"rea'); assert.equal(input.available, false);
|
||||
child.stdout.emit('data', 'dy","restore_token_present":false}\n');
|
||||
assert.equal((await grant).backend, 'portal-ei');
|
||||
child.emit('close', 0); assert.equal(input.available, false);
|
||||
assert.throws(() => input.send({}), /unavailable/);
|
||||
});
|
||||
|
||||
test('revoked portal grants cannot become ready later', async () => {
|
||||
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
||||
const grant = input.grant(); input.revoke();
|
||||
child.stdout.emit('data', '{"type":"ready"}\n');
|
||||
await assert.rejects(grant, /revoked/); assert.equal(input.available, false); assert.equal(child.killed, true);
|
||||
});
|
||||
|
||||
test('portal helper errors reject and allow a new grant', async () => {
|
||||
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
||||
const grant = input.grant(); child.stdout.emit('data', '{"type":"error","reason":"denied"}\n');
|
||||
await assert.rejects(grant, /denied/); assert.equal(input.process, null);
|
||||
});
|
||||
|
||||
test('idle timer never attempts to sleep a thinking or speaking voice', () => {
|
||||
let now = 0; const voice = new VoiceStateMachine({ now: () => now, idleMs: 10 });
|
||||
voice.typedUtterance(); now = 100; assert.equal(voice.expireIdle(), 'THINKING');
|
||||
voice.speak(); now = 200; assert.equal(voice.expireIdle(), 'SPEAKING');
|
||||
});
|
||||
|
||||
test('local endpoint policy accepts IPv6 loopback', () => {
|
||||
assert.equal(assertLocalEndpoint('http://[::1]:8080').hostname, '[::1]');
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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 { 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';
|
||||
|
||||
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({ 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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('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('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, true);
|
||||
});
|
||||
@@ -151,6 +151,57 @@ test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
|
||||
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
|
||||
});
|
||||
|
||||
test('voice loop retries microphone capture after PipeWire comes up', async () => {
|
||||
let starts = 0;
|
||||
const capture = new EventEmitter();
|
||||
capture.start = () => {
|
||||
starts += 1;
|
||||
if (starts === 1) queueMicrotask(() => capture.emit('close', { code: 1 }));
|
||||
};
|
||||
capture.stop = () => {};
|
||||
const loop = new VoiceLoop({
|
||||
capture,
|
||||
asr: { start: async () => {} },
|
||||
tts: { start: async () => {} },
|
||||
wake: new WakeEngine({ detect: () => null }),
|
||||
vad: new VadSegmenter(),
|
||||
captureRetryMs: 20,
|
||||
});
|
||||
loop.on('error', () => {});
|
||||
await loop.start();
|
||||
assert.equal(loop.status.asr, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(loop.status.capture, false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.ok(starts >= 2);
|
||||
assert.equal(loop.status.capture, true);
|
||||
await loop.stop();
|
||||
});
|
||||
|
||||
test('voice loop retries ASR after a first-login GPU miss', async () => {
|
||||
let attempts = 0;
|
||||
const capture = new EventEmitter();
|
||||
capture.start = () => { capture.started = true; };
|
||||
capture.stop = () => {};
|
||||
const loop = new VoiceLoop({
|
||||
capture,
|
||||
asr: { start: async () => { attempts += 1; if (attempts === 1) throw new Error('GPU not ready'); } },
|
||||
tts: { start: async () => {} },
|
||||
wake: new WakeEngine({ detect: () => null }),
|
||||
vad: new VadSegmenter(),
|
||||
asrRetryMs: 20,
|
||||
});
|
||||
loop.on('error', () => {});
|
||||
await loop.start();
|
||||
assert.equal(loop.status.asr, false);
|
||||
assert.equal(capture.started, undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(loop.status.asr, true);
|
||||
assert.equal(capture.started, true);
|
||||
await loop.stop();
|
||||
});
|
||||
|
||||
test('voice adapters load canonical ASR and TTS plugins', () => {
|
||||
const source = readFileSync(new URL('../daemon/voice-adapters.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /whispercpp-transcription/);
|
||||
|
||||
Reference in New Issue
Block a user