Add web UI form wiring regression tests and preserve dashboard draft state

This commit is contained in:
William Valentin
2026-02-21 22:36:21 -08:00
parent 9707b5a5df
commit 387906ce4d
7 changed files with 1093 additions and 4 deletions
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseHTML } from 'linkedom';
type ChatModule = {
ChatPage: {
render: (el: unknown, client: unknown) => Promise<void>;
teardown: () => void;
};
};
function installSelectValueShim(windowObj: any) {
const proto = windowObj?.HTMLSelectElement?.prototype;
if (!proto) {return;}
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor?.set) {return;}
Object.defineProperty(proto, 'value', {
configurable: true,
get() {
const options = Array.from((this as any).options ?? []) as any[];
const selected = options.find((option: any) => option.selected);
return selected ? String(selected.value ?? '') : '';
},
set(next) {
const desired = String(next ?? '');
const options = Array.from((this as any).options ?? []) as any[];
let matched = false;
for (const option of options as any[]) {
const selected = String(option.value ?? '') === desired;
option.selected = selected;
if (selected) {
matched = true;
}
}
if (!matched && options.length > 0) {
(options[0] as any).selected = true;
}
},
});
}
function createStream(resultPayload: Record<string, unknown>) {
const handlers = new Map<string, Array<(data: any) => void>>();
return {
on(event: string, cb: (data: any) => void) {
if (!handlers.has(event)) {
handlers.set(event, []);
}
handlers.get(event)?.push(cb);
},
emit(event: string, data: any) {
for (const cb of handlers.get(event) ?? []) {
cb(data);
}
},
result: Promise.resolve(resultPayload),
};
}
function createClient() {
const calls: Array<{ method: string; params?: Record<string, unknown> }> = [];
const streamCalls: Array<{ method: string; params?: Record<string, unknown> }> = [];
const cancelGate: { resolve?: () => void } = {};
const client = {
async call(method: string, params?: Record<string, unknown>) {
calls.push({ method, params });
if (method === 'sessions.list') {
return {
sessions: [
{ id: 'ws:alpha', messageCount: 3, lastMessageAt: Date.now() },
{ id: 'ws:beta', messageCount: 1, lastMessageAt: Date.now() - 5000 },
],
};
}
if (method === 'sessions.history') {
return {
messages: [
{ role: 'user', content: 'hello', timestamp: Date.now() - 1000 },
{ role: 'assistant', content: 'hi', timestamp: Date.now() },
],
};
}
if (method === 'sessions.create') {
return { sessionId: 'ws:new' };
}
if (method === 'agent.cancel') {
if (cancelGate.resolve) {
cancelGate.resolve();
}
return { ok: true };
}
return null;
},
stream(method: string, params?: Record<string, unknown>) {
streamCalls.push({ method, params });
if (params?.message === 'long run') {
return {
on() {},
result: new Promise((resolve) => {
cancelGate.resolve = () => resolve({ content: 'cancelled' });
}),
};
}
return createStream({ content: 'ack' });
},
};
return { client, calls, streamCalls };
}
describe('ChatPage wiring', () => {
let root: any;
let windowObj: any;
let ChatPage: ChatModule['ChatPage'];
beforeEach(async () => {
vi.resetModules();
const { document, window } = parseHTML('<!doctype html><html><body><div id="root"></div></body></html>') as unknown as {
document: any;
window: any;
};
(globalThis as any).document = document;
(globalThis as any).window = window;
(globalThis as any).marked = {
parse: (text: string) => text,
};
root = document.getElementById('root');
windowObj = window;
installSelectValueShim(windowObj);
// @ts-expect-error JS module without declaration file.
const mod = await import('./chat.js') as unknown as ChatModule;
ChatPage = mod.ChatPage;
});
afterEach(() => {
ChatPage.teardown();
delete (globalThis as any).document;
delete (globalThis as any).window;
delete (globalThis as any).marked;
});
it('wires sessions, history, search mode, send, new session, and cancel', async () => {
const { client, calls, streamCalls } = createClient();
await ChatPage.render(root, client);
expect(calls.some((entry) => entry.method === 'sessions.list')).toBe(true);
const sortSelect = root.querySelector('#chat-session-sort');
sortSelect.value = 'name';
sortSelect.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
await Promise.resolve();
expect(calls.filter((entry) => entry.method === 'sessions.list').length).toBeGreaterThanOrEqual(2);
const sessionSelect = root.querySelector('#chat-session-select');
const firstSession = sessionSelect.querySelector('option[value="ws:alpha"]');
if (firstSession) {
firstSession.selected = true;
}
sessionSelect.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
root.querySelector('#chat-load-history').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
expect(calls.some((entry) => entry.method === 'sessions.history')).toBe(true);
root.querySelector('#chat-search').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
const input = root.querySelector('#chat-input');
input.value = 'status of flynn';
root.querySelector('#chat-send').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const searchSend = streamCalls.find((entry) => entry.method === 'agent.send' && entry.params?.message === 'Search the web for: status of flynn');
expect(searchSend).toBeTruthy();
root.querySelector('#chat-new-session').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
expect(calls.some((entry) => entry.method === 'sessions.create')).toBe(true);
input.value = 'long run';
root.querySelector('#chat-send').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
root.querySelector('#chat-send').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
expect(calls.some((entry) => entry.method === 'agent.cancel')).toBe(true);
});
});
+97 -3
View File
@@ -14,6 +14,8 @@ let _assistantSaveState = null;
let _lastAssistantConfig = null;
let _assistantManualOverrides = new Set();
let _assistantModelDefaultsDraft = null;
let _assistantDraftState = new Map();
let _assistantDraftTouchedAt = 0;
let _lastCouncilTask = '';
let _lastCouncilResult = null;
let _lastCouncilError = null;
@@ -41,6 +43,7 @@ const SERVICE_TOGGLE_PATCH_PATHS = {
audio_transcription: 'audio.enabled',
sandbox: 'sandbox.enabled',
};
const ASSISTANT_DRAFT_TTL_MS = 2 * 60 * 1000;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -197,6 +200,77 @@ function setAssistantSaveState(message, tone = 'neutral') {
};
}
function writeAssistantDraftValue(control) {
if (!control || !control.id) {return;}
const isCheckbox = control.tagName === 'INPUT' && control.type === 'checkbox';
if (isCheckbox) {
_assistantDraftState.set(control.id, { kind: 'checkbox', value: Boolean(control.checked) });
} else if (control.tagName === 'SELECT') {
const selectedOption = Array.from(control.options ?? []).find((option) => option.selected);
_assistantDraftState.set(control.id, { kind: 'value', value: selectedOption?.value ?? '' });
} else {
_assistantDraftState.set(control.id, { kind: 'value', value: control.value ?? '' });
}
_assistantDraftTouchedAt = Date.now();
}
function bindAssistantDraftTracking(rootEl) {
const controls = rootEl.querySelectorAll('input[id], select[id], textarea[id]');
controls.forEach((control) => {
control.addEventListener('input', () => writeAssistantDraftValue(control));
control.addEventListener('change', () => writeAssistantDraftValue(control));
});
}
function applyAssistantDraftState(rootEl) {
if (_assistantDraftState.size === 0) {return;}
const now = Date.now();
if (_assistantDraftTouchedAt > 0 && (now - _assistantDraftTouchedAt) > ASSISTANT_DRAFT_TTL_MS) {
_assistantDraftState = new Map();
_assistantDraftTouchedAt = 0;
return;
}
for (const [id, draft] of _assistantDraftState.entries()) {
const control = rootEl.querySelector(`#${id}`);
if (!control) {continue;}
const isCheckbox = control.tagName === 'INPUT' && control.type === 'checkbox';
if (draft.kind === 'checkbox' && isCheckbox) {
control.checked = Boolean(draft.value);
} else if (control.tagName === 'SELECT') {
const options = Array.from(control.options ?? []);
const desired = String(draft.value ?? '');
let matchedIndex = -1;
for (let i = 0; i < options.length; i++) {
const option = options[i];
const isSelected = option.value === desired;
option.selected = isSelected;
if (isSelected) {
matchedIndex = i;
}
}
if (options.length > 0) {
const fallbackIndex = matchedIndex >= 0 ? matchedIndex : 0;
options[fallbackIndex].selected = true;
if ('selectedIndex' in control) {
control.selectedIndex = fallbackIndex;
}
}
try {
control.value = desired;
} catch {
// Some DOM shims expose readonly value on select nodes.
}
} else if ('value' in control) {
try {
control.value = String(draft.value ?? '');
} catch {
// Ignore rare non-writable value surfaces from non-browser DOM shims.
}
}
}
}
function renderAssistantSaveState() {
if (!_assistantSaveState) {
return '<div id="ops-assistant-status" class="text-sm text-zinc-500 mt-4">No recent save action.</div>';
@@ -1044,7 +1118,7 @@ function updateAssistantHealth(configData) {
</label>
</div>
<div class="flex flex-wrap gap-2">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-councils">
<button type="button" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-councils">
Save Councils
</button>
</div>
@@ -1053,7 +1127,7 @@ function updateAssistantHealth(configData) {
<div class="text-xs text-zinc-500 mb-3">${escapeHtml(councilSummary)}</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-2 mb-3">
<input id="assist-council-task" type="text" value="${escapeHtml(_lastCouncilTask)}" placeholder="Run councils on demand: e.g. design a 2-week experiment plan..." class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Prompt for an ad-hoc council run; use a concrete decision or planning question." />
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="run-council">
<button type="button" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="run-council">
Run Council
</button>
</div>
@@ -1119,6 +1193,9 @@ function updateAssistantHealth(configData) {
${renderAssistantSaveState()}
`;
// Preserve local unsaved form edits across periodic dashboard refreshes.
applyAssistantDraftState(el);
const updateModelOptions = (inputId, provider) => {
const input = el.querySelector(`#${inputId}`);
const list = el.querySelector(`#${inputId}-list`);
@@ -1145,11 +1222,26 @@ function updateAssistantHealth(configData) {
});
}
// Refresh datalist options after draft re-application in case provider selects were restored.
for (const tier of tierRows) {
const providerSelect = el.querySelector(`#assist-tier-${tier}-provider`);
if (!providerSelect) {continue;}
updateModelOptions(`assist-tier-${tier}-model`, providerSelect.value);
}
for (const task of taskRowsForModels) {
const providerSelect = el.querySelector(`#assist-bg-${task}-provider`);
if (!providerSelect) {continue;}
updateModelOptions(`assist-bg-${task}-model`, providerSelect.value);
}
bindAssistantDraftTracking(el);
const statusEl = el.querySelector('#ops-assistant-status');
const buttons = el.querySelectorAll('.assistant-action-btn');
buttons.forEach((button) => {
button.addEventListener('click', async () => {
const action = button.getAttribute('data-action');
const rawAction = button.getAttribute('data-action');
const action = rawAction === 'save-council' ? 'save-councils' : rawAction;
let patches = null;
if (action === 'toggle-announce') {
patches = { 'automation.delivery_mode': announce ? 'shared_session' : 'announce' };
@@ -1746,5 +1838,7 @@ export const DashboardPage = {
_lastAssistantConfig = null;
_assistantManualOverrides = new Set();
_assistantModelDefaultsDraft = null;
_assistantDraftState = new Map();
_assistantDraftTouchedAt = 0;
},
};
+380
View File
@@ -0,0 +1,380 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseHTML } from 'linkedom';
type DashboardModule = {
DashboardPage: {
render: (el: unknown, client: unknown) => Promise<void>;
teardown: () => void;
};
};
function deepClone(value: unknown) {
return JSON.parse(JSON.stringify(value));
}
function setByPath(target: Record<string, any>, path: string, value: unknown) {
const parts = path.split('.');
let cursor: Record<string, any> = target;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i];
const next = cursor[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
cursor[key] = {};
}
cursor = cursor[key] as Record<string, any>;
}
cursor[parts[parts.length - 1]] = value;
}
function createInitialConfig() {
return {
automation: {
delivery_mode: 'shared_session',
daily_briefing: {
enabled: true,
name: 'daily-briefing',
schedule: '0 8 * * *',
timezone: 'UTC',
model_tier: 'default',
output: {
channel: 'telegram',
peer: '12345',
},
prompt: 'Summarize the top priorities.',
},
},
memory: {
daily_log: { enabled: true },
proactive_extract: { enabled: true, min_tool_calls: 2 },
},
tts: {
enabled: false,
enabled_channels: [],
},
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
background_models: {
compaction: { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' },
memory_extraction: { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' },
classification: { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' },
tool_summarisation: { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'fast' },
complex_reasoning: { enabled: true, provider: 'openai', model: 'gpt-4o-mini', fallback_tier: 'default' },
},
},
models: {
default: { provider: 'openai', model: 'gpt-4o-mini' },
fast: { provider: 'openai', model: 'gpt-4o-mini' },
complex: { provider: 'anthropic', model: 'claude-3-7-sonnet' },
local: { provider: 'ollama', model: 'llama3.2' },
},
councils: {
enabled: true,
defaults: { max_rounds: 2 },
groups: {
D: { model_tier: 'complex', arbiter_agent: 'council_d_arbiter', freethinker_agent: 'council_d_freethinker' },
P: { model_tier: 'complex', arbiter_agent: 'council_p_arbiter', freethinker_agent: 'council_p_freethinker' },
},
meta_model_tier: 'complex',
meta_arbiter_agent: 'council_meta_arbiter',
scaffold_path: 'docs/councils/ai-council-production-scaffold.json',
},
};
}
function createMockClient() {
const state = {
config: createInitialConfig(),
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
};
const client = {
async call(method: string, params?: Record<string, unknown>) {
state.calls.push({ method, params });
if (method === 'system.metrics') {
return {
messagesProcessed: 0,
queueDepth: 0,
uptime: 30,
activeRequests: 0,
errors: 0,
modelCalls: {
total: 0,
avgLatency: 0,
errorRate: 0,
recentCalls: [],
},
};
}
if (method === 'system.events') {
return { events: [] };
}
if (method === 'system.activeRequests') {
return { requests: [] };
}
if (method === 'system.health') {
return { sessions: 0 };
}
if (method === 'system.services') {
return { services: [] };
}
if (method === 'system.sessionAnalytics') {
return {
daily: [],
topSessions: [],
topTools: [],
topTopics: [],
totalSessions: 0,
totalMessages: 0,
averageMessagesPerSession: 0,
};
}
if (method === 'system.contextUsage') {
return { sessions: [] };
}
if (method === 'system.modelCatalog') {
return {
providers: [
{ provider: 'openai', models: ['gpt-4o-mini', 'gpt-4.1-mini'] },
{ provider: 'anthropic', models: ['claude-3-7-sonnet'] },
{ provider: 'ollama', models: ['llama3.2'] },
],
};
}
if (method === 'config.get') {
return deepClone(state.config);
}
if (method === 'config.patch') {
const patches = (params?.patches ?? {}) as Record<string, unknown>;
for (const [key, value] of Object.entries(patches)) {
setByPath(state.config as Record<string, any>, key, value);
}
return {
applied: Object.keys(patches),
rejected: [],
persisted: true,
};
}
if (method === 'tools.invoke') {
if (params?.tool === 'council.run') {
return {
success: true,
output: JSON.stringify({
pipeline_version: '1.0',
stop_snapshot: { stop_reason: 'complete', round_reached: 1 },
conversations: [],
}),
};
}
if (params?.tool === 'cron.trigger') {
return { success: true, output: 'Triggered.' };
}
return { success: true, output: '' };
}
return null;
},
};
return { state, client };
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
function getControlCurrentValue(control: any) {
if (control.tagName === 'SELECT') {
const options = Array.from(control.options ?? []) as any[];
const selectedOption = options.find((option) => option.selected);
if (selectedOption?.value) {
return String(selectedOption.value);
}
if (typeof control.selectedIndex === 'number' && control.selectedIndex >= 0 && options[control.selectedIndex]?.value) {
return String(options[control.selectedIndex].value);
}
if (typeof control.getAttribute === 'function') {
return String(control.getAttribute('value') ?? '');
}
return String(selectedOption?.value ?? '');
}
return String(control.value ?? '');
}
function setControlToDraft(control: any, windowObj: any) {
if (control.tagName === 'SELECT') {
const options = Array.from(control.options ?? []) as any[];
if (options.length > 1) {
const currentIdx = Math.max(0, options.findIndex((option: any) => option.value === control.value));
const nextIdx = (currentIdx + 1) % options.length;
options.forEach((option) => {
option.selected = false;
});
options[nextIdx].selected = true;
}
} else if (control.tagName === 'INPUT' && control.type === 'checkbox') {
control.checked = !control.checked;
} else if (control.tagName === 'INPUT' && control.type === 'number') {
control.value = control.value === '6' ? '5' : '6';
} else {
control.value = `draft-${control.id}`;
}
control.dispatchEvent(new windowObj.Event('input', { bubbles: true }));
control.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
}
describe('DashboardPage assistant controls', () => {
let container: any;
let windowObj: any;
let DashboardPage: DashboardModule['DashboardPage'];
beforeEach(async () => {
const { document, window } = parseHTML('<!doctype html><html><body><div id="root"></div></body></html>') as unknown as {
document: any;
window: any;
};
windowObj = window;
(globalThis as any).document = document;
(globalThis as any).window = window;
container = document.getElementById('root');
// @ts-expect-error dashboard page is a plain JS module without a .d.ts declaration.
const mod = await import('./dashboard.js') as unknown as DashboardModule;
DashboardPage = mod.DashboardPage;
});
afterEach(() => {
DashboardPage.teardown();
vi.useRealTimers();
delete (globalThis as any).document;
delete (globalThis as any).window;
});
it('preserves unsaved assistant form control values across slow refresh re-renders', async () => {
vi.useFakeTimers();
const { state, client } = createMockClient();
await DashboardPage.render(container, client);
const controls = Array.from(container.querySelectorAll('#ops-assistant-health input[id], #ops-assistant-health select[id], #ops-assistant-health textarea[id]')) as any[];
expect(controls.length).toBeGreaterThan(0);
const expected = new Map<string, { kind: 'value' | 'checkbox'; value: string | boolean }>();
for (const control of controls) {
setControlToDraft(control, windowObj);
if (control.tagName === 'INPUT' && control.type === 'checkbox') {
expected.set(control.id, { kind: 'checkbox', value: Boolean(control.checked) });
} else {
expected.set(control.id, { kind: 'value', value: getControlCurrentValue(control) });
}
}
// Simulate server-side config drift that would overwrite form values without draft preservation.
state.config.councils.groups.D.model_tier = 'fast';
state.config.councils.groups.P.model_tier = 'default';
state.config.models.default.provider = 'anthropic';
state.config.automation.daily_briefing.output.channel = 'discord';
await vi.advanceTimersByTimeAsync(10000);
await flush();
const after = Array.from(container.querySelectorAll('#ops-assistant-health input[id], #ops-assistant-health select[id], #ops-assistant-health textarea[id]')) as any[];
const byId = new Map<string, any>(after.map((control) => [String(control.id), control]));
for (const [id, draft] of expected.entries()) {
const control = byId.get(id);
expect(control, `missing control ${id} after re-render`).toBeTruthy();
if (draft.kind === 'checkbox') {
expect(Boolean(control.checked)).toBe(draft.value);
} else {
expect(getControlCurrentValue(control)).toBe(draft.value);
}
}
});
it('wires all assistant action buttons to patch/tool calls', async () => {
const { state, client } = createMockClient();
await DashboardPage.render(container, client);
const actionButtons = Array.from(container.querySelectorAll('#ops-assistant-health .assistant-action-btn')) as any[];
const actions = new Set(actionButtons.map((button) => String(button.getAttribute('data-action'))));
expect(actions).toEqual(new Set([
'toggle-announce',
'toggle-daily-briefing',
'toggle-memory-daily',
'toggle-memory-proactive',
'toggle-tts',
'playbook-executive',
'playbook-operator',
'playbook-focus',
'playbook-undo',
'save-model-defaults',
'save-councils',
'run-council',
'save-briefing-output',
'test-daily-briefing',
]));
const clickAction = async (action: string) => {
const button = container.querySelector(`#ops-assistant-health .assistant-action-btn[data-action="${action}"]`);
expect(button, `button missing for ${action}`).toBeTruthy();
button.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
};
const councilTask = container.querySelector('#assist-council-task');
councilTask.value = 'Design a rollout plan';
// Ensure briefing output is present for save/test flows.
const briefChannel = container.querySelector('#assist-brief-channel');
const briefPeer = container.querySelector('#assist-brief-peer');
briefChannel.value = 'telegram';
briefPeer.value = '12345';
await clickAction('toggle-announce');
await clickAction('toggle-daily-briefing');
await clickAction('toggle-memory-daily');
await clickAction('toggle-memory-proactive');
await clickAction('toggle-tts');
await clickAction('playbook-executive');
await clickAction('playbook-operator');
await clickAction('playbook-focus');
await clickAction('playbook-undo');
await clickAction('save-model-defaults');
await clickAction('save-councils');
const councilTask2 = container.querySelector('#assist-council-task');
councilTask2.value = 'Design a rollout plan';
await clickAction('run-council');
const briefChannel2 = container.querySelector('#assist-brief-channel');
const briefPeer2 = container.querySelector('#assist-brief-peer');
briefChannel2.value = 'telegram';
briefPeer2.value = '12345';
await clickAction('save-briefing-output');
// Keep daily briefing enabled so button remains active.
state.config.automation.daily_briefing.enabled = true;
await clickAction('test-daily-briefing');
const patchCalls = state.calls.filter((entry) => entry.method === 'config.patch');
const toolCalls = state.calls.filter((entry) => entry.method === 'tools.invoke');
expect(patchCalls.length).toBeGreaterThanOrEqual(11);
expect(patchCalls.some((entry) => Object.prototype.hasOwnProperty.call(entry.params?.patches ?? {}, 'councils.enabled'))).toBe(true);
expect(patchCalls.some((entry) => Object.prototype.hasOwnProperty.call(entry.params?.patches ?? {}, 'agents.primary_tier'))).toBe(true);
expect(patchCalls.some((entry) => Object.prototype.hasOwnProperty.call(entry.params?.patches ?? {}, 'automation.daily_briefing.output.channel'))).toBe(true);
expect(toolCalls.some((entry) => entry.params?.tool === 'council.run')).toBe(true);
expect(toolCalls.some((entry) => entry.params?.tool === 'cron.trigger')).toBe(true);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseHTML } from 'linkedom';
type SessionsModule = {
SessionsPage: {
render: (el: unknown, client: unknown) => Promise<void>;
teardown: () => void;
};
};
function installSelectValueShim(windowObj: any) {
const proto = windowObj?.HTMLSelectElement?.prototype;
if (!proto) {return;}
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor?.set) {return;}
Object.defineProperty(proto, 'value', {
configurable: true,
get() {
const options = Array.from((this as any).options ?? []) as any[];
const selected = options.find((option: any) => option.selected);
return selected ? String(selected.value ?? '') : '';
},
set(next) {
const desired = String(next ?? '');
const options = Array.from((this as any).options ?? []) as any[];
let matched = false;
for (const option of options as any[]) {
const selected = String(option.value ?? '') === desired;
option.selected = selected;
if (selected) {
matched = true;
}
}
if (!matched && options.length > 0) {
(options[0] as any).selected = true;
}
},
});
}
function createClient() {
const calls: Array<{ method: string; params?: Record<string, unknown> }> = [];
const sessions = [
{
id: 'ws:alpha',
frontend: 'ws',
messageCount: 5,
config: { modelTier: 'default', queue: { mode: 'collect', cap: 10 } },
lastMessageAt: Date.now(),
},
{
id: 'telegram:bravo',
frontend: 'telegram',
messageCount: 2,
config: { modelTier: 'fast', queue: { mode: 'interrupt', cap: 5 } },
lastMessageAt: Date.now() - 1000,
},
];
const client = {
async call(method: string, params?: Record<string, unknown>) {
calls.push({ method, params });
if (method === 'sessions.list') {
return { sessions };
}
if (method === 'sessions.history') {
return {
messages: [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'world' },
],
};
}
if (method === 'sessions.delete') {
return { success: true };
}
return null;
},
};
return { client, calls };
}
describe('SessionsPage wiring', () => {
let root: any;
let windowObj: any;
let SessionsPage: SessionsModule['SessionsPage'];
beforeEach(async () => {
vi.resetModules();
const { document, window } = parseHTML('<!doctype html><html><body><div id="root"></div></body></html>') as unknown as {
document: any;
window: any;
};
(globalThis as any).document = document;
(globalThis as any).window = window;
(globalThis as any).confirm = vi.fn(() => true);
(globalThis as any).alert = vi.fn();
root = document.getElementById('root');
windowObj = window;
installSelectValueShim(windowObj);
// @ts-expect-error JS module without declaration file.
const mod = await import('./sessions.js') as unknown as SessionsModule;
SessionsPage = mod.SessionsPage;
});
afterEach(() => {
SessionsPage.teardown();
delete (globalThis as any).document;
delete (globalThis as any).window;
delete (globalThis as any).confirm;
delete (globalThis as any).alert;
});
it('wires filters, view, delete, and refresh', async () => {
const { client, calls } = createClient();
await SessionsPage.render(root, client);
expect(calls.some((entry) => entry.method === 'sessions.list')).toBe(true);
const frontend = root.querySelector('#sessions-frontend-filter');
const telegramOpt = frontend.querySelector('option[value="telegram"]');
if (telegramOpt) {
telegramOpt.selected = true;
}
frontend.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
await Promise.resolve();
const inactive = root.querySelector('#sessions-include-inactive');
inactive.checked = false;
inactive.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
await Promise.resolve();
const refresh = root.querySelector('#sessions-refresh-btn');
refresh.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const viewBtn = root.querySelector('.session-view-btn');
viewBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const deleteBtn = root.querySelector('.session-delete-btn');
deleteBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const listCalls = calls.filter((entry) => entry.method === 'sessions.list');
expect(listCalls.length).toBeGreaterThanOrEqual(4);
expect(listCalls.some((entry) => entry.params?.frontend === 'telegram')).toBe(true);
expect(listCalls.some((entry) => entry.params?.includePersisted === false)).toBe(true);
expect(calls.some((entry) => entry.method === 'sessions.history')).toBe(true);
expect(calls.some((entry) => entry.method === 'sessions.delete')).toBe(true);
expect((globalThis as any).confirm).toHaveBeenCalled();
});
});
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseHTML } from 'linkedom';
const mockIsPushSupported = vi.fn(() => true);
const mockGetPushStatus = vi.fn(async () => ({
supported: true,
permission: 'granted',
subscribed: false,
enabled: true,
configured: true,
message: null,
}));
const mockEnablePushNotifications = vi.fn(async () => ({}));
const mockDisablePushNotifications = vi.fn(async () => ({}));
vi.mock('../lib/pwa.js', () => ({
isPushSupported: mockIsPushSupported,
getPushStatus: mockGetPushStatus,
enablePushNotifications: mockEnablePushNotifications,
disablePushNotifications: mockDisablePushNotifications,
}));
type SettingsModule = {
SettingsPage: {
render: (el: unknown, client: unknown) => Promise<void>;
teardown: () => void;
};
};
function createClient() {
const calls: Array<{ method: string; params?: Record<string, unknown> }> = [];
const client = {
async call(method: string, params?: Record<string, unknown>) {
calls.push({ method, params });
if (method === 'config.get') {
return {
automation: {
delivery_mode: 'shared_session',
daily_briefing: {
enabled: true,
output: { channel: 'telegram', peer: '1001' },
},
},
memory: {
daily_log: { enabled: true },
proactive_extract: { enabled: true, min_tool_calls: 2 },
},
tts: {
enabled: false,
enabled_channels: ['telegram'],
},
hooks: {
confirm: ['tool:group:fs/**/*'],
log: ['tool:web.*'],
silent: [],
},
};
}
if (method === 'tools.list') {
return { tools: [{ name: 'file.read', description: 'Read file' }] };
}
if (method === 'system.services') {
return { services: [{ name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }] };
}
if (method === 'config.patch') {
return { applied: Object.keys((params?.patches ?? {}) as Record<string, unknown>), rejected: [], persisted: true };
}
return null;
},
};
return { client, calls };
}
describe('SettingsPage wiring', () => {
let root: any;
let windowObj: any;
let SettingsPage: SettingsModule['SettingsPage'];
beforeEach(async () => {
vi.resetModules();
mockIsPushSupported.mockClear();
mockGetPushStatus.mockClear();
mockEnablePushNotifications.mockClear();
mockDisablePushNotifications.mockClear();
const { document, window } = parseHTML('<!doctype html><html><body><div id="root"></div></body></html>') as unknown as {
document: any;
window: any;
};
(globalThis as any).document = document;
(globalThis as any).window = window;
root = document.getElementById('root');
windowObj = window;
// @ts-expect-error JS module without declaration file.
const mod = await import('./settings.js') as unknown as SettingsModule;
SettingsPage = mod.SettingsPage;
});
afterEach(() => {
SettingsPage.teardown();
delete (globalThis as any).document;
delete (globalThis as any).window;
});
it('wires assistant mode + hook + push actions', async () => {
const { client, calls } = createClient();
await SettingsPage.render(root, client);
expect(root.querySelector('#assistant-mode-save')).toBeTruthy();
expect(root.querySelector('#hooks-save')).toBeTruthy();
expect(root.querySelector('#push-enable')).toBeTruthy();
expect(root.querySelector('#push-disable')).toBeTruthy();
root.querySelector('#assist-delivery-announce').checked = true;
root.querySelector('#assist-daily-briefing').checked = false;
root.querySelector('#assist-memory-daily').checked = false;
root.querySelector('#assist-memory-proactive').checked = true;
root.querySelector('#assist-memory-min-tools').value = '6';
root.querySelector('#assist-tts-enabled').checked = true;
root.querySelector('#assist-tts-channels').value = 'telegram, discord';
root.querySelector('#assist-briefing-channel').value = 'discord';
root.querySelector('#assist-briefing-peer').value = '98765';
root.querySelector('#assistant-mode-save').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const assistantPatch = calls.find((entry) => entry.method === 'config.patch' && Object.prototype.hasOwnProperty.call(entry.params?.patches ?? {}, 'automation.delivery_mode'));
expect(assistantPatch).toBeTruthy();
root.querySelector('#hooks-confirm').value = 'tool:group:fs/**/*\ntool:group:web/**/*';
root.querySelector('#hooks-log').value = 'tool:web.search';
root.querySelector('#hooks-silent').value = 'tool:cron.trigger';
root.querySelector('#hooks-save').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const hookPatch = calls.find((entry) => entry.method === 'config.patch' && Object.prototype.hasOwnProperty.call(entry.params?.patches ?? {}, 'hooks.confirm'));
expect(hookPatch).toBeTruthy();
root.querySelector('#push-enable').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
root.querySelector('#push-disable').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
expect(mockEnablePushNotifications).toHaveBeenCalledTimes(1);
expect(mockDisablePushNotifications).toHaveBeenCalledTimes(1);
expect(mockGetPushStatus).toHaveBeenCalled();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseHTML } from 'linkedom';
type UsageModule = {
UsagePage: {
render: (el: unknown, client: unknown) => Promise<void>;
teardown: () => void;
};
};
function createClient() {
const calls: Array<{ method: string; params?: Record<string, unknown> }> = [];
const client = {
async call(method: string, params?: Record<string, unknown>) {
calls.push({ method, params });
if (method === 'system.tokenUsage') {
return {
sessions: [
{
sessionId: 'ws:alpha',
total: { inputTokens: 100, outputTokens: 50, calls: 3, estimatedCost: 0.02 },
delegation: { fast: { inputTokens: 80, outputTokens: 40 } },
},
],
};
}
if (method === 'system.contextUsage') {
return {
sessions: [
{ sessionId: 'ws:alpha', budget: { usagePct: 40.5, estimatedTokens: 150, contextWindow: 1000 } },
],
};
}
return null;
},
};
return { client, calls };
}
describe('UsagePage wiring', () => {
let root: any;
let windowObj: any;
let UsagePage: UsageModule['UsagePage'];
beforeEach(async () => {
vi.resetModules();
const { document, window } = parseHTML('<!doctype html><html><body><div id="root"></div></body></html>') as unknown as {
document: any;
window: any;
};
(globalThis as any).document = document;
(globalThis as any).window = window;
root = document.getElementById('root');
windowObj = window;
// @ts-expect-error JS module without declaration file.
const mod = await import('./usage.js') as unknown as UsageModule;
UsagePage = mod.UsagePage;
});
afterEach(() => {
UsagePage.teardown();
vi.useRealTimers();
delete (globalThis as any).document;
delete (globalThis as any).window;
});
it('wires refresh button and auto-refresh timer', async () => {
vi.useFakeTimers();
const { client, calls } = createClient();
await UsagePage.render(root, client);
expect(calls.filter((entry) => entry.method === 'system.tokenUsage').length).toBe(1);
expect(calls.filter((entry) => entry.method === 'system.contextUsage').length).toBe(1);
root.querySelector('#usage-refresh-btn').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
expect(calls.filter((entry) => entry.method === 'system.tokenUsage').length).toBe(2);
expect(calls.filter((entry) => entry.method === 'system.contextUsage').length).toBe(2);
await vi.advanceTimersByTimeAsync(30000);
await Promise.resolve();
expect(calls.filter((entry) => entry.method === 'system.tokenUsage').length).toBe(3);
expect(calls.filter((entry) => entry.method === 'system.contextUsage').length).toBe(3);
UsagePage.teardown();
await vi.advanceTimersByTimeAsync(60000);
expect(calls.filter((entry) => entry.method === 'system.tokenUsage').length).toBe(3);
});
});