/** * Tests for the expandable observation detail in chat.js live steps. * * Observation progress events carry the (bounded) full tool output in * `data.content` alongside the one-line `data.message`. The live step * handler must append the detail beneath the message β€” mirroring what * `_compose_chat_step_content` persists to chat_progress_steps on the * backend, so the live accordion and a post-reload session render * identically (symmetry invariant). Non-observation phases must NOT * compose: `metadata["content"]` is reused there for large payloads * (full synthesized answers) that don't belong in step rows. * * `handleProgressUpdate` is private to the chat IIFE, so we drive it the * way production does: load a session whose messages response reports an * in-progress research, capture the progress callback that chat.js hands * to window.socket.subscribeToResearch, and invoke it with progress * events. */ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; const SESSION_ID = 'sess-1'; const RESEARCH_ID = 'research-1'; function buildChatDom() { // Static literal (no interpolation) so no-unsanitized/property is // satisfied; SESSION_ID must match this meta content. document.head.innerHTML = ` `; document.body.innerHTML = `
`; } const json = (payload) => ({ ok: true, status: 200, json: async () => payload, }); const flush = async (n = 10) => { for (let i = 0; i < n; i++) await Promise.resolve(); }; /** The step elements inside the live accordion. */ function liveSteps() { return document.querySelectorAll( '.ldr-chat-steps-group[data-live="true"] .ldr-chat-message-step' ); } describe('chat.js β€” live step observation detail (data.content)', () => { let progressCb; beforeEach(async () => { vi.resetModules(); progressCb = null; buildChatDom(); window.api = { getCsrfToken: () => '' }; window.ui = { renderMarkdown: (t) => t }; window.socket = { subscribeToResearch: vi.fn((id, cb) => { progressCb = cb; }), unsubscribeFromResearch: vi.fn(), getSocketInstance: vi.fn(() => ({ on: vi.fn(), off: vi.fn(), })), }; // Session restore: one prior user message + an in-progress // research, so loadSession() subscribes to progress events. globalThis.fetch = vi.fn(async (url) => { const u = String(url); if (u.includes(`/api/chat/sessions/${SESSION_ID}/messages`)) { return json({ success: true, messages: [ { id: 'm1', role: 'user', message_type: 'chat', content: 'hello', created_at: '2026-07-11T00:00:00Z', }, ], has_more: false, in_progress_research_id: RESEARCH_ID, }); } if (u.includes(`/api/chat/sessions/${SESSION_ID}`)) { return json({ success: true, session: { id: SESSION_ID, title: 'Test session' }, }); } return json({ success: true, sessions: [] }); }); await import('@js/components/chat.js'); await flush(); expect(window.socket.subscribeToResearch).toHaveBeenCalledWith( RESEARCH_ID, expect.any(Function) ); expect(progressCb).toBeTypeOf('function'); }); afterEach(() => { delete globalThis.fetch; }); it('appends data.content beneath the message for observation steps', () => { const message = 'πŸ“„ From the web (SearXNG): [1] Some Title (http://a.com) Snip'; const detail = '[1] Some Title (http://a.com)\nThe full snippet body with much more text.'; progressCb({ research_id: RESEARCH_ID, phase: 'observation', message, content: detail, progress: 42, }); const steps = liveSteps(); expect(steps.length).toBe(1); const text = steps[0].querySelector('.ldr-chat-message-text').textContent; expect(text).toBe(message + '\n\n' + detail); }); it('keeps the bare message when an observation has no content', () => { const message = 'πŸ“„ From PubMed: no results'; progressCb({ research_id: RESEARCH_ID, phase: 'observation', message, progress: 42, }); const steps = liveSteps(); expect(steps.length).toBe(1); expect( steps[0].querySelector('.ldr-chat-message-text').textContent ).toBe(message); }); it('does NOT compose content for non-observation step phases', () => { const message = 'Generating final answer…'; progressCb({ research_id: RESEARCH_ID, phase: 'output_generation', message, content: 'the entire synthesized answer β€” must not land in the step', progress: 90, }); const steps = liveSteps(); expect(steps.length).toBe(1); expect( steps[0].querySelector('.ldr-chat-message-text').textContent ).toBe(message); }); it('keeps the thinking-bubble preview on the short message only', () => { progressCb({ research_id: RESEARCH_ID, phase: 'observation', message: 'πŸ“„ From arXiv: [2] Paper', content: '[2] Paper (http://arxiv.org/x)\nAbstract text', progress: 42, }); const preview = document.querySelector('.ldr-chat-thinking-text'); expect(preview).not.toBeNull(); expect(preview.textContent).toBe('πŸ“„ From arXiv: [2] Paper'); expect(preview.textContent).not.toContain('Abstract text'); }); }); describe('chat.js β€” reloaded step icons use the persisted phase', () => { /** * Persisted step rows now carry composed observation detail β€” raw web * text that can contain words like "failed". The reload icon must come * from the row's `phase` field, not the content heuristic, so a * successful result row never renders with the error icon. */ function stepIcons() { return [ ...document.querySelectorAll( '.ldr-chat-steps-group .ldr-chat-message-step .ldr-chat-message-avatar i' ), ].map((i) => i.className); } beforeEach(async () => { vi.resetModules(); buildChatDom(); window.api = { getCsrfToken: () => '' }; window.ui = { renderMarkdown: (t) => t }; window.socket = { subscribeToResearch: vi.fn(), unsubscribeFromResearch: vi.fn(), getSocketInstance: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })), }; // Completed session: no in-progress research, two persisted steps β€” // one observation whose detail contains failure-y web text, one // legacy row without a phase (pre-phase-column fallback). globalThis.fetch = vi.fn(async (url) => { const u = String(url); if (u.includes(`/api/chat/sessions/${SESSION_ID}/messages`)) { return json({ success: true, messages: [ { id: 'm1', role: 'user', message_type: 'chat', content: 'hello', created_at: '2026-07-11T00:00:00Z', }, { id: 'step-1', role: 'assistant', message_type: 'step', phase: 'observation', content: 'πŸ“„ From PubMed: [1] Titration study\n\n' + '[1] Titration study (http://p.com)\n' + 'The first titration attempt failed; the ' + 'second failed titration was repeated.', created_at: '2026-07-11T00:00:01Z', }, { id: 'step-2', role: 'assistant', message_type: 'step', content: 'Starting research: "hello"', created_at: '2026-07-11T00:00:02Z', }, ], has_more: false, in_progress_research_id: null, }); } if (u.includes(`/api/chat/sessions/${SESSION_ID}`)) { return json({ success: true, session: { id: SESSION_ID, title: 'Test session' }, }); } return json({ success: true, sessions: [] }); }); await import('@js/components/chat.js'); await flush(); }); afterEach(() => { delete globalThis.fetch; }); it('uses the phase icon for observation rows despite "failed" in the detail', () => { const icons = stepIcons(); expect(icons.length).toBe(2); expect(icons[0]).toContain('fa-eye'); // observation, NOT fa-circle-xmark expect(icons[0]).not.toContain('fa-circle-xmark'); }); it('falls back to the content heuristic for rows without a phase', () => { const icons = stepIcons(); expect(icons[1]).toContain('fa-play'); // "starting research" heuristic }); });