From 387906ce4dbaacc3f21796a96d849b76848478b2 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 21 Feb 2026 22:36:21 -0800 Subject: [PATCH] Add web UI form wiring regression tests and preserve dashboard draft state --- docs/plans/state.json | 29 +- src/gateway/ui/pages/chat.test.ts | 188 ++++++++++++ src/gateway/ui/pages/dashboard.js | 100 ++++++- src/gateway/ui/pages/dashboard.test.ts | 380 +++++++++++++++++++++++++ src/gateway/ui/pages/sessions.test.ts | 157 ++++++++++ src/gateway/ui/pages/settings.test.ts | 149 ++++++++++ src/gateway/ui/pages/usage.test.ts | 94 ++++++ 7 files changed, 1093 insertions(+), 4 deletions(-) create mode 100644 src/gateway/ui/pages/chat.test.ts create mode 100644 src/gateway/ui/pages/dashboard.test.ts create mode 100644 src/gateway/ui/pages/sessions.test.ts create mode 100644 src/gateway/ui/pages/settings.test.ts create mode 100644 src/gateway/ui/pages/usage.test.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index 6e7d267..b79d7c5 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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", diff --git a/src/gateway/ui/pages/chat.test.ts b/src/gateway/ui/pages/chat.test.ts new file mode 100644 index 0000000..5b6a8b0 --- /dev/null +++ b/src/gateway/ui/pages/chat.test.ts @@ -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; + 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) { + const handlers = new Map 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 }> = []; + const streamCalls: Array<{ method: string; params?: Record }> = []; + const cancelGate: { resolve?: () => void } = {}; + + const client = { + async call(method: string, params?: Record) { + 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) { + 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('
') 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); + }); +}); diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 443eb42..e0a50cb 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -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 '
No recent save action.
'; @@ -1044,7 +1118,7 @@ function updateAssistantHealth(configData) {
-
@@ -1053,7 +1127,7 @@ function updateAssistantHealth(configData) {
${escapeHtml(councilSummary)}
-
@@ -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; }, }; diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts new file mode 100644 index 0000000..347bd7e --- /dev/null +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -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; + teardown: () => void; + }; +}; + +function deepClone(value: unknown) { + return JSON.parse(JSON.stringify(value)); +} + +function setByPath(target: Record, path: string, value: unknown) { + const parts = path.split('.'); + let cursor: Record = 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; + } + 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 }>, + }; + + const client = { + async call(method: string, params?: Record) { + 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; + for (const [key, value] of Object.entries(patches)) { + setByPath(state.config as Record, 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('
') 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(); + 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(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); + }); +}); diff --git a/src/gateway/ui/pages/sessions.test.ts b/src/gateway/ui/pages/sessions.test.ts new file mode 100644 index 0000000..94d9431 --- /dev/null +++ b/src/gateway/ui/pages/sessions.test.ts @@ -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; + 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 }> = []; + 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) { + 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('
') 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(); + }); +}); diff --git a/src/gateway/ui/pages/settings.test.ts b/src/gateway/ui/pages/settings.test.ts new file mode 100644 index 0000000..77288c5 --- /dev/null +++ b/src/gateway/ui/pages/settings.test.ts @@ -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; + teardown: () => void; + }; +}; + +function createClient() { + const calls: Array<{ method: string; params?: Record }> = []; + const client = { + async call(method: string, params?: Record) { + 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), 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('
') 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(); + }); +}); diff --git a/src/gateway/ui/pages/usage.test.ts b/src/gateway/ui/pages/usage.test.ts new file mode 100644 index 0000000..ee4a2ed --- /dev/null +++ b/src/gateway/ui/pages/usage.test.ts @@ -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; + teardown: () => void; + }; +}; + +function createClient() { + const calls: Array<{ method: string; params?: Record }> = []; + const client = { + async call(method: string, params?: Record) { + 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('
') 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); + }); +});