202 lines
8.7 KiB
JavaScript
202 lines
8.7 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const source = readFileSync(new URL('../apps/gnome-extension/[email protected]/extension.js', import.meta.url), 'utf8');
|
|
function harness() {
|
|
const timers = new Map();
|
|
class Actor {
|
|
constructor(props = {}) {
|
|
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
|
|
Object.assign(this, props);
|
|
this.children = [];
|
|
this.visible = props.visible !== false;
|
|
this.clutter_text = { connect() {}, ellipsize: null };
|
|
}
|
|
add_child(child) { this.children.push(child); }
|
|
destroy_all_children() { this.children = []; }
|
|
get_n_children() { return this.children.length; }
|
|
contains() { return false; }
|
|
get_last_child() { return this.children[this.children.length - 1] || null; }
|
|
connect() { return 1; }
|
|
hide() { this.visible = false; }
|
|
show() { this.visible = true; }
|
|
destroy() { this.destroyed = true; }
|
|
set_position() {}
|
|
set_width() {}
|
|
set_height() {}
|
|
grab_key_focus() {}
|
|
set_style() {}
|
|
add_style_class_name() {}
|
|
get_first_child() { return this.children[0] || null; }
|
|
}
|
|
const context = vm.createContext({
|
|
Extension: class {},
|
|
global: { stage: { get_key_focus() { return null; } } },
|
|
St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } },
|
|
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0 },
|
|
Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } },
|
|
Main: { layoutManager: { addChrome() {}, primaryMonitor: { x: 0, y: 0, width: 1920, height: 1080 } } },
|
|
GLib: {
|
|
PRIORITY_DEFAULT: 0, PRIORITY_DEFAULT_IDLE: 200, SOURCE_REMOVE: false,
|
|
idle_add(_priority, callback) { callback(); return 1; },
|
|
timeout_add(_priority, _delay, callback) { const id = timers.size + 1; timers.set(id, callback); return id; },
|
|
Source: { remove(id) { timers.delete(id); } },
|
|
},
|
|
log() {},
|
|
});
|
|
vm.runInContext(source.replace(/^import .*;\n/gm, '').replace('export default class', 'class') +
|
|
'\nglobalThis.classes = { ArcOverlay, JarvisExtension, JarvisProxy };', context);
|
|
return { ...context.classes, timers };
|
|
}
|
|
|
|
test('overlay constructs and destroys with pending halo animation', () => {
|
|
const { ArcOverlay, timers } = harness();
|
|
const overlay = new ArcOverlay();
|
|
assert.equal(overlay.header.children[1].x_expand, true);
|
|
overlay.attach();
|
|
overlay.show(true);
|
|
overlay.showHalo();
|
|
overlay.showHalo();
|
|
assert.equal(timers.size, 1);
|
|
overlay.destroy();
|
|
assert.equal(timers.size, 0);
|
|
});
|
|
|
|
test('empty chrome widgets start hidden', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
assert.equal(overlay.job.visible, false);
|
|
assert.equal(overlay.target.visible, false);
|
|
assert.equal(overlay.cursor.visible, false);
|
|
assert.equal(overlay.wave.visible, false);
|
|
});
|
|
|
|
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.token('Hello! I am ready.');
|
|
overlay.finalizeReply('[object Object]');
|
|
overlay.finalizeReply({ text: 'ignored duplicate' });
|
|
assert.equal(overlay.transcript.children.length, 1);
|
|
assert.match(overlay.transcript.children[0].text, /Hello! I am ready/);
|
|
assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/);
|
|
});
|
|
|
|
test('final reply is shown after tool result rows', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.addToolCall('{"name":"runtime_status"}');
|
|
overlay.addToolResult('{"name":"runtime_status"}');
|
|
overlay.finalizeReply('Your computer is ready.');
|
|
assert.equal(overlay.transcript.children.length, 3);
|
|
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
|
});
|
|
|
|
test('tokens after a tool result start a new spoken Jarvis row', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.addToolCall('{"name":"capability_status"}');
|
|
overlay.addToolResult('{"name":"capability_status"}');
|
|
overlay.token('Your computer is ready.');
|
|
overlay.finalizeReply('Your computer is ready.');
|
|
assert.equal(overlay.transcript.children.length, 3);
|
|
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
|
assert.doesNotMatch(overlay.transcript.children[2].style_class, /jarvis-row-tool/);
|
|
});
|
|
|
|
test('addRow and token coerce objects to readable text', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.addRow('U', { text: 'Hi there' });
|
|
overlay.token({ text: 'Hello from Jarvis' });
|
|
assert.equal(overlay.transcript.children.length, 2);
|
|
assert.match(overlay.transcript.children[0].text, /Hi there/);
|
|
assert.match(overlay.transcript.children[1].text, /Hello from Jarvis/);
|
|
assert.doesNotMatch(overlay.transcript.children.map((row) => row.text).join('\n'), /object Object/);
|
|
});
|
|
|
|
for (const fails of [false, true]) {
|
|
test('daemon connection finishing after disable is ignored: ' + (fails ? 'failure' : 'success'), async () => {
|
|
const { JarvisExtension } = harness();
|
|
const extension = new JarvisExtension();
|
|
let finish;
|
|
extension.proxy = { connect: () => new Promise((resolve, reject) => { finish = fails ? reject : resolve; }) };
|
|
const pending = extension._connectDaemon();
|
|
extension.proxy = null;
|
|
extension.overlay = null;
|
|
finish(fails ? new Error('Disconnected') : undefined);
|
|
await pending;
|
|
});
|
|
}
|
|
|
|
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
|
|
assert.match(source, /import Shell from 'gi:\/\/Shell'/);
|
|
assert.match(source, /import Meta from 'gi:\/\/Meta'/);
|
|
assert.match(source, /import Pango from 'gi:\/\/Pango'/);
|
|
assert.match(source, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
|
assert.match(source, /PushToTalk/);
|
|
assert.match(source, /finalizeReply/);
|
|
assert.match(source, /\['Thinking'/);
|
|
assert.match(source, /\['ToolCall'/);
|
|
assert.match(source, /Minimize/);
|
|
});
|
|
|
|
test('D-Bus confirmation signal does not call missing Interface.emit', () => {
|
|
const dbusSource = readFileSync(new URL('../daemon/dbus-service.js', import.meta.url), 'utf8');
|
|
assert.match(dbusSource, /ConfirmationRequired\(tool, args, pattern\) \{ return \[String\(tool\)/);
|
|
assert.doesNotMatch(dbusSource, /ConfirmationRequired\(tool, args, pattern\) \{ this\.emit/);
|
|
assert.match(dbusSource, /ContextReset\(\) \{ return; \}/);
|
|
assert.match(dbusSource, /Confirm: \{ inSignature: 'sss'/);
|
|
});
|
|
|
|
test('minimize stays closed while Jarvis speaks, then restore shows the transcript', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.attach();
|
|
overlay.show(true);
|
|
overlay.minimize();
|
|
overlay.setState('SPEAKING');
|
|
overlay.finalizeReply('I am still talking in the background.');
|
|
assert.equal(overlay.root.visible, false);
|
|
overlay.show(true);
|
|
assert.equal(overlay.root.visible, true);
|
|
assert.match(overlay.transcript.children[0].text, /still talking/);
|
|
});
|
|
|
|
test('ConfirmationRequired chips answer Confirm with job and tool ids', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
const answers = [];
|
|
overlay.onConfirm = (...args) => answers.push(args);
|
|
overlay.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
|
|
assert.equal(overlay.confirm.visible, true);
|
|
overlay._answerConfirm('allow');
|
|
assert.equal(overlay.confirm.visible, false);
|
|
assert.deepEqual(answers, [['job-1', 'call-9', 'allow']]);
|
|
});
|
|
|
|
test('errors and reset failures are one-line notices, not chat rows', () => {
|
|
const { ArcOverlay } = harness();
|
|
const overlay = new ArcOverlay();
|
|
overlay.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
|
|
assert.equal(overlay.transcript.children.length, 0);
|
|
assert.equal(overlay.notice.visible, true);
|
|
assert.doesNotMatch(overlay.notice.text, /\n/);
|
|
overlay.clear();
|
|
overlay.setNotice('New conversation');
|
|
assert.equal(overlay.notice.text, 'New conversation');
|
|
});
|
|
|
|
test('prefs bind every GSettings schema key', () => {
|
|
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/);
|
|
});
|