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.
|