From 618a787b3607b04db35cbb700c02431b33246c5e Mon Sep 17 00:00:00 2001 From: th Date: Wed, 15 Apr 2026 19:30:43 +0200 Subject: [PATCH] tests: UI + API smoke tests for chat plugin Covers the flyout lifecycle (footer button, toggle, header-back visibility tracking thread/list view), the pick-view detour, DOM structural invariants (peer strip inside thread view, form/input/send present) and the three read-only chat endpoints. Send/receive requires a real relay and is intentionally skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/plugins/tests/chat.test.js | 102 ++++++++++++++++++++++++++++++ public/plugins/tests/tests.js | 1 + 2 files changed, 103 insertions(+) create mode 100644 public/plugins/tests/chat.test.js diff --git a/public/plugins/tests/chat.test.js b/public/plugins/tests/chat.test.js new file mode 100644 index 0000000..d227ada --- /dev/null +++ b/public/plugins/tests/chat.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, click, $, sleep, waitFor } from './runner.js?v=4'; + +// Chat plugin tests — UI-focused. No send/receive here: that would +// require a real relay, and the user's "no mocks" rule means we don't +// fake one. Runs against the real /api/chat endpoints, which respond +// cleanly on a fresh install (empty conversation list). + +const ensureFlyoutClosed = async () => { + const f = $('#chat-flyout'); + if (!f) return; + if (!f.classList.contains('hidden')) { + click('#chat-close-btn'); + await waitFor(() => f.classList.contains('hidden')); + } +}; + +describe('Chat: flyout', () => { + it('footer button is injected', () => { + expect($('#chat-footer-btn')).toExist(); + }); + + it('toggles open and closed', async () => { + await ensureFlyoutClosed(); + const f = $('#chat-flyout'); + click('#chat-footer-btn'); + await waitFor(() => !f.classList.contains('hidden')); + expect(f.classList.contains('hidden')).toBe(false); + click('#chat-close-btn'); + await waitFor(() => f.classList.contains('hidden')); + expect(f.classList.contains('hidden')).toBe(true); + }); + + it('header-back visibility matches active view', async () => { + await ensureFlyoutClosed(); + click('#chat-footer-btn'); + await waitFor(() => !$('#chat-flyout').classList.contains('hidden')); + // Wait for loadConversations + initial view pick to settle. + await sleep(150); + const back = $('#chat-header-back'); + const threadShown = !$('#chat-thread-view').classList.contains('hidden'); + const listShown = !$('#chat-list-view').classList.contains('hidden'); + // Exactly one of thread / list should be active here (pick view + // is only reached via the + button). + expect(threadShown || listShown).toBe(true); + if (threadShown) expect(back.classList.contains('hidden')).toBe(false); + if (listShown) expect(back.classList.contains('hidden')).toBe(true); + await ensureFlyoutClosed(); + }); + + it('new-conversation button opens pick view', async () => { + await ensureFlyoutClosed(); + click('#chat-footer-btn'); + await waitFor(() => !$('#chat-flyout').classList.contains('hidden')); + await sleep(100); + click('#chat-new-btn'); + await sleep(50); + expect($('#chat-pick-view').classList.contains('hidden')).toBe(false); + expect($('#chat-list-view').classList.contains('hidden')).toBe(true); + expect($('#chat-thread-view').classList.contains('hidden')).toBe(true); + // Header back is only for thread view — not needed when pick has + // its own dedicated back button. + expect($('#chat-header-back').classList.contains('hidden')).toBe(true); + click('#chat-pick-back'); + await sleep(50); + expect($('#chat-pick-view').classList.contains('hidden')).toBe(true); + await ensureFlyoutClosed(); + }); +}); + +describe('Chat: thread DOM structure', () => { + it('peer-strip element lives inside thread view', () => { + const strip = $('#chat-thread-peers'); + expect(strip).toExist(); + expect(strip.closest('#chat-thread-view')).toExist(); + }); + + it('thread form, input and send button exist', () => { + expect($('#chat-thread-form')).toExist(); + expect($('#chat-thread-input')).toExist(); + expect($('#chat-thread-send')).toExist(); + }); +}); + +describe('Chat: API', () => { + it('/api/chat/conversations returns expected shape', async () => { + const res = await fetch('api/chat/conversations').then(r => r.json()); + expect(Array.isArray(res.conversations)).toBe(true); + expect(typeof res.total_unread).toBe('number'); + }); + + it('/api/chat/messages without peer returns 400', async () => { + const r = await fetch('api/chat/messages'); + expect(r.status).toBe(400); + }); + + it('/api/chat/messages with unknown peer returns empty array', async () => { + const res = await fetch('api/chat/messages?peer=__test_no_such_peer__') + .then(r => r.json()); + expect(Array.isArray(res.messages)).toBe(true); + expect(res.messages.length).toBe(0); + }); +}); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index 1014f2d..c7afd95 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -54,6 +54,7 @@ const FILES = [ './asset-picker.test.js', './terminal.test.js', './theme.test.js', + './chat.test.js', ]; // Await all imports so the runner can start in a deterministic state.