Add web UI form wiring regression tests and preserve dashboard draft state
This commit is contained in:
+28
-1
@@ -1,8 +1,35 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"updated_at": "2026-02-21",
|
||||
"updated_at": "2026-02-22",
|
||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||
"plans": {
|
||||
"web-ui-form-wiring-regression-tests": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-22",
|
||||
"updated": "2026-02-22",
|
||||
"summary": "Added page-level web UI regression coverage for interactive form/button wiring across dashboard, settings, sessions, usage, and chat pages, including assistant control draft-preservation under periodic refresh and key action dispatch paths (`config.patch`, `tools.invoke`, session actions, token usage refresh, chat send/cancel/search).",
|
||||
"files_modified": [
|
||||
"src/gateway/ui/pages/dashboard.test.ts",
|
||||
"src/gateway/ui/pages/settings.test.ts",
|
||||
"src/gateway/ui/pages/sessions.test.ts",
|
||||
"src/gateway/ui/pages/usage.test.ts",
|
||||
"src/gateway/ui/pages/chat.test.ts",
|
||||
"docs/plans/state.json"
|
||||
],
|
||||
"test_status": "pnpm typecheck + pnpm test:run src/gateway/ui/pages/dashboard.test.ts src/gateway/ui/pages/settings.test.ts src/gateway/ui/pages/sessions.test.ts src/gateway/ui/pages/usage.test.ts src/gateway/ui/pages/chat.test.ts passing"
|
||||
},
|
||||
"dashboard-assistant-controls-draft-preservation": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-22",
|
||||
"updated": "2026-02-22",
|
||||
"summary": "Fixed dashboard assistant control state resets during periodic slow refreshes by preserving unsaved local input/select/checkbox draft values across re-renders (with TTL expiry), including council dropdowns and model-tier selectors.",
|
||||
"files_modified": [
|
||||
"src/gateway/ui/pages/dashboard.js",
|
||||
"src/gateway/ui/pages/dashboard.test.ts",
|
||||
"docs/plans/state.json"
|
||||
],
|
||||
"test_status": "pnpm test:run src/gateway/ui/pages/dashboard.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"slash-command-parity-and-authoritative-tools": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-21",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user